react-native-image-stitcher 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +21 -0
  4. package/README.md +189 -0
  5. package/RNImageStitcher.podspec +76 -0
  6. package/android/build.gradle +224 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/cpp/CMakeLists.txt +124 -0
  9. package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
  10. package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
  11. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
  12. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
  13. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
  14. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
  15. package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
  16. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
  17. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
  18. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
  19. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
  20. package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
  21. package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
  22. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
  23. package/cpp/ar_frame_pose.h +63 -0
  24. package/cpp/keyframe_gate.cpp +927 -0
  25. package/cpp/keyframe_gate.hpp +240 -0
  26. package/cpp/stitcher.cpp +2207 -0
  27. package/cpp/stitcher.hpp +275 -0
  28. package/dist/ar/useARSession.d.ts +102 -0
  29. package/dist/ar/useARSession.js +133 -0
  30. package/dist/camera/ARCameraView.d.ts +93 -0
  31. package/dist/camera/ARCameraView.js +170 -0
  32. package/dist/camera/Camera.d.ts +134 -0
  33. package/dist/camera/Camera.js +688 -0
  34. package/dist/camera/CameraShutter.d.ts +80 -0
  35. package/dist/camera/CameraShutter.js +237 -0
  36. package/dist/camera/CameraView.d.ts +65 -0
  37. package/dist/camera/CameraView.js +117 -0
  38. package/dist/camera/CaptureControlsBar.d.ts +87 -0
  39. package/dist/camera/CaptureControlsBar.js +82 -0
  40. package/dist/camera/CaptureHeader.d.ts +62 -0
  41. package/dist/camera/CaptureHeader.js +81 -0
  42. package/dist/camera/CapturePreview.d.ts +70 -0
  43. package/dist/camera/CapturePreview.js +188 -0
  44. package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
  45. package/dist/camera/CaptureStatusOverlay.js +326 -0
  46. package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
  47. package/dist/camera/CaptureThumbnailStrip.js +177 -0
  48. package/dist/camera/IncrementalPanGuide.d.ts +83 -0
  49. package/dist/camera/IncrementalPanGuide.js +267 -0
  50. package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
  51. package/dist/camera/PanoramaBandOverlay.js +399 -0
  52. package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
  53. package/dist/camera/PanoramaConfirmModal.js +128 -0
  54. package/dist/camera/PanoramaGuidance.d.ts +79 -0
  55. package/dist/camera/PanoramaGuidance.js +246 -0
  56. package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
  57. package/dist/camera/PanoramaSettingsModal.js +611 -0
  58. package/dist/camera/ViewportCropOverlay.d.ts +46 -0
  59. package/dist/camera/ViewportCropOverlay.js +67 -0
  60. package/dist/camera/useCapture.d.ts +111 -0
  61. package/dist/camera/useCapture.js +160 -0
  62. package/dist/camera/useDeviceOrientation.d.ts +48 -0
  63. package/dist/camera/useDeviceOrientation.js +131 -0
  64. package/dist/camera/useVideoCapture.d.ts +79 -0
  65. package/dist/camera/useVideoCapture.js +151 -0
  66. package/dist/index.d.ts +26 -0
  67. package/dist/index.js +39 -0
  68. package/dist/quality/normaliseOrientation.d.ts +36 -0
  69. package/dist/quality/normaliseOrientation.js +62 -0
  70. package/dist/quality/runQualityCheck.d.ts +41 -0
  71. package/dist/quality/runQualityCheck.js +98 -0
  72. package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
  73. package/dist/sensors/useIMUTranslationGate.js +235 -0
  74. package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
  75. package/dist/stitching/IncrementalStitcherView.js +157 -0
  76. package/dist/stitching/incremental.d.ts +930 -0
  77. package/dist/stitching/incremental.js +133 -0
  78. package/dist/stitching/stitchFrames.d.ts +55 -0
  79. package/dist/stitching/stitchFrames.js +56 -0
  80. package/dist/stitching/stitchVideo.d.ts +119 -0
  81. package/dist/stitching/stitchVideo.js +57 -0
  82. package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
  83. package/dist/stitching/useIncrementalJSDriver.js +199 -0
  84. package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
  85. package/dist/stitching/useIncrementalStitcher.js +172 -0
  86. package/dist/types.d.ts +58 -0
  87. package/dist/types.js +15 -0
  88. package/ios/Package.swift +72 -0
  89. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
  90. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
  91. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
  92. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
  93. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
  94. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
  95. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
  96. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
  97. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
  98. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
  99. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
  101. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
  102. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
  105. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
  106. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
  107. package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
  108. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
  109. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
  110. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
  111. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
  112. package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
  113. package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
  114. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
  115. package/package.json +73 -0
  116. package/react-native.config.js +34 -0
  117. package/scripts/opencv-version.txt +1 -0
  118. package/scripts/postinstall-fetch-binaries.js +286 -0
  119. package/src/ar/useARSession.ts +210 -0
  120. package/src/camera/.gitkeep +0 -0
  121. package/src/camera/ARCameraView.tsx +256 -0
  122. package/src/camera/Camera.tsx +1053 -0
  123. package/src/camera/CameraShutter.tsx +292 -0
  124. package/src/camera/CameraView.tsx +157 -0
  125. package/src/camera/CaptureControlsBar.tsx +204 -0
  126. package/src/camera/CaptureHeader.tsx +184 -0
  127. package/src/camera/CapturePreview.tsx +318 -0
  128. package/src/camera/CaptureStatusOverlay.tsx +391 -0
  129. package/src/camera/CaptureThumbnailStrip.tsx +277 -0
  130. package/src/camera/IncrementalPanGuide.tsx +328 -0
  131. package/src/camera/PanoramaBandOverlay.tsx +498 -0
  132. package/src/camera/PanoramaConfirmModal.tsx +206 -0
  133. package/src/camera/PanoramaGuidance.tsx +327 -0
  134. package/src/camera/PanoramaSettingsModal.tsx +1357 -0
  135. package/src/camera/ViewportCropOverlay.tsx +81 -0
  136. package/src/camera/useCapture.ts +279 -0
  137. package/src/camera/useDeviceOrientation.ts +140 -0
  138. package/src/camera/useVideoCapture.ts +236 -0
  139. package/src/index.ts +53 -0
  140. package/src/quality/.gitkeep +0 -0
  141. package/src/quality/normaliseOrientation.ts +79 -0
  142. package/src/quality/runQualityCheck.ts +131 -0
  143. package/src/sensors/useIMUTranslationGate.ts +347 -0
  144. package/src/stitching/.gitkeep +0 -0
  145. package/src/stitching/IncrementalStitcherView.tsx +198 -0
  146. package/src/stitching/incremental.ts +1021 -0
  147. package/src/stitching/stitchFrames.ts +88 -0
  148. package/src/stitching/stitchVideo.ts +153 -0
  149. package/src/stitching/useIncrementalJSDriver.ts +273 -0
  150. package/src/stitching/useIncrementalStitcher.ts +252 -0
  151. package/src/types.ts +78 -0
@@ -0,0 +1,1021 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Incremental panorama-stitching native module bindings.
4
+ *
5
+ * See docs/site-content/design/2026-04-30-realtime-incremental-stitching.md
6
+ * for the architectural rationale. This file is the type-safe JS
7
+ * wrapper around the RN bridge; `useIncrementalStitcher` is the hook
8
+ * host code consumes; `IncrementalStitcherView` renders the live
9
+ * panorama preview.
10
+ */
11
+
12
+ import { NativeModules, NativeEventEmitter } from 'react-native';
13
+ import type { EmitterSubscription, NativeModule } from 'react-native';
14
+
15
+
16
+ /**
17
+ * Per-frame outcome returned by the engine. Mirrors the iOS
18
+ * `RLISFrameOutcome` enum and the Android equivalent — numeric
19
+ * values are kept identical so the JS layer doesn't branch on
20
+ * platform.
21
+ */
22
+ export enum IncrementalOutcome {
23
+ /** High-confidence accept — silent UX. */
24
+ AcceptedHigh = 0,
25
+ /** Accept with marginal confidence — show subtle yellow ring. */
26
+ AcceptedMedium = 1,
27
+ /** Frame too close to the previous accepted — wait for more pan. */
28
+ SkippedTooClose = 2,
29
+ /** Frame too far past the overlap window — show "slow down" hint. */
30
+ RejectedTooFar = 3,
31
+ /** Too few feature matches — show "scene too uniform" hint. */
32
+ RejectedSceneUniform = 4,
33
+ /** RANSAC failed or homography degenerate — show "alignment lost". */
34
+ RejectedAlignmentLost = 5,
35
+ /** AR tracking quality was poor — skip silently. */
36
+ SkippedTrackingPoor = 6,
37
+ /**
38
+ * V12.11 Step D — operator panned BACKWARDS past the running
39
+ * max along the pan axis. Engine has SKIPPED the paste; host
40
+ * should auto-finalize the capture (the most useful pano is
41
+ * what we have so far at the high-water mark). Emitted by
42
+ * the rectilinear engine only — cylindrical engines tolerate
43
+ * reverse motion via their warp pipeline.
44
+ */
45
+ RejectedReverseDirection = 7,
46
+ /**
47
+ * V16 — pose-driven keyframe gate rejected the frame because it
48
+ * overlapped >= (1 − overlapThreshold) with the last accepted
49
+ * keyframe. Host should keep showing the same status — user is
50
+ * mid-pan between two natural keyframe boundaries. No UX hint
51
+ * needed (this is the expected behaviour 90% of the time when
52
+ * pose-based selection is on).
53
+ */
54
+ SkippedKeyframeOverlap = 8,
55
+ /**
56
+ * V16 — pose-driven keyframe gate rejected the frame because the
57
+ * capture has hit `keyframeMaxCount` (default 6). Host should
58
+ * auto-finalize since no more frames will be accepted.
59
+ */
60
+ SkippedKeyframeMaxReached = 9,
61
+ }
62
+
63
+
64
+ export interface IncrementalState {
65
+ /**
66
+ * Path to the latest panorama snapshot JPEG (file path, no
67
+ * `file://` prefix). Present only on accepted frames where a
68
+ * snapshot was written. Renders directly via RN's `<Image>`.
69
+ */
70
+ panoramaPath: string | null;
71
+ /** Width of the latest snapshot in pixels. 0 if no snapshot. */
72
+ width: number;
73
+ /** Height of the latest snapshot in pixels. 0 if no snapshot. */
74
+ height: number;
75
+ /** Total frames accepted into the panorama since `start()`. */
76
+ acceptedCount: number;
77
+ /** What happened to the most recent ARFrame the engine processed. */
78
+ outcome: IncrementalOutcome;
79
+ /** Composite confidence score [0, 1] for the most recent frame. */
80
+ confidence: number;
81
+ /**
82
+ * Estimated FoV-overlap with the previous accepted frame, in
83
+ * percent. Useful for pacing UX. -1 if first frame.
84
+ */
85
+ overlapPercent: number;
86
+ /** Wall-clock ms the most recent addPixelBuffer call took. */
87
+ processingMs: number;
88
+ /**
89
+ * V12.12 — engine-detected physical orientation, set from
90
+ * `R_panToCam` at first frame. TRUE for landscape capture
91
+ * (vertical pan), FALSE for portrait (horizontal pan). Stays
92
+ * at the FIRST-FRAME determination thereafter.
93
+ *
94
+ * **This is the single source of truth for orientation across
95
+ * the SDK + host.** JS-side hooks (e.g. `useDeviceOrientation`,
96
+ * `useWindowDimensions`) are unreliable when iOS interface-
97
+ * orientation lock is on; pose-derived detection is. UI
98
+ * components that need to know orientation (band overlay, dim
99
+ * bars, pan guide) MUST consume `state.isLandscape` rather
100
+ * than re-detecting.
101
+ *
102
+ * Defaults to `false` before the first frame is accepted (no
103
+ * pose to detect from yet). Hosts can fall back to a JS hook
104
+ * for the brief pre-capture preview if needed.
105
+ */
106
+ isLandscape: boolean;
107
+ /**
108
+ * V12.14.9 — running painted extent along the pan axis, in canvas
109
+ * pixels. Trailing edge of the most-recently-pasted slit. Pre-
110
+ * first-frame this is 0. After first-frame ≈ slit pan-axis size
111
+ * (~756 px for default kPanAxisFractionRect=0.7 on 1080-row sensor).
112
+ * Grows toward `panExtent` as the user pans.
113
+ *
114
+ * Used by the band overlay to compute `fillRatio = paintedExtent /
115
+ * panExtent`, which sizes the thumbnail proportional to pan
116
+ * progress. Replaces the V12.13 aspect-ratio-based formula that
117
+ * required the user to pan >1920 px before the thumb visibly grew.
118
+ *
119
+ * Defaults to 0 before the first frame.
120
+ */
121
+ paintedExtent: number;
122
+ /**
123
+ * V12.14.9 — total canvas pan-axis extent in pixels (engine config,
124
+ * default 5000). Constant for the lifetime of a capture. Used as
125
+ * the denominator for the fillRatio computation. Defaults to 0
126
+ * before the first frame.
127
+ */
128
+ panExtent: number;
129
+ /**
130
+ * V16 — pose-driven keyframe gate's max-keyframes cap for the
131
+ * current capture. When > 0, the JS status pill renders
132
+ * `Keyframes: acceptedCount / keyframeMax` so the operator can see
133
+ * the budget remaining. When 0, the keyframe gate is disabled
134
+ * (frameSelectionMode = "time-based") and the host should display
135
+ * acceptedCount as a raw counter without a denominator.
136
+ *
137
+ * Defaults to 0 before the first frame and stays 0 for the entire
138
+ * capture when the gate is disabled.
139
+ */
140
+ keyframeMax: number;
141
+ /**
142
+ * V16 Phase 1 — populated by the `batch-keyframe` engine on each
143
+ * keyframe-accepted event. Path to the JPEG saved under the
144
+ * session directory. Host can render a thumbnail from this path
145
+ * in the live-frame strip overlay so the operator sees what the gate accepted.
146
+ * Undefined for other engines and for non-accept events.
147
+ */
148
+ batchKeyframeThumbnailPath?: string;
149
+ /**
150
+ * V16 Phase 1 — zero-based keyframe index assigned by the
151
+ * collector when the JPEG was saved. Useful as a stable React key
152
+ * for the thumbnail strip.
153
+ */
154
+ batchKeyframeIndex?: number;
155
+ /**
156
+ * 2026-05-16 — realtime+batch fusion (Option A "Replace on
157
+ * completion"). True between the moment a hybrid-engine
158
+ * `finalize()` resolves with the live panorama AND the async
159
+ * refinement of the same keyframes through cv::Stitcher completes.
160
+ *
161
+ * During the refinement window the host should render a small
162
+ * "Refining…" pill so the operator knows a higher-quality result
163
+ * is on the way; the operator can continue browsing / starting
164
+ * another capture while the refinement runs.
165
+ *
166
+ * Stays false (or undefined) when the auto-trigger is a no-op —
167
+ * e.g. when the hybrid engine had nothing on disk to refine.
168
+ *
169
+ * See: docs/site-content/design/2026-05-14-realtime-batch-fusion.md
170
+ */
171
+ isRefining?: boolean;
172
+ /**
173
+ * 2026-05-16 — realtime+batch fusion (Option A). Path to the
174
+ * refined panorama JPEG written by `cv::Stitcher`. Emitted in a
175
+ * single state event when the async refinement completes (after
176
+ * the hybrid engine's `finalize()` has already returned the live
177
+ * `panoramaPath`).
178
+ *
179
+ * Host code should treat this as the canonical panorama for the
180
+ * remainder of the audit-capture flow when present, falling back
181
+ * to `panoramaPath` when absent. The refined output replaces the
182
+ * live output in-place — operator UX-wise it's the same JPEG slot,
183
+ * just sharper.
184
+ *
185
+ * Undefined when no refinement is in flight, when refinement fails,
186
+ * or when the auto-trigger was skipped because there were no
187
+ * keyframes on disk.
188
+ */
189
+ refinedPanoramaPath?: string;
190
+ }
191
+
192
+
193
+ export interface IncrementalStartOptions {
194
+ /**
195
+ * 2026-05-18 (Issue #2 regression fix) — frame source for the
196
+ * iOS engine.
197
+ *
198
+ * - 'arSession' (default) — engine registers as the
199
+ * ARSession's frame consumer. Use in AR captures. iOS
200
+ * bridge.start() requires `RNSARSession.start()` to
201
+ * have already been called.
202
+ *
203
+ * - 'jsDriver' — engine skips AR-session registration; JS
204
+ * feeds frames via `processFrameAtPath`. Use in iOS non-AR
205
+ * captures (vision-camera + gyro). No AR session required.
206
+ *
207
+ * Android ignores this option — its engine always accepts
208
+ * JS-driven frames.
209
+ */
210
+ frameSourceMode?: 'arSession' | 'jsDriver';
211
+ /** Compose-resolution width in pixels (default 720 for portrait, 960 for landscape). */
212
+ composeWidth?: number;
213
+ /** Compose-resolution height in pixels (default 960 for portrait, 720 for landscape). */
214
+ composeHeight?: number;
215
+ /** Pre-allocated canvas width (default 5000). */
216
+ canvasWidth?: number;
217
+ /** Pre-allocated canvas height (default 5000 — square so either
218
+ * pan axis fits without runtime grow logic). */
219
+ canvasHeight?: number;
220
+ /** Feather-blend band width in pixels (default 20). Unused after
221
+ * v5 hard-seam switch but kept for backwards compatibility. */
222
+ featherPx?: number;
223
+ /** JPEG quality for live snapshots [1, 100] (default 75). */
224
+ snapshotJpegQuality?: number;
225
+ /**
226
+ * Emit a snapshot on every Nth accepted frame (default 1 — every
227
+ * accept). Higher values save disk I/O at the cost of staler
228
+ * preview. Useful on lower-end Android.
229
+ */
230
+ snapshotEveryNAccepts?: number;
231
+ /**
232
+ * Per-frame rotation applied before any stitching work, in degrees.
233
+ * Must be one of `0`, `90`, `180`, `270`. Compute from the device's
234
+ * physical orientation:
235
+ * portrait → 90 (CW; panorama grows horizontally
236
+ * for the user's left↔right pan)
237
+ * portrait-upside-down → 270 (CCW)
238
+ * landscape-left → 0 (sensor already aligned)
239
+ * landscape-right → 0 (sensor already aligned)
240
+ *
241
+ * Default `90` because most shelf scans are done in portrait.
242
+ *
243
+ * @deprecated Use `captureOrientation` instead — it carries the
244
+ * landscape-left vs landscape-right distinction we need for
245
+ * correct output rotation per the two-modes spec
246
+ * (see memory/ar-stitching-two-modes.md). Once Phase 3 of the
247
+ * captureOrientation migration lands this field is removed.
248
+ */
249
+ frameRotationDegrees?: 0 | 90 | 180 | 270;
250
+ /**
251
+ * Physical phone orientation at capture start, classified by the
252
+ * accelerometer (`useDeviceOrientation`). Drives the output
253
+ * panorama's bake-rotation per the two supported capture modes:
254
+ *
255
+ * AR-STITCHING-TWO-MODES — see memory/ar-stitching-two-modes.md
256
+ *
257
+ * Mode A — landscape phone + vertical pan from top:
258
+ * 'landscape-left' → bake-rotate output 90° CCW
259
+ * 'landscape-right' → bake-rotate output 90° CW
260
+ * (mirror images of each other: world-up is on opposite
261
+ * sensor edges between L-left and L-right, so the
262
+ * rotations are opposite to land world-up at output-top)
263
+ *
264
+ * Mode B — portrait phone + horizontal pan from left:
265
+ * 'portrait' → no bake-rotation
266
+ * 'portrait-upside-down' → bake-rotate output 180°
267
+ *
268
+ * Any other combination of phone orientation + pan direction is a
269
+ * user deviation, not a supported mode. The engine still runs
270
+ * for unsupported combinations but the output rotation is a best-
271
+ * effort: the same mapping is applied.
272
+ *
273
+ * Defaults to `'portrait'` (Mode B start state) if not supplied.
274
+ */
275
+ captureOrientation?:
276
+ | 'portrait'
277
+ | 'portrait-upside-down'
278
+ | 'landscape-left'
279
+ | 'landscape-right';
280
+ /**
281
+ * Engine mode (V15):
282
+ * 'hybrid' — Whole-frame projection + feature matching;
283
+ * planar projection by default (was cylindrical
284
+ * before V15; cylindrical can be re-enabled via
285
+ * `config.hybridProjection`).
286
+ * 'slitscan-rotate' — V13.0a baseline (pose-only paste, rectilinear,
287
+ * first-painted-wins) + 1D NCC for rotation
288
+ * wobble correction.
289
+ * 'slitscan-both' — DEFAULT. V13.0a baseline + no accept gate
290
+ * + feather blend. Iterate via `config`
291
+ * overrides (toggle triangulation / 2D NCC /
292
+ * RANSAC homography / paint mode etc.).
293
+ *
294
+ * Backward compat: 'firstwins-rectilinear' is mapped to
295
+ * 'slitscan-rotate'. Legacy 'firstwins', 'firstwins-zoomed', and
296
+ * 'slitscan' fall back to 'slitscan-both' with a deprecation warning
297
+ * in the native log.
298
+ */
299
+ engine?: 'hybrid' | 'slitscan-rotate' | 'slitscan-both' | 'batch-keyframe' |
300
+ // Deprecated — kept for type-compat during the V14 → V15 transition:
301
+ 'firstwins' | 'firstwins-zoomed' | 'firstwins-rectilinear' | 'slitscan';
302
+ /**
303
+ * V15 — per-stage correction config overrides. Mode-driven defaults
304
+ * are applied first (see RLISStitcherConfig +configForMode:); fields
305
+ * present here override those defaults. Any field may be omitted to
306
+ * accept the default.
307
+ */
308
+ config?: Partial<StitcherConfig>;
309
+ }
310
+
311
+
312
+ /**
313
+ * V15 — per-stage stitcher correction config. Each field is a runtime
314
+ * toggle/value; the native engine reads it on every ingest call. All
315
+ * fields optional (omit to accept the engine-mode default).
316
+ */
317
+ export interface StitcherConfig {
318
+ // Slit shaping (slit-scan engine only)
319
+ /** Fraction of pan-axis the rectilinear slit retains per frame.
320
+ * Range 0.10 – 0.70, default 0.30 in V15 slit-scan modes. */
321
+ kPanAxisFractionRect: number;
322
+ /** Minimum pan-axis advance (px) before a frame is accepted.
323
+ * 0 = accept on every consumeFrame (Apple-dense slit-scan, V15
324
+ * default). 50 = V13.0g default. */
325
+ kMinAcceptDeltaPx: number;
326
+
327
+ // Per-stage correction toggles
328
+ /** V13.0e+ ORB triangulation + median-Z parallax correction. */
329
+ enableTriangulation: boolean;
330
+ /** V13.0g per-accept incremental Δt accumulator on top of triangulation. */
331
+ enableTriAccumulator: boolean;
332
+ /** V15 1D NCC perpendicular-axis wobble correction (slitscan-rotate
333
+ * default). Independent of the other correction stages. */
334
+ enable1dNcc: boolean;
335
+ /** 1D NCC search radius in pixels (5 – 60). */
336
+ nccSearchRadius1d: number;
337
+ /** V13.0g 2D NCC fine-alignment after triangulation. */
338
+ enable2dNcc: boolean;
339
+ /** V14.0a RANSAC homography per slit + cv::warpPerspective. When
340
+ * enabled and successful, supersedes the rectangular paste path. */
341
+ enableRansacHomography: boolean;
342
+
343
+ // Paint mode (slit-scan engine only)
344
+ /** 'FirstPaintedWins' protects already-painted pixels (V13.0e+
345
+ * default). 'FeatherBlend' alpha-blends new content into already-
346
+ * painted overlap pixels (V13.0d-style; V15 slitscan-both default). */
347
+ paintMode: 'FirstPaintedWins' | 'FeatherBlend';
348
+
349
+ // Hybrid engine
350
+ /** 'Cylindrical' (V12.x – V14.0a behaviour) or 'Planar' (V15 default;
351
+ * cv::detail::PlaneWarper). Planar is well-behaved for pans <60°. */
352
+ hybridProjection: 'Cylindrical' | 'Planar';
353
+
354
+ /** V15.0c — where on the camera frame the per-accept sliver is taken.
355
+ * 'Center' (V13.x default), 'Bottom' (leading edge for top-to-bottom
356
+ * pan), or 'Top' (leading edge for bottom-to-top pan). */
357
+ sliverPosition: 'Center' | 'Bottom' | 'Top';
358
+
359
+ /** V15.0c — when true, the FIRST accepted frame paints the entire
360
+ * camera frame at canvas (0, 0); subsequent frames still use the
361
+ * configured sliver clip. Default false; set true when sliverPosition
362
+ * is Bottom/Top so the canvas is anchored with full-frame content. */
363
+ firstFrameFullFrame: boolean;
364
+
365
+ /** **DEPRECATED in V15.0d** — use `planeSource` instead.
366
+ *
367
+ * V15.0b boolean toggle for the plane-projected stitch path.
368
+ * Kept for backward compat: when `planeSource` is left at its
369
+ * default (Disabled), `useDetectedPlane = true` upgrades it to
370
+ * ARKitDetected. New callers should set `planeSource` directly. */
371
+ useDetectedPlane: boolean;
372
+
373
+ /** V15.0d — source of the plane used by the V15.0b plane-projected
374
+ * stitch path.
375
+ *
376
+ * - 'Disabled' (default): no plane projection; slit-scan path runs.
377
+ * - 'ARKitDetected': use ARKit's first vertical plane that aligns
378
+ * with the camera's view direction (filter threshold:
379
+ * `arkitPlaneAlignmentThreshold`). Falls back to slit-scan
380
+ * silently when no aligned plane is found.
381
+ * - 'Virtual': synthesize a plane at first frame: origin =
382
+ * camera_pos + `virtualPlaneDepthMeters` × camera_forward;
383
+ * normal = -camera_forward. Always works; no ARKit dependency.
384
+ *
385
+ * Field testing showed ARKit plane detection often picks the WRONG
386
+ * surface (side wall, doorframe) — Virtual mode is the safer
387
+ * default for arbitrary scenes. ARKitDetected wins when ARKit
388
+ * finds the correct fixture face. */
389
+ planeSource: 'Disabled' | 'ARKitDetected' | 'Virtual';
390
+
391
+ /** V15.0d — depth (metres) at which the synthetic plane is placed
392
+ * in front of the camera when `planeSource = Virtual`. Set to
393
+ * the user's typical scan distance. Range 0.3 – 5.0 m. Default
394
+ * 1.5 m. */
395
+ virtualPlaneDepthMeters: number;
396
+
397
+ /** V15.0d — minimum dot product between an ARKit-detected plane's
398
+ * surface normal and the camera's facing direction for the plane
399
+ * to be accepted (when `planeSource = ARKitDetected`). 1.0 =
400
+ * plane perfectly facing camera; 0.0 = plane edge-on; negative
401
+ * = facing away. Range 0.0 – 1.0. Default 0.6 (≈53° max angle
402
+ * off-camera). */
403
+ arkitPlaneAlignmentThreshold: number;
404
+
405
+ /** V15.0g — how the plane-projection helper renders each frame onto
406
+ * the canvas. Affects ARKitDetected and Virtual modes; ignored
407
+ * when planeSource = Disabled.
408
+ *
409
+ * - 'Trapezoidal' (V15.0b legacy): geometrically-correct 3D
410
+ * raycast. Each camera pixel maps to its plane intersection.
411
+ * Result is a trapezoid that grows distorted with tilt
412
+ * (cooler-bottom-2.3×-wider-than-top problem).
413
+ * - 'Rectified' (V15.0g default): camera frame pasted as a clean
414
+ * rectangle around its plane-projected anchor. Eliminates the
415
+ * tilt-induced trapezoidal distortion at the cost of strict 3D-
416
+ * correctness — the camera's per-pixel perspective stays inside
417
+ * the rectangle but doesn't reconcile across tilts. */
418
+ planeProjectionStyle: 'Trapezoidal' | 'Rectified';
419
+
420
+ /** V15.0d — 2D NCC search half-window in pixels. Was hardcoded
421
+ * ±12 in V15.0c.4. Smaller = less wandering on repetitive
422
+ * textures (peg holes, slatted panels), but easier to miss the
423
+ * true overlap when pose noise is high. Range 4 – 30. Default
424
+ * 12. */
425
+ nccSearchMargin2d: number;
426
+
427
+ /** V15.0d — 2D NCC confidence threshold below which the correction
428
+ * is rejected. Was hardcoded 0.75 in V15.0c.4. Higher = stricter,
429
+ * fewer false matches on repetitive textures, but more frames
430
+ * where NCC silently doesn't fire. Range 0.30 – 0.99. Default
431
+ * 0.75. */
432
+ nccConfidenceThreshold2d: number;
433
+
434
+ /** V15.0d (1B) — exponential-moving-average smoothing on 2D NCC
435
+ * corrections. When enabled, the applied correction is
436
+ * `α × current + (1−α) × prev` instead of just `current`. Damps
437
+ * single-frame snaps to spurious peaks. Default false. */
438
+ enableNcc2dEmaSmoothing: boolean;
439
+
440
+ /** V15.0d — EMA weight on the CURRENT-frame NCC correction
441
+ * (1 − α weight on the previous correction). Range 0.05 – 0.95.
442
+ * Default 0.4 (60% prev / 40% current — heavy damping). */
443
+ ncc2dEmaAlpha: number;
444
+
445
+ /** V15.0d (1C) — pan-axis-aware 2D NCC. When enabled, the cross-
446
+ * axis (perpendicular to pan) NCC correction is clamped tighter
447
+ * than the pan-axis (since 1D NCC + pose already handle cross-
448
+ * axis wobble). Default false. */
449
+ enableNcc2dPanAxisLock: boolean;
450
+
451
+ /** V15.0d — cross-axis clamp (pixels) for the pan-axis-aware mode.
452
+ * Range 0 – 30. Default 5. */
453
+ ncc2dCrossAxisLockPx: number;
454
+
455
+ // Frame selection (V16)
456
+
457
+ /** V16 — how the engine decides which ARFrames to ingest.
458
+ *
459
+ * - 'time-based' (default): every frame the AR delegate delivers
460
+ * is forwarded to the engine; the engine's existing internal
461
+ * gate (kMinAcceptDeltaPx, time-throttled snapshot) decides
462
+ * accept/reject. Backward-compatible with all prior versions.
463
+ * - 'pose-based': frames are pre-filtered by a KeyframeGate. A
464
+ * frame is forwarded only when its projection onto the latched
465
+ * ARKit plane has at least `keyframeOverlapThreshold` of NEW
466
+ * area vs the last accepted keyframe. Bounded to
467
+ * `keyframeMaxCount` frames per capture. Mirrors how iOS
468
+ * Camera and Samsung Pano actually work. Requires
469
+ * `planeSource` != 'Disabled'; degrades silently to passthrough
470
+ * otherwise.
471
+ * - 'flow-based' (V16 A2): same KeyframeGate cap + threshold but
472
+ * the novelty metric is sparse-Lucas-Kanade optical flow on
473
+ * full-frame content rather than plane-projected polygon
474
+ * overlap. Plane-independent — no `planeSource` requirement;
475
+ * scale-invariant — works regardless of latched plane size.
476
+ * Falls back to angular delta when KLT tracking fails. */
477
+ frameSelectionMode: 'time-based' | 'pose-based' | 'flow-based';
478
+
479
+ /** V16 — required fraction of NEW content per keyframe (pose-based
480
+ * AND flow-based modes share this knob). Range 0.10 – 0.80.
481
+ * Default 0.40. Lower = more keyframes per capture + more
482
+ * redundancy + better feature matching but higher memory.
483
+ * Higher = fewer keyframes + less margin for blurry frames. */
484
+ keyframeOverlapThreshold: number;
485
+
486
+ /** V16 — hard cap on keyframes per capture (pose-based + flow-
487
+ * based modes). Range 3 – 10. Default 6 (matches Samsung's
488
+ * typical behaviour). Once reached, all subsequent frames are
489
+ * rejected and the host should auto-finalize. */
490
+ keyframeMaxCount: number;
491
+
492
+ /** V16 A2 — flow-based mode: max Shi-Tomasi corners detected per
493
+ * accepted keyframe. Range 50 – 300, default 150. Higher =
494
+ * more robust median pan-axis displacement; slower detect. */
495
+ flowMaxCorners: number;
496
+
497
+ /** V16 A2 — flow-based mode: Shi-Tomasi quality level (0, 1].
498
+ * Range 0.005 – 0.05, default 0.01. Lower = more (weaker)
499
+ * corners detected. */
500
+ flowQualityLevel: number;
501
+
502
+ /** V16 A2 — flow-based mode: minimum pixel distance between
503
+ * detected corners at WORKING resolution (gate downscales the
504
+ * frame to 720 px longest side internally). Range 5 – 20,
505
+ * default 10. */
506
+ flowMinDistance: number;
507
+
508
+ /** V16 — flow-based mode: translation budget in CENTIMETRES. When
509
+ * > 0, the gate force-accepts a frame if the camera has moved
510
+ * more than this distance (3D Euclidean) since the last accepted
511
+ * keyframe — even when novelty < keyframeOverlapThreshold.
512
+ * Bounds the parallax between adjacent keyframes so the
513
+ * downstream stitcher's matcher (AffineBestOf2NearestMatcher
514
+ * post-V16) sees inputs it can fit a homography to.
515
+ *
516
+ * Range 0 – 100 cm, default 0 = disabled. Recommended starting
517
+ * value once enabled: 8 cm. Set higher for fast pans, lower for
518
+ * precise multi-pass scans. */
519
+ flowMaxTranslationCm: number;
520
+
521
+ /** V16 — flow-based mode: percentile used to aggregate tracked-
522
+ * feature absolute displacements into the novelty estimate.
523
+ * Pre-V16 used median (0.50); 0.85 picks up the LEADING EDGE
524
+ * motion sooner — better matches user perception of "new
525
+ * content visible". Range 0.50 – 0.99, default 0.85. Set
526
+ * closer to 1.0 for more sensitive (catches even small leading-
527
+ * edge motion), closer to 0.5 for more conservative (needs
528
+ * half the features to have moved). */
529
+ flowNoveltyPercentile: number;
530
+
531
+ /** V16 — flow-based mode: eval-throttle. Gate evaluation runs
532
+ * every Nth consumeFrame from the AR delegate instead of every
533
+ * frame. Pure CPU/battery savings — doesn't change WHICH frames
534
+ * are accepted, just samples less frequently. Trade-off: up to
535
+ * N-1 frames of latency between "user moved enough" and "frame
536
+ * accepted". Range 1 – 10, default 1 (every frame).
537
+ *
538
+ * Recommended for long captures on devices that overheat: set 3
539
+ * for ~3× CPU reduction on the per-frame gate path. Eval cost
540
+ * is ~3-5 ms per call at 60 fps, so 3-5 ms / 16 ms ≈ 20-30 %
541
+ * AR-delegate budget freed when N=3. */
542
+ flowEvalEveryNFrames: number;
543
+
544
+ // cv::Stitcher pipeline knobs (batch-keyframe engine, V16 Phase 1.fix3)
545
+
546
+ /** V16 Phase 1.fix3 — `cv::Stitcher`'s warper choice for the
547
+ * batch-keyframe finalize.
548
+ *
549
+ * - 'plane': flat output, best when camera angles stay near
550
+ * perpendicular to scene. Unbounded bbox for tilt-heavy pans
551
+ * (umatrix.cpp:710 crash).
552
+ * - 'cylindrical': wraps onto a cylinder with FIXED vertical axis.
553
+ * Good for horizontal pans; unrolls vertical pans along the wrong
554
+ * axis (output looks rotated 90°).
555
+ * - 'spherical' (recommended for batch-keyframe): rotationally
556
+ * symmetric, handles any pan direction. Mild uniform curvature.
557
+ *
558
+ * Native default is "spherical" specifically for batch-keyframe
559
+ * (overrides this prop's value in `IncrementalStitcher.start`
560
+ * unless explicitly provided). Same field is also consumed by the
561
+ * legacy non-AR batch path (`BatchStitcher.stitchVideo`) where
562
+ * the historical default is "plane". */
563
+ warperType: 'plane' | 'cylindrical' | 'spherical';
564
+
565
+ /** V16 Phase 1.fix3 — `cv::Stitcher`'s blender choice for the
566
+ * batch-keyframe finalize.
567
+ * - 'multiband' (default): Laplacian-pyramid blending; best seam
568
+ * quality, higher peak memory.
569
+ * - 'feather': single-band alpha; faster, no halo artifacts when
570
+ * exposure varies. */
571
+ blenderType: 'multiband' | 'feather';
572
+
573
+ /** V16 Phase 1.fix3 — `cv::Stitcher`'s seam-finder choice.
574
+ * - 'graphcut' (default): cv::detail::GraphCutSeamFinder; optimal
575
+ * seams, pairs with multi-band, holds all warped frames in memory.
576
+ * - 'skip': stream warp+feed, lower peak memory, fine with feather. */
577
+ seamFinderType: 'graphcut' | 'skip';
578
+
579
+ /** V16 Phase 1b.fix5c — toggle the max-inscribed-rectangle crop in
580
+ * the batch-keyframe finalize pipeline. When false (default), the
581
+ * output is cropped to `cv::boundingRect(mask)` only — preserves
582
+ * all stitched content at the cost of possible black corners
583
+ * where cv::Stitcher's projection didn't fill. When true, the
584
+ * pipeline additionally runs `MaxInscribedRectFromMask` +
585
+ * morphological-close + column-projection second pass for a
586
+ * clean-cornered rectangle (but can over-aggressively shrink the
587
+ * output on lopsided masks). Surfaced as a settings toggle so
588
+ * the operator can A/B the two crop strategies on real scenes. */
589
+ enableMaxInscribedRectCrop: boolean;
590
+
591
+ /** 2026-05-14 — `cv::Stitcher` pipeline mode for the batch-keyframe
592
+ * finalize step.
593
+ *
594
+ * 'auto' (default) — Engine picks PANORAMA or SCANS at finalize
595
+ * time based on accumulated translation vs
596
+ * rotation magnitudes between first and last
597
+ * accepted keyframe poses (AR mode) or the
598
+ * windowed IMU integration (non-AR mode).
599
+ * 'panorama' — Force cv::Stitcher::PANORAMA (rotation-only
600
+ * pipeline; ORB + HomographyBasedEstimator +
601
+ * BundleAdjusterRay + SphericalWarper).
602
+ * Best for rotate-in-place panoramas.
603
+ * WARNING: on translation-heavy input the
604
+ * rotation-only model diverges and the
605
+ * compositing canvas can grow to multi-GB
606
+ * (Android lmkd kill observed 2026-05-14).
607
+ * 'scans' — Force cv::Stitcher::SCANS (affine pipeline;
608
+ * AffineBestOf2NearestMatcher +
609
+ * BundleAdjusterAffine + PlaneWarper).
610
+ * Best for walk-past-shelf captures. Canvas
611
+ * size bounded by sum of frame areas.
612
+ *
613
+ * iOS note: as of 2026-05-14 iOS uses a hand-rolled PANORAMA-style
614
+ * pipeline regardless of this setting. Setting is passed through
615
+ * to iOS but currently ignored; Android honours it via
616
+ * `image_stitcher_jni.cpp` + `IncrementalStitcher.kt`. */
617
+ stitchMode: 'auto' | 'panorama' | 'scans';
618
+
619
+ /** 2026-05-14 (revised) — capture source axis.
620
+ *
621
+ * 'ar' — ARKit / ARCore session feeds the engine.
622
+ * 'non-ar' — vision-camera feeds the engine via the gyro-driven
623
+ * Android snapshot loop (or iOS equivalent — see
624
+ * realtime-batch-fusion design doc Out-of-Scope).
625
+ * Lens choice (0.5× / 1×) is handled by the on-screen
626
+ * chip after mount, not by this setting.
627
+ *
628
+ * Native side uses this to:
629
+ * 1. Decide whether the KeyframeGate should DISABLE its angular-
630
+ * delta fallback path. Non-AR has no usable pose data → the
631
+ * angular calc would produce nonsense → set `disableAngularFallback`
632
+ * true on the gate.
633
+ * 2. Decide whether to expect pose updates through the AR delegate
634
+ * path (only meaningful when source='ar').
635
+ *
636
+ * Earlier draft (replaced 2026-05-14) had 4 values:
637
+ * 'ar' | 'wide' | 'ultrawide' | 'auto'. Pre-mount physical-lens
638
+ * selection via vision-camera's `physicalDevices` filter crashed
639
+ * Galaxy A35's CameraCaptureSession with a Parcel exception
640
+ * (physical_camera_id=null in AidlCamera3-Device configureStreams).
641
+ * Switched to post-mount chip-driven lens swap. */
642
+ captureSource: 'ar' | 'non-ar';
643
+ }
644
+
645
+
646
+ export interface IncrementalFinalizeResult {
647
+ /** Path to the final panorama JPEG written to `outputPath`. */
648
+ panoramaPath: string;
649
+ width: number;
650
+ height: number;
651
+ acceptedCount: number;
652
+ /** Frames the engine queue dropped due to backpressure (diagnostic). */
653
+ droppedBackpressure: number;
654
+ /** 2026-05-15 (D) — batch-keyframe stitcher telemetry. Populated
655
+ * by the cv::Stitcher PANORAMA / SCANS path. Surfaces
656
+ * `leaveBiggestComponent` drops so the host UI can warn the
657
+ * operator when boundary frames were excluded due to weak feature-
658
+ * matching confidence.
659
+ *
660
+ * Undefined on the realtime (hybrid / firstwins) engines — those
661
+ * don't run leaveBiggestComponent.
662
+ *
663
+ * framesRequested: number of keyframes handed to the
664
+ * stitcher (== acceptedCount for batch).
665
+ * framesIncluded: number of keyframes retained after
666
+ * leaveBiggestComponent pruning.
667
+ * framesDropped: framesRequested − framesIncluded.
668
+ * > 0 means the stitcher silently
669
+ * dropped boundary frames; surface a
670
+ * "Stitched N of M frames" toast.
671
+ * finalConfidenceThresh: panoConfidenceThresh value used on
672
+ * the successful attempt (1.0 / 0.5 /
673
+ * 0.3 — see image_stitcher_jni.cpp
674
+ * retry loop). Useful for debugging
675
+ * scenes that consistently need a
676
+ * lower threshold. */
677
+ framesRequested?: number;
678
+ framesIncluded?: number;
679
+ framesDropped?: number;
680
+ finalConfidenceThresh?: number;
681
+ }
682
+
683
+
684
+ /**
685
+ * 2026-05-16 — input to `refinePanorama`. Mirrors the subset of
686
+ * `StitcherConfig` that affects the batch refinement step
687
+ * (`cv::Stitcher` pipeline knobs). All fields optional — when
688
+ * omitted the native side picks production-tested defaults that
689
+ * match the existing batch-keyframe finalize path:
690
+ *
691
+ * warperType = "spherical" (handles any pan direction)
692
+ * blenderType = "multiband"
693
+ * seamFinderType = "graphcut"
694
+ * captureOrientation = "portrait"
695
+ * useInscribedRectCrop = false
696
+ * stitchMode = "auto" (Android only; iOS hand-rolled
697
+ * pipeline is PANORAMA regardless).
698
+ * NOTE: on the explicit `refinePanorama`
699
+ * path, Android collapses "auto" to
700
+ * "scans" — affine, not rotational —
701
+ * because refinement is the slow-path
702
+ * quality bake where SCANS' translation
703
+ * tolerance pays off. The "auto" name is
704
+ * kept for API symmetry with the live
705
+ * pipeline, but it is NOT cv::Stitcher's
706
+ * PANORAMA mode on this path.
707
+ * jpegQuality = 90
708
+ *
709
+ * Resolution budgets (`*ResolMP`) keep cv::Stitcher's staged-pipeline
710
+ * memory bounded — see image_stitcher_jni.cpp on Android and the
711
+ * shared C++ `StitchConfig` for the full rationale. Passing a
712
+ * negative value or omitting the field keeps the per-platform safe
713
+ * default (Android compose-MP cap of 1.0, iOS manual-pipeline cap of
714
+ * 0.6).
715
+ *
716
+ * See: docs/site-content/design/2026-05-14-realtime-batch-fusion.md
717
+ */
718
+ export interface IncrementalRefineOptions {
719
+ /** "plane" | "cylindrical" | "spherical". Default "spherical". */
720
+ warperType?: 'plane' | 'cylindrical' | 'spherical';
721
+ /** "multiband" | "feather". Default "multiband". */
722
+ blenderType?: 'multiband' | 'feather';
723
+ /** "graphcut" | "skip". Default "graphcut". */
724
+ seamFinderType?: 'graphcut' | 'skip';
725
+ /** Drives the OUTPUT bake-rotation. Default "portrait". */
726
+ captureOrientation?:
727
+ | 'portrait'
728
+ | 'portrait-upside-down'
729
+ | 'landscape-left'
730
+ | 'landscape-right';
731
+ /** Crop to max-inscribed rectangle. Default false (bbox crop only). */
732
+ useInscribedRectCrop?: boolean;
733
+ /**
734
+ * Android: `cv::Stitcher` pipeline mode. Default "auto".
735
+ *
736
+ * On the explicit `refinePanorama` path, "auto" silently collapses
737
+ * to "scans" (affine). This is intentional: refinement is the
738
+ * slow-path quality bake where SCANS' translation tolerance gives
739
+ * a noticeably better stitch than PANORAMA's rotation-only model.
740
+ * Pass "panorama" explicitly if you need rotational behaviour.
741
+ *
742
+ * iOS ignores this field — the hand-rolled `cv::detail::*` pipeline
743
+ * in `cpp/stitcher.cpp` is functionally equivalent to PANORAMA
744
+ * regardless of what you pass here.
745
+ */
746
+ stitchMode?: 'auto' | 'panorama' | 'scans';
747
+ /** JPEG quality 1..100, default 90. */
748
+ jpegQuality?: number;
749
+ }
750
+
751
+
752
+ /**
753
+ * 2026-05-16 — result of an explicit `refinePanorama` call. Mirrors
754
+ * `IncrementalFinalizeResult` so host code can treat refined results
755
+ * the same way it treats batch-keyframe finalize results.
756
+ */
757
+ export interface IncrementalRefineResult {
758
+ /** Path to the refined panorama JPEG written to `outputPath`. */
759
+ panoramaPath: string;
760
+ width: number;
761
+ height: number;
762
+ /** Frames the stitcher saw (== framePaths.length). */
763
+ framesRequested: number;
764
+ /** Frames retained after `leaveBiggestComponent` (≤ framesRequested). */
765
+ framesIncluded: number;
766
+ /** framesRequested − framesIncluded. > 0 = some frames dropped. */
767
+ framesDropped: number;
768
+ /** The confidence threshold that succeeded. -1 when not applicable. */
769
+ finalConfidenceThresh: number;
770
+ }
771
+
772
+
773
+ /**
774
+ * V15.0e — ARKit plane detection state, polled by the capture screen
775
+ * UI when planeSource=ARKitDetected. Used to render a status pill:
776
+ *
777
+ * - status === 'searching': no candidate plane seen yet. UI shows
778
+ * a red/amber "Looking for wall plane…" pill and a hint to aim
779
+ * at a textured area for a few seconds.
780
+ * - status === 'evaluating': ARKit found candidate plane(s) but
781
+ * the alignment filter rejected them all. UI shows the
782
+ * bestAlignment so the operator can see they're CLOSE
783
+ * ("plane found but off-axis (best 0.45)") and aim more
784
+ * directly at the wall.
785
+ * - status === 'ready': plane is latched. UI shows green "Plane
786
+ * locked" and enables the Capture (hold-to-scan) button.
787
+ */
788
+ export interface ARPlaneStatus {
789
+ status: 'searching' | 'evaluating' | 'ready';
790
+ hasPlane: boolean;
791
+ /** Best rejected-alignment score seen so far. -1 = no candidate yet.
792
+ * Range [-1, 1]; positive when at least one candidate was evaluated. */
793
+ bestAlignment: number;
794
+ /** Current alignment threshold (matches the engine config). */
795
+ threshold: number;
796
+ }
797
+
798
+
799
+ interface NativeIncrementalModule {
800
+ start(options: IncrementalStartOptions): Promise<{ ok: true }>;
801
+ /**
802
+ * Finalize the running capture and write the final panorama JPEG.
803
+ *
804
+ * `outputPath` (optional) — when empty/omitted the native side
805
+ * creates a path under the app's tmp directory and returns it
806
+ * inside the `panoramaPath` field of the result. Host apps that
807
+ * want the stitched panorama to be USER-VISIBLE (e.g., browsable
808
+ * via iOS Files.app) should pass a path under the app's
809
+ * Documents directory, e.g.
810
+ * `${RNFS.DocumentDirectoryPath}/captures/${auditId}.jpg`
811
+ * (or the platform-equivalent on Android). Two host-side
812
+ * requirements for Files.app exposure on iOS:
813
+ *
814
+ * 1. Info.plist must set `UIFileSharingEnabled = true` so the
815
+ * app's Documents directory is exposed via the Files
816
+ * browser at all.
817
+ * 2. Info.plist must set `LSSupportsOpeningDocumentsInPlace
818
+ * = true` so users can open the files in-place rather than
819
+ * requiring a copy.
820
+ *
821
+ * Frames (intermediate keyframe JPEGs) are saved by the engine
822
+ * to its own private directory and are NOT auto-cleaned — see
823
+ * `cleanupKeyframes` for the GC hook host apps should call on
824
+ * launch or via a lifecycle event.
825
+ */
826
+ finalize(options: {
827
+ outputPath?: string;
828
+ quality?: number;
829
+ /**
830
+ * 2026-05-18 (iOS cross-orientation fix) — JS-supplied current
831
+ * device orientation at finalize time. When provided, the
832
+ * engine uses this for the bake-rotation pass in place of the
833
+ * orientation captured at start(). Closes the cross-orientation
834
+ * hole where the user starts in one orientation and pans/
835
+ * captures in another — the start-time snapshot would otherwise
836
+ * bake to the wrong direction. Valid values match
837
+ * IncrementalStartOptions.captureOrientation; omit/empty to keep
838
+ * legacy start-time behaviour.
839
+ */
840
+ captureOrientation?: string;
841
+ }): Promise<IncrementalFinalizeResult>;
842
+ cancel(): Promise<{ ok: true }>;
843
+ getState(): Promise<IncrementalState | null>;
844
+ /** V15.0e — poll AR plane detection state. Polled at ~2 Hz when
845
+ * planeSource=ARKitDetected so the status pill updates live. */
846
+ getARPlaneStatus(): Promise<ARPlaneStatus>;
847
+ /** V15.0g — clear the latched ARKit plane and re-evaluate all
848
+ * currently-tracked vertical planes against the camera's CURRENT
849
+ * aim, picking the largest plane that passes the alignment
850
+ * threshold. Called by the capture screen on hold-to-scan press
851
+ * so the latched plane reflects what the operator is aiming at
852
+ * right NOW, not whichever plane ARKit noticed first. */
853
+ relatchARPlane(): Promise<{ latched: boolean }>;
854
+ /** V16 — arm the pose-driven keyframe gate to force-accept the
855
+ * next ARFrame regardless of overlap. Called by the capture
856
+ * screen on shutter release so the trailing edge of the scan
857
+ * isn't truncated when the user releases mid-pan. No-op when
858
+ * the gate is disabled (frameSelectionMode = 'time-based'). */
859
+ markNextFrameAsLastKeyframe(): Promise<{ ok: true }>;
860
+ /** V16 Phase 1b.fix2 — poll the process phys_footprint in MB.
861
+ * Backs the on-screen memory debug overlay. Same metric iOS
862
+ * jetsam evaluates against, so the displayed value is the
863
+ * one-true-number for "how close are we to OOM?". Returns -1
864
+ * on task_info failure (very rare). Resolves immediately. */
865
+ getMemoryFootprintMB(): Promise<number>;
866
+ /**
867
+ * 2026-05-16 — realtime+batch fusion API foundation. Run the
868
+ * shared C++ `cv::Stitcher` pipeline over a caller-supplied list
869
+ * of keyframe JPEG paths and write a refined panorama to
870
+ * `outputPath`.
871
+ *
872
+ * Pre-conditions:
873
+ * - `framePaths.length >= 2`
874
+ * - Each path must exist on disk (the native side will read it
875
+ * via cv::imread); rejected otherwise.
876
+ *
877
+ * Per-platform routing:
878
+ * - iOS: `OpenCVStitcher.stitchFramePaths(...)`
879
+ * (manual cv::detail::* pipeline, useManualPipeline=true).
880
+ * - Android: `BatchStitcher.stitchSync(...)` →
881
+ * `image_stitcher_jni.cpp` (high-level
882
+ * cv::Stitcher::create() pipeline).
883
+ *
884
+ * Reuses the same C++ stitcher both platforms use for the
885
+ * batch-keyframe `finalize()` path — so refinement quality on
886
+ * arbitrary keyframe sets matches what the batch-keyframe engine
887
+ * has been producing in production.
888
+ *
889
+ * The auto-trigger inside the hybrid engine's `finalize()` is a
890
+ * separate code path that internally calls `refinePanorama` when
891
+ * keyframes are on disk; host code may also call it explicitly to
892
+ * re-refine after editing the keyframe set.
893
+ */
894
+ refinePanorama(options: {
895
+ framePaths: string[];
896
+ outputPath: string;
897
+ config?: IncrementalRefineOptions;
898
+ }): Promise<IncrementalRefineResult>;
899
+ /** PiP investigation only — write a JS-side message into the
900
+ * Swift-side rlis-debug.log so we get a single timeline. */
901
+ appendDebugLog?(message: string): Promise<{ ok: true }>;
902
+ /**
903
+ * 2026-05-18 (Iss 3) — delete keyframe JPEGs older than the cutoff
904
+ * from the SDK's intermediate-keyframe storage directory.
905
+ *
906
+ * Background: the batch-keyframe capture mode saves accepted
907
+ * frames as JPEGs in a per-session directory (iOS:
908
+ * `Library/Application Support/Captures/{uuid}/`, Android: app's
909
+ * private files dir under `captures/{uuid}/`). These are kept
910
+ * across runs so post-hoc re-stitching is possible from the
911
+ * debug menu — but they accumulate over time and bloat user
912
+ * storage. Host apps should call this on launch or on a
913
+ * lifecycle hook to garbage-collect old sessions.
914
+ *
915
+ * `olderThanMs` is the staleness cutoff in milliseconds. Sessions
916
+ * whose newest file mtime is older than `Date.now() - olderThanMs`
917
+ * are deleted in full. Default if omitted: 24 hours. Pass 0 to
918
+ * delete every keyframe session unconditionally (use with care).
919
+ *
920
+ * Resolves with the count of deleted sessions + total bytes freed,
921
+ * so the host can surface a "freed 42 MB of old captures"
922
+ * confirmation if desired. Rejects on filesystem errors (e.g.,
923
+ * the captures dir does not exist — which is also fine; pass 0
924
+ * sessions back) — implementations should swallow ENOENT-style
925
+ * errors and resolve with zero counts.
926
+ */
927
+ cleanupKeyframes?(options?: {
928
+ olderThanMs?: number;
929
+ }): Promise<{ sessionsDeleted: number; bytesFreed: number }>;
930
+ /**
931
+ * 2026-05-18 (Iss 3) — return the absolute filesystem path of the
932
+ * directory where keyframe JPEGs for the CURRENT (running)
933
+ * capture are being saved. Returns an empty string when no
934
+ * capture is in flight or when the engine isn't using a per-
935
+ * session keyframe directory (e.g., hybrid mode without the
936
+ * batch-keyframe collector).
937
+ *
938
+ * Mainly useful for debugging — e.g., the host can dump the
939
+ * directory's contents to the on-screen log, or copy it to
940
+ * /Documents for post-hoc inspection.
941
+ */
942
+ getKeyframeDir?(): Promise<{ path: string }>;
943
+ }
944
+
945
+
946
+ /**
947
+ * Lazy-resolve the native module. Returns null on platforms that
948
+ * don't have it registered yet (e.g. older builds without the new
949
+ * native code). Callers fall back to the batch stitcher in that
950
+ * case.
951
+ */
952
+ export function getIncrementalNativeModule(): NativeIncrementalModule | null {
953
+ const m = (NativeModules as Record<string, unknown>)['IncrementalStitcher'];
954
+ if (!m || typeof m !== 'object') return null;
955
+ // The cast is safe — RN runtime sees only `Function` for each
956
+ // method but TypeScript's structural type system is happy with
957
+ // a record of any-callable.
958
+ return m as NativeIncrementalModule;
959
+ }
960
+
961
+
962
+ /**
963
+ * Whether the native incremental stitcher is registered and ready.
964
+ * Equivalent to `getIncrementalNativeModule() !== null`; provided
965
+ * as a convenience export so host code reads cleanly.
966
+ */
967
+ export function incrementalStitcherIsAvailable(): boolean {
968
+ return getIncrementalNativeModule() !== null;
969
+ }
970
+
971
+
972
+ /**
973
+ * 2026-05-18 (Iss 3) — host-callable helper to clean up old
974
+ * keyframe sessions. Wraps the native `cleanupKeyframes` with a
975
+ * sensible default (24 hours) and a noop fallback when the native
976
+ * method isn't implemented (older SDK builds).
977
+ *
978
+ * Typical use: call this in App.tsx's mount effect or from a
979
+ * background-task hook so storage stays bounded between captures.
980
+ *
981
+ * Resolves with the count of sessions deleted + bytes freed so the
982
+ * host can log / surface a "cleaned up X MB" message. Never
983
+ * rejects — filesystem failures (including ENOENT on the captures
984
+ * dir) resolve as `{ sessionsDeleted: 0, bytesFreed: 0 }`.
985
+ */
986
+ export async function cleanupOldKeyframes(
987
+ options?: { olderThanMs?: number },
988
+ ): Promise<{ sessionsDeleted: number; bytesFreed: number }> {
989
+ const native = getIncrementalNativeModule();
990
+ if (!native?.cleanupKeyframes) {
991
+ return { sessionsDeleted: 0, bytesFreed: 0 };
992
+ }
993
+ try {
994
+ const olderThanMs = options?.olderThanMs ?? 24 * 3600 * 1000;
995
+ return await native.cleanupKeyframes({ olderThanMs });
996
+ } catch {
997
+ return { sessionsDeleted: 0, bytesFreed: 0 };
998
+ }
999
+ }
1000
+
1001
+
1002
+ /**
1003
+ * Subscribe to per-frame state updates emitted by the native engine.
1004
+ * The returned `EmitterSubscription` MUST be removed when no longer
1005
+ * needed (`subscription.remove()`); leaks here cause memory growth
1006
+ * across captures.
1007
+ */
1008
+ export function subscribeIncrementalState(
1009
+ listener: (state: IncrementalState) => void,
1010
+ ): EmitterSubscription | null {
1011
+ const native = getIncrementalNativeModule();
1012
+ if (!native) return null;
1013
+ // Cast through the structural NativeModule type — the bridge
1014
+ // module IS an RCTEventEmitter at runtime, which exposes
1015
+ // addListener/removeListeners as part of the contract. TS just
1016
+ // can't see the iOS side's class hierarchy.
1017
+ const emitter = new NativeEventEmitter(
1018
+ NativeModules.IncrementalStitcher as unknown as NativeModule,
1019
+ );
1020
+ return emitter.addListener('IncrementalStateUpdate', listener);
1021
+ }