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,960 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn
3
+
4
+ import android.util.Log
5
+ import com.facebook.react.bridge.WritableMap
6
+ // V13.0a — removed unused imports (Calib3d, DMatch, KeyPoint,
7
+ // MatOfByte, MatOfDMatch, MatOfKeyPoint, MatOfPoint2f, BFMatcher,
8
+ // ORB) after the homography revert. CvType + MatOfInt kept because
9
+ // they're still used elsewhere in the engine.
10
+ import org.opencv.core.Core
11
+ import org.opencv.core.CvType
12
+ import org.opencv.core.Mat
13
+ import org.opencv.core.MatOfInt
14
+ import org.opencv.core.Point
15
+ import org.opencv.core.Rect
16
+ import org.opencv.core.Scalar
17
+ import org.opencv.core.Size
18
+ import org.opencv.imgcodecs.Imgcodecs
19
+ import org.opencv.imgproc.Imgproc
20
+ import java.io.File
21
+ import java.util.Locale
22
+ import java.util.concurrent.atomic.AtomicInteger
23
+ import kotlin.math.atan2
24
+ import kotlin.math.cos
25
+ import kotlin.math.floor
26
+ import kotlin.math.max
27
+ import kotlin.math.min
28
+ import kotlin.math.round
29
+ import kotlin.math.sin
30
+ import kotlin.math.sqrt
31
+
32
+ /**
33
+ * Android port of iOS' OpenCVFirstWinsCylindricalStitcher with the
34
+ * full V12.x stack baked in:
35
+ *
36
+ * V12.2 — cylindrical projection (mirror-fixed: theta = atan2(-wx, wz))
37
+ * V12.3 — orientation-aware cylinder axis (transverse for landscape)
38
+ * V12.4 — central 70% (pan) × 85% (perpendicular) post-warp crop
39
+ * V12.6 — orientation detection from R_panToCam at first frame
40
+ * (NOT from JS-passed frameRotationDegrees, which is wrong
41
+ * under iOS interface-orientation lock — Android equivalent
42
+ * is screen-orientation lock; same fix applies)
43
+ * V12.7 — rectilinear path: skip cylindrical warp entirely. First
44
+ * frame pasted raw onto canvas; subsequent frames contribute
45
+ * a narrow central strip placed by pose-delta around the
46
+ * dominant pan axis. First-painted-wins masks the strip.
47
+ *
48
+ * Differences from the iOS engine:
49
+ * - Frames arrive as JPEG paths (vision-camera + gyro driver writes
50
+ * them to disk on each accept), not as raw CVPixelBuffers. We
51
+ * read with Imgcodecs and downsample to compose dims here.
52
+ * - OpenCV Java bindings: cv::Mat is org.opencv.core.Mat with
53
+ * element accessors that return double[] — slower per-element
54
+ * than the C++ at<double>() but only used in setup paths, not
55
+ * the per-pixel inverse-map loop (that's done with put/get
56
+ * bulk float arrays for the remap).
57
+ *
58
+ * What this DOESN'T do (yet, intentional scope):
59
+ * - No KLT optical-flow refinement (iOS hybrid only; firstwins
60
+ * doesn't use it either)
61
+ * - No per-pair gain compensation
62
+ * - No CLAHE finalize (kept simpler to match firstwins minimalism)
63
+ */
64
+ internal class IncrementalFirstwinsEngine(
65
+ val composeWidth: Int,
66
+ val composeHeight: Int,
67
+ val canvasWidth: Int,
68
+ val canvasHeight: Int,
69
+ val snapshotJpegQuality: Int,
70
+ val snapshotEveryNAccepts: Int,
71
+ /// 0/90/180/270 — output rotation applied at finalize for display.
72
+ /// Compute pipeline works in sensor-native landscape compose space.
73
+ /// V12.6: orientation detection no longer uses this; ARKit/ARCore
74
+ /// pose at first frame is ground truth.
75
+ val frameRotationDegrees: Int,
76
+ /// V12.7 Variant B: when true, skip cylindrical warp. See class doc.
77
+ val useRectilinear: Boolean,
78
+ /// Critic #27 fix: cache-dir from the bridge. Snapshot JPEGs are
79
+ /// written here. System.getProperty("java.io.tmpdir") on Android
80
+ /// resolves to /data/local/tmp which is NOT writable by ordinary
81
+ /// apps, so the previous version silently dropped every snapshot.
82
+ val snapshotCacheDir: String,
83
+ ) {
84
+ // V12.12 — canvas allocation:
85
+ // • RECTILINEAR engine: deferred to first-frame branch so the
86
+ // canvas can be sized from the pose-detected orientation.
87
+ // Empty Mats here; the first-frame branch checks
88
+ // `canvas.empty()` and allocates.
89
+ // • CYLINDRICAL engines (hybrid, firstwins): use the
90
+ // constructor's square 5000×5000 canvas as before — they
91
+ // compute paste positions in cylinder coords and don't
92
+ // benefit from orientation-aware sizing.
93
+ private var canvas: Mat = if (useRectilinear) {
94
+ Mat()
95
+ } else {
96
+ Mat.zeros(canvasHeight, canvasWidth, CvType.CV_8UC3)
97
+ }
98
+ private var canvasMask: Mat = if (useRectilinear) {
99
+ Mat()
100
+ } else {
101
+ Mat.zeros(canvasHeight, canvasWidth, CvType.CV_8UC1)
102
+ }
103
+
104
+ // V12.x state — mirrors iOS layout.
105
+ private var firstRotationArkit: Mat = Mat()
106
+ private var rPanToWorld: Mat = Mat()
107
+ private var kCompose: Mat = Mat()
108
+ private var focalCompose: Double = 0.0
109
+ private val mArkitToCv: Mat = Mat(3, 3, CvType.CV_64F).apply {
110
+ // diag(1, -1, -1) — ARKit/ARCore world (Y-up, -Z forward) → OpenCV.
111
+ setTo(Scalar(0.0))
112
+ put(0, 0, 1.0); put(1, 1, -1.0); put(2, 2, -1.0)
113
+ }
114
+ private var canvasOriginCylX: Int = 0
115
+ private var canvasOriginCylY: Int = 0
116
+ /// V12.7 first-frame anchor for rectilinear placement (canvas-pixel).
117
+ private var firstFrameDstX: Int = 0
118
+ private var firstFrameDstY: Int = 0
119
+ /// V12.11 Step D — running max position along the pan axis.
120
+ /// Mirrors iOS' _maxDstX/_maxDstY. Reverse-direction stop
121
+ /// fires when the homography-corrected dst regresses below
122
+ /// max - kReverseStopPx.
123
+ private var maxDstX: Int = 0
124
+ private var maxDstY: Int = 0
125
+ /// V12.6 detected at first frame from R_panToCam.
126
+ private var isLandscape: Boolean = false
127
+
128
+ private var hasFirstFrame: Boolean = false
129
+ private var acceptsSinceSnapshot: Int = 0
130
+ /// Critic #19 fix: AtomicInteger so JS-thread reads from
131
+ /// `getState`/promise resolves see a consistent value.
132
+ private val acceptedCountAtomic = AtomicInteger(0)
133
+ val acceptedCount: Int get() = acceptedCountAtomic.get()
134
+ private var snapshotSeq: Int = 0
135
+ /// Critic #30 fix: cache the painted-region bbox so we don't
136
+ /// re-scan a 25M-px mask N times per accept (writeOut + width
137
+ /// + height + buildState all called boundingRect separately).
138
+ private var cachedBoundingRect: Rect? = null
139
+ var lastState: WritableMap? = null
140
+ private set
141
+
142
+ /// V12.4 slit-scan + long-side clip fractions. Same values as iOS.
143
+ private val kPanStripFraction: Double = 0.70
144
+ private val kLongSideFraction: Double = 0.85
145
+ /// V12.12 — fraction of the PAN AXIS (not the long side) the
146
+ /// rectilinear engine retains per frame. Apple-pano slit-scan:
147
+ /// each frame contributes a narrower-than-frame slit perpendicular
148
+ /// to motion. See iOS `kPanAxisFractionRect` for the full
149
+ /// rationale. Both platforms MUST stay in sync.
150
+ private val kPanAxisFractionRect: Double = 0.70
151
+
152
+ /// V12.12 — pan-axis canvas extent, sized at first-frame
153
+ /// allocation alongside the orientation-detected perpendicular
154
+ /// dim. Default 5000 (max of the constructor canvas dims) —
155
+ /// kept as max-of-args for backwards compatibility with hosts
156
+ /// that pass canvasWidth/Height hints.
157
+ private val canvasPanExtent: Int = max(canvasWidth, canvasHeight)
158
+
159
+ /**
160
+ * Same shape as the V7 IncrementalEngine.addFrameAtPath() so the
161
+ * RN bridge can route frames here without changing the JS contract.
162
+ */
163
+ fun addFrameAtPath(
164
+ path: String,
165
+ qx: Double,
166
+ qy: Double,
167
+ qz: Double,
168
+ qw: Double,
169
+ fx: Double,
170
+ fy: Double,
171
+ cx: Double,
172
+ cy: Double,
173
+ imageWidth: Int,
174
+ imageHeight: Int,
175
+ yaw: Double,
176
+ pitch: Double,
177
+ fovHorizDegrees: Double,
178
+ fovVertDegrees: Double,
179
+ trackingPoor: Boolean,
180
+ ): FrameTelemetry {
181
+ val t0 = System.nanoTime()
182
+ if (trackingPoor) {
183
+ return FrameTelemetry(
184
+ FrameOutcome.SkippedTrackingPoor, -1.0, 0, yaw, pitch,
185
+ msSince(t0),
186
+ isLandscape = isLandscape,
187
+ )
188
+ }
189
+ val cleaned = path.removePrefix("file://")
190
+ val srcRaw = Imgcodecs.imread(cleaned, Imgcodecs.IMREAD_COLOR)
191
+ if (srcRaw.empty()) {
192
+ return FrameTelemetry(
193
+ FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch,
194
+ msSince(t0),
195
+ isLandscape = isLandscape,
196
+ )
197
+ }
198
+ val frameBGR = downsampleToCompose(srcRaw)
199
+ if (frameBGR !== srcRaw) srcRaw.release()
200
+
201
+ val rNew = quaternionToRotationMat(qx, qy, qz, qw)
202
+
203
+ if (!hasFirstFrame) {
204
+ // V11/V12 first-frame setup: build panorama frame, detect
205
+ // orientation, paste/warp the first frame.
206
+ firstRotationArkit = rNew.clone()
207
+ val sx = frameBGR.cols().toDouble() / max(1, imageWidth)
208
+ val sy = frameBGR.rows().toDouble() / max(1, imageHeight)
209
+ kCompose = Mat(3, 3, CvType.CV_64F).apply {
210
+ setTo(Scalar(0.0))
211
+ put(0, 0, fx * sx); put(0, 2, cx * sx)
212
+ put(1, 1, fy * sy); put(1, 2, cy * sy)
213
+ put(2, 2, 1.0)
214
+ }
215
+ focalCompose = sqrt(fx * sx * fy * sy)
216
+
217
+ // Build R_panToWorld from horizontal projection of camera-forward.
218
+ val fwdArkitCam = Mat(3, 1, CvType.CV_64F).apply {
219
+ put(0, 0, 0.0); put(1, 0, 0.0); put(2, 0, -1.0)
220
+ }
221
+ val fwdWorld = Mat()
222
+ Core.gemm(firstRotationArkit, fwdArkitCam, 1.0, Mat(), 0.0, fwdWorld)
223
+ val fwx = fwdWorld[0, 0][0]
224
+ val fwz = fwdWorld[2, 0][0]
225
+ val horiz = sqrt(fwx * fwx + fwz * fwz)
226
+ if (horiz < 0.1) {
227
+ // V11 Gap #3 — refuse first-frame init while looking near vertical.
228
+ frameBGR.release()
229
+ return FrameTelemetry(
230
+ FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch,
231
+ msSince(t0),
232
+ isLandscape = isLandscape,
233
+ )
234
+ }
235
+ val pzx = fwx / horiz
236
+ val pzz = fwz / horiz
237
+ rPanToWorld = Mat(3, 3, CvType.CV_64F).apply {
238
+ put(0, 0, pzz); put(0, 1, 0.0); put(0, 2, pzx)
239
+ put(1, 0, 0.0); put(1, 1, 1.0); put(1, 2, 0.0)
240
+ put(2, 0, -pzx); put(2, 1, 0.0); put(2, 2, pzz)
241
+ }
242
+
243
+ // V12.6 orientation detection from R_panToCam.
244
+ val rPanToCamFirst = computeRPanToCam(firstRotationArkit)
245
+ val absR01 = Math.abs(rPanToCamFirst[0, 1][0])
246
+ val absR11 = Math.abs(rPanToCamFirst[1, 1][0])
247
+ isLandscape = absR11 > absR01
248
+ Log.i(
249
+ "V12.6-orient",
250
+ "engine=android-firstwins detected isLandscape=$isLandscape " +
251
+ "|R[0,1]|=${"%.4f".format(absR01)} " +
252
+ "|R[1,1]|=${"%.4f".format(absR11)}",
253
+ )
254
+
255
+ if (useRectilinear) {
256
+ // V12.12 — orientation-aware clip + engine-internal
257
+ // canvas allocation. Pan axis = canvas Y for
258
+ // landscape (vertical pan), canvas X for portrait
259
+ // (horizontal pan). Clip is ALONG the pan axis.
260
+ // Mirrors iOS' OpenCVSlitScanStitcher.mm exactly.
261
+ val clipW: Int
262
+ val clipH: Int
263
+ val srcClipX: Int
264
+ val srcClipY: Int
265
+ if (isLandscape) {
266
+ clipW = frameBGR.cols()
267
+ clipH = max(1, (frameBGR.rows() * kPanAxisFractionRect).toInt())
268
+ srcClipX = 0
269
+ srcClipY = (frameBGR.rows() - clipH) / 2
270
+ } else {
271
+ clipW = max(1, (frameBGR.cols() * kPanAxisFractionRect).toInt())
272
+ clipH = frameBGR.rows()
273
+ srcClipX = (frameBGR.cols() - clipW) / 2
274
+ srcClipY = 0
275
+ }
276
+ val frameClipped = Mat(frameBGR, Rect(srcClipX, srcClipY, clipW, clipH))
277
+
278
+ // V12.12 — engine-internal canvas allocation, sized
279
+ // from detected orientation + actual frame dims.
280
+ if (canvas.empty()) {
281
+ val newCanvasCols: Int
282
+ val newCanvasRows: Int
283
+ if (isLandscape) {
284
+ newCanvasCols = frameBGR.cols() // perp full
285
+ newCanvasRows = canvasPanExtent // pan extent
286
+ } else {
287
+ newCanvasCols = canvasPanExtent // pan extent
288
+ newCanvasRows = frameBGR.rows() // perp full
289
+ }
290
+ canvas = Mat.zeros(newCanvasRows, newCanvasCols, CvType.CV_8UC3)
291
+ canvasMask = Mat.zeros(newCanvasRows, newCanvasCols, CvType.CV_8UC1)
292
+ Log.i(
293
+ "V12.12-canvas",
294
+ "allocated ${newCanvasCols}x${newCanvasRows} (cols x rows) for " +
295
+ "isLandscape=$isLandscape (pan extent $canvasPanExtent, " +
296
+ "frame=${frameBGR.cols()}x${frameBGR.rows()})",
297
+ )
298
+ }
299
+
300
+ // V12.12 — first-frame placement at canvas ORIGIN
301
+ // (0,0). Canvas perpendicular dim now matches
302
+ // clipped frame perpendicular dim, so no centring
303
+ // offset. As user pans, dstX (portrait) or dstY
304
+ // (landscape) advances from 0.
305
+ val dstX = 0
306
+ val dstY = 0
307
+ val roi = Rect(dstX, dstY, clipW, clipH).intersection(
308
+ Rect(0, 0, canvas.cols(), canvas.rows())
309
+ )
310
+ val srcR = Rect(0, 0, roi.width, roi.height)
311
+ Mat(frameClipped, srcR).copyTo(Mat(canvas, roi))
312
+ Imgproc.rectangle(
313
+ canvasMask,
314
+ Point(roi.x.toDouble(), roi.y.toDouble()),
315
+ Point((roi.x + roi.width).toDouble(), (roi.y + roi.height).toDouble()),
316
+ Scalar(255.0), -1
317
+ )
318
+ firstFrameDstX = dstX
319
+ firstFrameDstY = dstY
320
+ // V12.11 Step D — initialise running-max trackers
321
+ // to first-frame position.
322
+ maxDstX = dstX
323
+ maxDstY = dstY
324
+ hasFirstFrame = true
325
+ acceptedCountAtomic.set(1); cachedBoundingRect = null
326
+ Log.i(
327
+ "V12.12-rect",
328
+ "first frame placed at ($dstX,$dstY) clipped=${clipW}x${clipH} " +
329
+ "(srcClip=$srcClipX,$srcClipY) along-pan-axis " +
330
+ "isLandscape=$isLandscape focal=${"%.2f".format(focalCompose)} " +
331
+ "canvas=${canvas.cols()}x${canvas.rows()}",
332
+ )
333
+ frameClipped.release()
334
+ frameBGR.release()
335
+ return FrameTelemetry(
336
+ FrameOutcome.AcceptedHigh, 1.0, 0, yaw, pitch, msSince(t0),
337
+ isLandscape = isLandscape,
338
+ )
339
+ }
340
+
341
+ // V12.2 cylindrical first frame: warp + place at canvas centre.
342
+ val warped = Mat()
343
+ val warpedMask = Mat()
344
+ val firstCornerCyl = cylindricalWarp(frameBGR, rNew, warped, warpedMask)
345
+ if (warped.empty()) {
346
+ frameBGR.release()
347
+ return FrameTelemetry(
348
+ FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch,
349
+ msSince(t0),
350
+ isLandscape = isLandscape,
351
+ )
352
+ }
353
+ val dstX = (canvasWidth - warped.cols()) / 2
354
+ val dstY = (canvasHeight - warped.rows()) / 2
355
+ val roi = Rect(dstX, dstY, warped.cols(), warped.rows()).intersection(
356
+ Rect(0, 0, canvasWidth, canvasHeight)
357
+ )
358
+ val srcR = Rect(0, 0, roi.width, roi.height)
359
+ Mat(warped, srcR).copyTo(Mat(canvas, roi), Mat(warpedMask, srcR))
360
+ Mat(warpedMask, srcR).copyTo(Mat(canvasMask, roi), Mat(warpedMask, srcR))
361
+ canvasOriginCylX = firstCornerCyl.x.toInt() - dstX
362
+ canvasOriginCylY = firstCornerCyl.y.toInt() - dstY
363
+
364
+ warped.release(); warpedMask.release()
365
+ hasFirstFrame = true
366
+ acceptedCountAtomic.set(1); cachedBoundingRect = null
367
+ frameBGR.release()
368
+ return FrameTelemetry(
369
+ FrameOutcome.AcceptedHigh, 1.0, 0, yaw, pitch, msSince(t0),
370
+ isLandscape = isLandscape,
371
+ )
372
+ }
373
+
374
+ // ─── Subsequent frame ───────────────────────────────────────
375
+ if (useRectilinear) {
376
+ // V12.8 Variant B: paste the SAME long-side-clipped portion
377
+ // as the first frame at canvas offset = pan_angle * focal.
378
+ // First-painted-wins masking ensures only the leading-edge
379
+ // sliver (the part outside the previously-painted region)
380
+ // gets painted — smooth incremental growth from frame 2,
381
+ // no V12.7 dead-zone.
382
+ val rRel = Mat()
383
+ val firstT = firstRotationArkit.t()
384
+ try {
385
+ Core.gemm(firstT, rNew, 1.0, Mat(), 0.0, rRel)
386
+ } finally {
387
+ firstT.release()
388
+ }
389
+
390
+ // V12.12 — orientation-aware clip, MUST match the
391
+ // first-frame branch above so subsequent frames have the
392
+ // same shape as the first frame (otherwise paste positions
393
+ // get inconsistent).
394
+ val clipW: Int
395
+ val clipH: Int
396
+ val srcClipX: Int
397
+ val srcClipY: Int
398
+ if (isLandscape) {
399
+ clipW = frameBGR.cols()
400
+ clipH = max(1, (frameBGR.rows() * kPanAxisFractionRect).toInt())
401
+ srcClipX = 0
402
+ srcClipY = (frameBGR.rows() - clipH) / 2
403
+ } else {
404
+ clipW = max(1, (frameBGR.cols() * kPanAxisFractionRect).toInt())
405
+ clipH = frameBGR.rows()
406
+ srcClipX = (frameBGR.cols() - clipW) / 2
407
+ srcClipY = 0
408
+ }
409
+ val frameClipped = Mat(frameBGR, Rect(srcClipX, srcClipY, clipW, clipH))
410
+
411
+ var dstX: Int
412
+ var dstY: Int
413
+ val alpha: Double
414
+ if (isLandscape) {
415
+ // Vertical pan around cam +X: alpha = atan2(R_rel[2,1], R_rel[1,1]).
416
+ alpha = atan2(rRel[2, 1][0], rRel[1, 1][0])
417
+ dstX = firstFrameDstX
418
+ // alpha > 0 (look up) → content shifts UP in canvas.
419
+ dstY = firstFrameDstY - round(alpha * focalCompose).toInt()
420
+ } else {
421
+ // Horizontal pan around cam +Y: alpha = atan2(R_rel[0,2], R_rel[0,0]).
422
+ alpha = atan2(rRel[0, 2][0], rRel[0, 0][0])
423
+ // alpha > 0 (look right) → content shifts RIGHT in canvas.
424
+ dstX = firstFrameDstX + round(alpha * focalCompose).toInt()
425
+ dstY = firstFrameDstY
426
+ }
427
+ rRel.release()
428
+
429
+ // V13.0a — REVERTED V12.11 Step 4 + V12.11.1 Item E + V12.14
430
+ // homography refinement (mirrors iOS revert). See
431
+ // OpenCVSlitScanStitcher.mm for the rationale. Restored
432
+ // pose-only paste; perpendicular drift correction will
433
+ // come back in V13.0b as 1D column-edge NCC correlation.
434
+
435
+ // V12.11 Step D — reverse-direction detection. Mirrors
436
+ // iOS' running-max check. See OpenCVSlitScanStitcher.mm
437
+ // for the full rationale. 150 px ≈ 4° of pan at the
438
+ // typical iPhone focal — comfortably above wobble.
439
+ val kReverseStopPx = 150
440
+ if (isLandscape) {
441
+ if (dstY > maxDstY) {
442
+ maxDstY = dstY
443
+ } else if (dstY < maxDstY - kReverseStopPx) {
444
+ Log.i(
445
+ "V12.11-reverse",
446
+ "landscape stop: dstY=$dstY max=$maxDstY (regressed ${maxDstY - dstY} px)",
447
+ )
448
+ frameClipped.release()
449
+ frameBGR.release()
450
+ return FrameTelemetry(
451
+ FrameOutcome.RejectedReverseDirection, -1.0, 0, yaw, pitch,
452
+ msSince(t0),
453
+ isLandscape = isLandscape,
454
+ )
455
+ }
456
+ } else {
457
+ if (dstX > maxDstX) {
458
+ maxDstX = dstX
459
+ } else if (dstX < maxDstX - kReverseStopPx) {
460
+ Log.i(
461
+ "V12.11-reverse",
462
+ "portrait stop: dstX=$dstX max=$maxDstX (regressed ${maxDstX - dstX} px)",
463
+ )
464
+ frameClipped.release()
465
+ frameBGR.release()
466
+ return FrameTelemetry(
467
+ FrameOutcome.RejectedReverseDirection, -1.0, 0, yaw, pitch,
468
+ msSince(t0),
469
+ isLandscape = isLandscape,
470
+ )
471
+ }
472
+ }
473
+
474
+ // V13.0a — pose-only paste (the ONLY paste path now).
475
+ // V12.11.1's full warpPerspective paste was removed; this
476
+ // pose-projected (dstX, dstY) is the final paste position.
477
+ val dstRoi = Rect(dstX, dstY, clipW, clipH).intersection(
478
+ Rect(0, 0, canvas.cols(), canvas.rows())
479
+ )
480
+ if (dstRoi.width <= 0 || dstRoi.height <= 0) {
481
+ frameClipped.release()
482
+ frameBGR.release()
483
+ return FrameTelemetry(
484
+ FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch,
485
+ msSince(t0),
486
+ isLandscape = isLandscape,
487
+ )
488
+ }
489
+ val srcRoi = Rect(
490
+ dstRoi.x - dstX,
491
+ dstRoi.y - dstY,
492
+ dstRoi.width, dstRoi.height,
493
+ )
494
+ val srcRegion = Mat(frameClipped, srcRoi)
495
+ val canvasRoi = Mat(canvas, dstRoi)
496
+ val maskRoi = Mat(canvasMask, dstRoi)
497
+ val emptyMask = Mat()
498
+ Core.compare(maskRoi, Scalar(0.0), emptyMask, Core.CMP_EQ)
499
+ val newPixels = Core.countNonZero(emptyMask)
500
+ if (newPixels > 0) {
501
+ srcRegion.copyTo(canvasRoi, emptyMask)
502
+ maskRoi.setTo(Scalar(255.0), emptyMask)
503
+ acceptedCountAtomic.incrementAndGet(); cachedBoundingRect = null
504
+ srcRegion.release(); canvasRoi.release(); maskRoi.release()
505
+ emptyMask.release(); frameClipped.release()
506
+ frameBGR.release()
507
+ return FrameTelemetry(
508
+ FrameOutcome.AcceptedHigh, 1.0, 0, yaw, pitch, msSince(t0),
509
+ isLandscape = isLandscape,
510
+ )
511
+ }
512
+ srcRegion.release(); canvasRoi.release(); maskRoi.release()
513
+ emptyMask.release(); frameClipped.release()
514
+ frameBGR.release()
515
+ return FrameTelemetry(
516
+ FrameOutcome.SkippedTooClose, 0.0, 0, yaw, pitch, msSince(t0),
517
+ isLandscape = isLandscape,
518
+ )
519
+ }
520
+
521
+ // V12.4 firstwins (cylindrical + central crop + first-painted-wins).
522
+ val warped = Mat()
523
+ val warpedMask = Mat()
524
+ val newCornerCyl = cylindricalWarp(frameBGR, rNew, warped, warpedMask)
525
+ if (warped.empty()) {
526
+ frameBGR.release()
527
+ return FrameTelemetry(
528
+ FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch, msSince(t0),
529
+ isLandscape = isLandscape,
530
+ )
531
+ }
532
+ val newCornerCanvas = Point(
533
+ (newCornerCyl.x - canvasOriginCylX).toDouble(),
534
+ (newCornerCyl.y - canvasOriginCylY).toDouble(),
535
+ )
536
+ val dstRoi = Rect(
537
+ newCornerCanvas.x.toInt(), newCornerCanvas.y.toInt(),
538
+ warped.cols(), warped.rows(),
539
+ ).intersection(Rect(0, 0, canvas.cols(), canvas.rows()))
540
+ if (dstRoi.width <= 0 || dstRoi.height <= 0) {
541
+ warped.release(); warpedMask.release(); frameBGR.release()
542
+ return FrameTelemetry(
543
+ FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch, msSince(t0),
544
+ isLandscape = isLandscape,
545
+ )
546
+ }
547
+ val srcRoi = Rect(
548
+ dstRoi.x - newCornerCanvas.x.toInt(),
549
+ dstRoi.y - newCornerCanvas.y.toInt(),
550
+ dstRoi.width, dstRoi.height,
551
+ )
552
+ val warpedClipped = Mat(warped, srcRoi)
553
+ val warpedMaskClipped = Mat(warpedMask, srcRoi)
554
+ val canvasRoi = Mat(canvas, dstRoi)
555
+ val canvasMaskRoi = Mat(canvasMask, dstRoi)
556
+
557
+ // First-painted-wins: paint where canvasMask == 0 AND warped mask == 255.
558
+ val noPrior = Mat()
559
+ Core.compare(canvasMaskRoi, Scalar(0.0), noPrior, Core.CMP_EQ)
560
+ val paintMask = Mat()
561
+ Core.bitwise_and(noPrior, warpedMaskClipped, paintMask)
562
+ val newPixels = Core.countNonZero(paintMask)
563
+ if (newPixels > 0) {
564
+ warpedClipped.copyTo(canvasRoi, paintMask)
565
+ paintMask.copyTo(canvasMaskRoi, paintMask)
566
+ acceptedCountAtomic.incrementAndGet(); cachedBoundingRect = null
567
+ }
568
+ noPrior.release(); paintMask.release()
569
+ warped.release(); warpedMask.release(); frameBGR.release()
570
+ return FrameTelemetry(
571
+ if (newPixels > 0) FrameOutcome.AcceptedHigh else FrameOutcome.SkippedTooClose,
572
+ if (newPixels > 0) 1.0 else 0.0, 0, yaw, pitch, msSince(t0),
573
+ isLandscape = isLandscape,
574
+ )
575
+ }
576
+
577
+ /**
578
+ * Live-snapshot path — same JPEG-cycle pattern as iOS. Cycles
579
+ * through 4 filenames so RN's <Image> cache sees a fresh URI
580
+ * each accept.
581
+ *
582
+ * Critic #8 / #9 fix: ALWAYS build state and pass `telemetry` so
583
+ * JS sees outcome / confidence / overlapPercent / processingMs
584
+ * even on rejected/skipped frames. Matches the JS
585
+ * IncrementalState contract in src/stitching/incremental.ts.
586
+ */
587
+ fun snapshotIfDue(telemetry: FrameTelemetry): WritableMap? {
588
+ val isAccept = telemetry.outcome == FrameOutcome.AcceptedHigh ||
589
+ telemetry.outcome == FrameOutcome.AcceptedMedium
590
+ var snapshotPath: String? = null
591
+ if (isAccept) {
592
+ acceptsSinceSnapshot += 1
593
+ if (acceptsSinceSnapshot >= snapshotEveryNAccepts) {
594
+ acceptsSinceSnapshot = 0
595
+ val path = currentSnapshotPath()
596
+ if (writeOut(path, snapshotJpegQuality, applyExposureComp = false)) {
597
+ snapshotPath = path
598
+ }
599
+ }
600
+ }
601
+ lastState = buildState(snapshotPath = snapshotPath, telemetry = telemetry)
602
+ return lastState
603
+ }
604
+
605
+ fun finalize(outputPath: String, quality: Int): StitcherSnapshot? {
606
+ val cleaned = outputPath.removePrefix("file://")
607
+ val ok = writeOut(cleaned, quality, applyExposureComp = true)
608
+ if (!ok) return null
609
+ val bbox = cachedBoundingRect ?: Imgproc.boundingRect(canvasMask).also { cachedBoundingRect = it }
610
+ val snap = StitcherSnapshot(
611
+ cleaned,
612
+ if (bbox.width > 0) bbox.width else canvasWidth,
613
+ if (bbox.height > 0) bbox.height else canvasHeight,
614
+ acceptedCount,
615
+ )
616
+ reset()
617
+ return snap
618
+ }
619
+
620
+ /**
621
+ * Critic #22 fix: explicit native-buffer release (75 MB canvas
622
+ * + 25 MB mask + smaller transient Mats). Call from the bridge
623
+ * when the engine is being thrown away (finalize/cancel paths).
624
+ * After this, the engine is unusable.
625
+ */
626
+ fun release() {
627
+ canvas.release()
628
+ canvasMask.release()
629
+ firstRotationArkit.release()
630
+ rPanToWorld.release()
631
+ kCompose.release()
632
+ mArkitToCv.release()
633
+ cachedBoundingRect = null
634
+ }
635
+
636
+ fun reset() {
637
+ canvas.setTo(Scalar(0.0, 0.0, 0.0))
638
+ canvasMask.setTo(Scalar(0.0))
639
+ firstRotationArkit = Mat()
640
+ rPanToWorld = Mat()
641
+ kCompose = Mat()
642
+ focalCompose = 0.0
643
+ canvasOriginCylX = 0
644
+ canvasOriginCylY = 0
645
+ firstFrameDstX = 0
646
+ firstFrameDstY = 0
647
+ // V12.11 Step D — clear running-max trackers; reinitialised
648
+ // to first-frame position on next accept.
649
+ maxDstX = 0
650
+ maxDstY = 0
651
+ isLandscape = false
652
+ hasFirstFrame = false
653
+ acceptsSinceSnapshot = 0
654
+ acceptedCountAtomic.set(0)
655
+ snapshotSeq = 0
656
+ lastState = null
657
+ }
658
+
659
+ // ─── Internals ─────────────────────────────────────────────────────
660
+
661
+ private fun computeRPanToCam(rArkit: Mat): Mat {
662
+ // R_panToCam = M · R_arkit^T · R_panToWorld
663
+ val tmp1 = Mat()
664
+ Core.gemm(rArkit.t(), rPanToWorld, 1.0, Mat(), 0.0, tmp1)
665
+ val out = Mat()
666
+ Core.gemm(mArkitToCv, tmp1, 1.0, Mat(), 0.0, out)
667
+ tmp1.release()
668
+ return out
669
+ }
670
+
671
+ /**
672
+ * V12.6 cylindrical warp — full annotation in iOS' OpenCVSlitScanStitcher.mm.
673
+ * Returns the bbox top-left in cylindrical-pixel coords. Writes the
674
+ * warped frame into outImage and the corresponding mask into outMask.
675
+ */
676
+ private fun cylindricalWarp(src: Mat, rArkit: Mat, outImage: Mat, outMask: Mat): Point {
677
+ if (rPanToWorld.empty() || focalCompose <= 0) {
678
+ outImage.release(); outMask.release()
679
+ return Point(0.0, 0.0)
680
+ }
681
+ val rPanToCam = computeRPanToCam(rArkit)
682
+ val rCamToPan = rPanToCam.t()
683
+
684
+ val fx = kCompose[0, 0][0]
685
+ val fy = kCompose[1, 1][0]
686
+ val cx = kCompose[0, 2][0]
687
+ val cy = kCompose[1, 2][0]
688
+ val f = focalCompose
689
+
690
+ // Forward-project the 4 source corners.
691
+ val r00 = rPanToCam[0, 0][0]; val r01 = rPanToCam[0, 1][0]; val r02 = rPanToCam[0, 2][0]
692
+ val r10 = rPanToCam[1, 0][0]; val r11 = rPanToCam[1, 1][0]; val r12 = rPanToCam[1, 2][0]
693
+ val r20 = rPanToCam[2, 0][0]; val r21 = rPanToCam[2, 1][0]; val r22 = rPanToCam[2, 2][0]
694
+ val cTP00 = rCamToPan[0, 0][0]; val cTP01 = rCamToPan[0, 1][0]; val cTP02 = rCamToPan[0, 2][0]
695
+ val cTP10 = rCamToPan[1, 0][0]; val cTP11 = rCamToPan[1, 1][0]; val cTP12 = rCamToPan[1, 2][0]
696
+ val cTP20 = rCamToPan[2, 0][0]; val cTP21 = rCamToPan[2, 1][0]; val cTP22 = rCamToPan[2, 2][0]
697
+
698
+ fun project(u: Double, v: Double): DoubleArray {
699
+ val rx = (u - cx) / fx
700
+ val ry = (v - cy) / fy
701
+ val rz = 1.0
702
+ val wx = cTP00 * rx + cTP01 * ry + cTP02 * rz
703
+ val wy = cTP10 * rx + cTP11 * ry + cTP12 * rz
704
+ val wz = cTP20 * rx + cTP21 * ry + cTP22 * rz
705
+ return if (isLandscape) {
706
+ // Transverse cylinder (axis = pan_X)
707
+ val denom = sqrt(wy * wy + wz * wz)
708
+ val s = if (denom > 1e-9) (-wx / denom) else 0.0
709
+ val theta = atan2(wy, wz)
710
+ doubleArrayOf(f * s, -f * theta)
711
+ } else {
712
+ // Vertical cylinder (axis = pan_Y), V12 mirror fix.
713
+ val theta = atan2(-wx, wz)
714
+ val denom = sqrt(wx * wx + wz * wz)
715
+ val h = if (denom > 1e-9) (wy / denom) else 0.0
716
+ doubleArrayOf(f * theta, -f * h)
717
+ }
718
+ }
719
+
720
+ val c00 = project(0.0, 0.0)
721
+ val c10 = project((src.cols() - 1).toDouble(), 0.0)
722
+ val c01 = project(0.0, (src.rows() - 1).toDouble())
723
+ val c11 = project((src.cols() - 1).toDouble(), (src.rows() - 1).toDouble())
724
+ val xs = doubleArrayOf(c00[0], c10[0], c01[0], c11[0])
725
+ val ys = doubleArrayOf(c00[1], c10[1], c01[1], c11[1])
726
+ val minX = xs.min(); val maxX = xs.max()
727
+ val minY = ys.min(); val maxY = ys.max()
728
+
729
+ var bboxX = floor(minX).toInt()
730
+ var bboxY = floor(minY).toInt()
731
+ var bboxW = (Math.ceil(maxX - minX).toInt()) + 1
732
+ var bboxH = (Math.ceil(maxY - minY).toInt()) + 1
733
+ if (bboxW <= 0 || bboxH <= 0 ||
734
+ bboxW > canvasWidth * 2 || bboxH > canvasHeight * 2) {
735
+ outImage.release(); outMask.release()
736
+ rPanToCam.release(); rCamToPan.release()
737
+ return Point(0.0, 0.0)
738
+ }
739
+
740
+ // V12.4 slit-scan + long-side clip.
741
+ run {
742
+ val newW: Int
743
+ val newH: Int
744
+ if (isLandscape) {
745
+ newW = max(1, (bboxW * kLongSideFraction).toInt())
746
+ newH = max(1, (bboxH * kPanStripFraction).toInt())
747
+ } else {
748
+ newW = max(1, (bboxW * kPanStripFraction).toInt())
749
+ newH = max(1, (bboxH * kLongSideFraction).toInt())
750
+ }
751
+ bboxX += (bboxW - newW) / 2
752
+ bboxY += (bboxH - newH) / 2
753
+ bboxW = newW
754
+ bboxH = newH
755
+ }
756
+
757
+ // Inverse-map: build mapX/mapY for cv::remap, then warp.
758
+ val mapX = Mat(bboxH, bboxW, CvType.CV_32FC1)
759
+ val mapY = Mat(bboxH, bboxW, CvType.CV_32FC1)
760
+
761
+ val rowMx = FloatArray(bboxW)
762
+ val rowMy = FloatArray(bboxW)
763
+ val srcCols = src.cols(); val srcRows = src.rows()
764
+
765
+ if (isLandscape) {
766
+ for (y in 0 until bboxH) {
767
+ val sphereY = (bboxY + y).toDouble()
768
+ val theta = -sphereY / f
769
+ val sinT = sin(theta); val cosT = cos(theta)
770
+ for (x in 0 until bboxW) {
771
+ val sphereX = (bboxX + x).toDouble()
772
+ val s = sphereX / f
773
+ val wx = -s
774
+ val wy = sinT
775
+ val wz = cosT
776
+ val rx = r00 * wx + r01 * wy + r02 * wz
777
+ val ry = r10 * wx + r11 * wy + r12 * wz
778
+ val rz = r20 * wx + r21 * wy + r22 * wz
779
+ if (rz <= 1e-6) {
780
+ rowMx[x] = -1.0f; rowMy[x] = -1.0f
781
+ } else {
782
+ val u = fx * rx / rz + cx
783
+ val v = fy * ry / rz + cy
784
+ if (u < 0 || u >= srcCols || v < 0 || v >= srcRows) {
785
+ rowMx[x] = -1.0f; rowMy[x] = -1.0f
786
+ } else {
787
+ rowMx[x] = u.toFloat(); rowMy[x] = v.toFloat()
788
+ }
789
+ }
790
+ }
791
+ mapX.put(y, 0, rowMx)
792
+ mapY.put(y, 0, rowMy)
793
+ }
794
+ } else {
795
+ for (y in 0 until bboxH) {
796
+ val cylY = (bboxY + y).toDouble()
797
+ val h = -cylY / f
798
+ for (x in 0 until bboxW) {
799
+ val cylX = (bboxX + x).toDouble()
800
+ val theta = cylX / f
801
+ val sinT = sin(theta); val cosT = cos(theta)
802
+ val wx = -sinT; val wy = h; val wz = cosT
803
+ val rx = r00 * wx + r01 * wy + r02 * wz
804
+ val ry = r10 * wx + r11 * wy + r12 * wz
805
+ val rz = r20 * wx + r21 * wy + r22 * wz
806
+ if (rz <= 1e-6) {
807
+ rowMx[x] = -1.0f; rowMy[x] = -1.0f
808
+ } else {
809
+ val u = fx * rx / rz + cx
810
+ val v = fy * ry / rz + cy
811
+ if (u < 0 || u >= srcCols || v < 0 || v >= srcRows) {
812
+ rowMx[x] = -1.0f; rowMy[x] = -1.0f
813
+ } else {
814
+ rowMx[x] = u.toFloat(); rowMy[x] = v.toFloat()
815
+ }
816
+ }
817
+ }
818
+ mapX.put(y, 0, rowMx)
819
+ mapY.put(y, 0, rowMy)
820
+ }
821
+ }
822
+
823
+ outImage.create(bboxH, bboxW, src.type())
824
+ Imgproc.remap(
825
+ src, outImage, mapX, mapY,
826
+ Imgproc.INTER_LINEAR, Core.BORDER_CONSTANT, Scalar(0.0, 0.0, 0.0),
827
+ )
828
+
829
+ // Build the mask in a single pass by re-reading mapX rows.
830
+ outMask.create(bboxH, bboxW, CvType.CV_8UC1)
831
+ outMask.setTo(Scalar(0.0))
832
+ val maskRow = ByteArray(bboxW)
833
+ for (y in 0 until bboxH) {
834
+ mapX.get(y, 0, rowMx)
835
+ for (x in 0 until bboxW) {
836
+ maskRow[x] = if (rowMx[x] >= 0.0f) 255.toByte() else 0.toByte()
837
+ }
838
+ outMask.put(y, 0, maskRow)
839
+ }
840
+
841
+ mapX.release(); mapY.release()
842
+ rPanToCam.release(); rCamToPan.release()
843
+ return Point(bboxX.toDouble(), bboxY.toDouble())
844
+ }
845
+
846
+ private fun downsampleToCompose(src: Mat): Mat {
847
+ val scale = min(
848
+ composeWidth.toDouble() / src.cols(),
849
+ composeHeight.toDouble() / src.rows(),
850
+ )
851
+ if (scale >= 1.0) return src
852
+ val outW = max(1, round(src.cols() * scale).toInt())
853
+ val outH = max(1, round(src.rows() * scale).toInt())
854
+ val out = Mat()
855
+ Imgproc.resize(src, out, Size(outW.toDouble(), outH.toDouble()), 0.0, 0.0, Imgproc.INTER_AREA)
856
+ return out
857
+ }
858
+
859
+ private fun quaternionToRotationMat(qx: Double, qy: Double, qz: Double, qw: Double): Mat {
860
+ val n = sqrt(qx * qx + qy * qy + qz * qz + qw * qw)
861
+ val x = if (n > 1e-9) qx / n else qx
862
+ val y = if (n > 1e-9) qy / n else qy
863
+ val z = if (n > 1e-9) qz / n else qz
864
+ val w = if (n > 1e-9) qw / n else qw
865
+ return Mat(3, 3, CvType.CV_64F).apply {
866
+ put(0, 0, 1 - 2 * (y * y + z * z)); put(0, 1, 2 * (x * y - w * z)); put(0, 2, 2 * (x * z + w * y))
867
+ put(1, 0, 2 * (x * y + w * z)); put(1, 1, 1 - 2 * (x * x + z * z)); put(1, 2, 2 * (y * z - w * x))
868
+ put(2, 0, 2 * (x * z - w * y)); put(2, 1, 2 * (y * z + w * x)); put(2, 2, 1 - 2 * (x * x + y * y))
869
+ }
870
+ }
871
+
872
+ private fun currentSnapshotPath(): String {
873
+ snapshotSeq += 1
874
+ val slot = snapshotSeq % 4
875
+ // Critic #27 fix: use the bridge-provided cache dir
876
+ // (reactContext.cacheDir.absolutePath), NOT java.io.tmpdir
877
+ // which on Android is /data/local/tmp (rooted-only).
878
+ return "$snapshotCacheDir/rlis-live-$slot.jpg"
879
+ }
880
+
881
+ private fun writeOut(path: String, quality: Int, applyExposureComp: Boolean): Boolean {
882
+ // V12 — bbox crop only, no inscribed-rect search (dropped per V12 plan).
883
+ val bbox = Imgproc.boundingRect(canvasMask)
884
+ val cropRect = if (bbox.width > 0 && bbox.height > 0) bbox
885
+ else Rect(0, 0, canvasWidth, canvasHeight)
886
+ val cropped = Mat(canvas, cropRect).clone()
887
+ val out = if (applyExposureComp) applyClahe(cropped) else cropped
888
+ val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, max(1, min(100, quality)))
889
+ val ok = Imgcodecs.imwrite(path, out, params)
890
+ if (cropped !== out) cropped.release()
891
+ out.release()
892
+ return ok
893
+ }
894
+
895
+ private fun applyClahe(src: Mat): Mat {
896
+ val lab = Mat()
897
+ Imgproc.cvtColor(src, lab, Imgproc.COLOR_BGR2Lab)
898
+ val channels = mutableListOf<Mat>()
899
+ Core.split(lab, channels)
900
+ val clahe = Imgproc.createCLAHE(2.0, Size(8.0, 8.0))
901
+ clahe.apply(channels[0], channels[0])
902
+ Core.merge(channels, lab)
903
+ val out = Mat()
904
+ Imgproc.cvtColor(lab, out, Imgproc.COLOR_Lab2BGR)
905
+ for (c in channels) c.release()
906
+ lab.release()
907
+ return out
908
+ }
909
+
910
+ /**
911
+ * Critic #8/#9/#23 fix: always include the full state event shape
912
+ * the JS IncrementalState interface expects (outcome, confidence,
913
+ * overlapPercent, processingMs). Matches the hybrid engine's
914
+ * shape so JS subscribers don't break when the engine variant
915
+ * is toggled at runtime.
916
+ *
917
+ * Critic #30 fix: use cached bounding rect; refresh once per
918
+ * accept inside the inner loops, NOT here on every state event.
919
+ */
920
+ private fun buildState(snapshotPath: String?, telemetry: FrameTelemetry): WritableMap {
921
+ val map = com.facebook.react.bridge.Arguments.createMap()
922
+ map.putInt("acceptedCount", acceptedCount)
923
+ if (snapshotPath != null) {
924
+ map.putString("panoramaPath", snapshotPath)
925
+ } else {
926
+ map.putNull("panoramaPath")
927
+ }
928
+ val r = cachedBoundingRect ?: Imgproc.boundingRect(canvasMask).also { cachedBoundingRect = it }
929
+ map.putInt("width", if (r.width > 0) r.width else 0)
930
+ map.putInt("height", if (r.height > 0) r.height else 0)
931
+ map.putInt("outcome", telemetry.outcome.ordinal)
932
+ map.putDouble("confidence", telemetry.confidence)
933
+ map.putDouble("overlapPercent", telemetry.overlapPercent)
934
+ map.putDouble("processingMs", telemetry.processingMs)
935
+ // V12.12 — engine-detected orientation, plumbed through to JS
936
+ // for the band overlay + dim bar UI.
937
+ map.putBoolean("isLandscape", telemetry.isLandscape)
938
+ return map
939
+ }
940
+
941
+ private fun msSince(t0Nanos: Long): Double =
942
+ (System.nanoTime() - t0Nanos) / 1_000_000.0
943
+ }
944
+
945
+ // Helper: Rect intersection (Android OpenCV doesn't expose it cleanly).
946
+ private fun Rect.intersection(other: Rect): Rect {
947
+ val x = max(this.x, other.x)
948
+ val y = max(this.y, other.y)
949
+ val r = min(this.x + this.width, other.x + other.width)
950
+ val b = min(this.y + this.height, other.y + other.height)
951
+ return Rect(x, y, max(0, r - x), max(0, b - y))
952
+ }
953
+
954
+ // V13.0a — homographyOffset(), HomographyResult, and the kHomogTier*
955
+ // constants were removed in the revert from V12.11.1 + V12.14
956
+ // (ORB+RANSAC homography correction with 3-tier confidence ladder)
957
+ // back to pose-only paste. See iOS OpenCVSlitScanStitcher.mm for
958
+ // the matching revert + V13.0b plan (1D column-edge NCC correlation
959
+ // for sub-pixel perpendicular drift correction).
960
+