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,1326 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // OpenCVIncrementalStitcher.mm
4
+ //
5
+ // See OpenCVIncrementalStitcher.h for the API contract.
6
+ //
7
+ // Algorithm summary (per addPixelBuffer call):
8
+ // 1. Lock + read NV12 planes from CVPixelBuffer
9
+ // 2. Convert + downscale to compose-size BGR cv::Mat
10
+ // 3. Pose-delta gate: skip if overlap > maxOverlap or < minOverlap
11
+ // 4. ORB.detectAndCompute (1000 features cap)
12
+ // 5. BFMatcher.knnMatch + Lowe's ratio test (0.75)
13
+ // 6. cv::findHomography(src=newPts, dst=lastPts, RANSAC, 5.0)
14
+ // 7. Inlier ratio + match count + det(H) → confidence
15
+ // 8. Compose worldH = lastFrameToWorldH * H_newToLast
16
+ // 9. warpPerspective + distance-transform feather blend onto canvas
17
+ // 10. Update state: lastFrameToWorldH, lastDescriptors, lastKeypoints,
18
+ // lastAcceptedYaw/Pitch, acceptedCount++
19
+ //
20
+ // What this file deliberately does NOT do:
21
+ // - Bundle adjustment. We accumulate pair-wise homography only;
22
+ // drift is accepted as the trade for live preview + Android parity
23
+ // (the Android prebuilt OpenCV ships without `cv::Stitcher`'s BA
24
+ // helpers). Long-pan drift is documented as future work in the
25
+ // design doc's open questions.
26
+ // - Multi-band blending. Same Android-parity reason. Distance-
27
+ // transform feather over a 20px band gives clean-enough seams for
28
+ // live preview; a final-pass MultiBand is possible at finalize
29
+ // time on iOS only if drift becomes the dominant artefact.
30
+ // - Exposure compensation. Auto-exposure on the camera handles
31
+ // gross brightness changes; the feather hides residual mismatches.
32
+
33
+ // OpenCV's stitching headers contain `enum { NO, ... }` and `enum { YES, ... }`
34
+ // declarations. Objective-C's `<objc/objc.h>` (transitively imported by every
35
+ // Cocoapods prefix.pch) #defines `NO` and `YES` as boolean macros — by the
36
+ // time OpenCV's enums are parsed, the preprocessor has already eaten those
37
+ // identifiers and the build dies with "expected identifier". Undef both
38
+ // BEFORE importing opencv2/*; restore after. Same pattern as OpenCVStitcher.mm.
39
+ #ifdef NO
40
+ #undef NO
41
+ #endif
42
+ #ifdef YES
43
+ #undef YES
44
+ #endif
45
+
46
+ #import <opencv2/opencv.hpp>
47
+ #import <opencv2/core.hpp>
48
+ #import <opencv2/imgproc.hpp>
49
+ #import <opencv2/imgcodecs.hpp>
50
+ #import <opencv2/features2d.hpp>
51
+ #import <opencv2/calib3d.hpp>
52
+ #import <opencv2/stitching/detail/warpers.hpp> // V14.0pre — cv::detail::CylindricalWarper
53
+
54
+ #import <vector>
55
+ #import <chrono>
56
+
57
+ #define NO ((BOOL)0)
58
+ #define YES ((BOOL)1)
59
+
60
+ #import "OpenCVIncrementalStitcher.h"
61
+
62
+ #import <UIKit/UIKit.h>
63
+
64
+ NSString *const RNImageStitcherIncrementalErrorDomain =
65
+ @"RNImageStitcherIncrementalErrorDomain";
66
+
67
+ // ── Private telemetry result class ──────────────────────────────────
68
+
69
+ @interface RLISFrameTelemetry ()
70
+ @property (nonatomic, readwrite) RLISFrameOutcome outcome;
71
+ @property (nonatomic, readwrite) double overlapPercent;
72
+ @property (nonatomic, readwrite) NSInteger matchCount;
73
+ @property (nonatomic, readwrite) double inlierRatio;
74
+ @property (nonatomic, readwrite) double confidence;
75
+ @property (nonatomic, readwrite) double processingMs;
76
+ @property (nonatomic, readwrite) BOOL isLandscape;
77
+ // V12.14.9 — see header doc for `paintedExtent` / `panExtent` semantics.
78
+ @property (nonatomic, readwrite) NSInteger paintedExtent;
79
+ @property (nonatomic, readwrite) NSInteger panExtent;
80
+ @end
81
+
82
+ @implementation RLISFrameTelemetry
83
+ @end
84
+
85
+ @interface RLISSnapshot ()
86
+ @property (nonatomic, copy, readwrite) NSString *panoramaPath;
87
+ @property (nonatomic, readwrite) NSInteger width;
88
+ @property (nonatomic, readwrite) NSInteger height;
89
+ @property (nonatomic, readwrite) NSInteger acceptedCount;
90
+ @end
91
+
92
+ @implementation RLISSnapshot
93
+ @end
94
+
95
+
96
+ // ── V15 — RLISStitcherConfig ────────────────────────────────────────
97
+
98
+ @implementation RLISStitcherConfig
99
+
100
+ + (instancetype)configForMode:(NSString *)mode {
101
+ RLISStitcherConfig *c = [[RLISStitcherConfig alloc] init];
102
+
103
+ NSString *m = mode ?: @"slitscan-both";
104
+ // Backward-compat translation.
105
+ if ([m isEqualToString:@"firstwins-rectilinear"]) {
106
+ m = @"slitscan-rotate";
107
+ } else if ([m isEqualToString:@"firstwins"] ||
108
+ [m isEqualToString:@"firstwins-zoomed"]) {
109
+ NSLog(@"[V15-config] DEPRECATED engine mode '%@' — falling "
110
+ @"back to 'slitscan-both'", mode);
111
+ m = @"slitscan-both";
112
+ }
113
+
114
+ // V15.0d — defaults shared by all engine modes for the new knobs.
115
+ // Set before the per-mode overrides so per-mode only overrides
116
+ // the fields that genuinely differ from this baseline.
117
+ c.nccSearchMargin2d = 12; // was hardcoded V15.0c.4
118
+ // V15.0i.1 — default raised to 0.99. At 0.75 the NCC was applying
119
+ // corrections on weak matches in plane-projected mode (Ram observed
120
+ // mid-capture wobble). 0.99 means we only apply corrections on
121
+ // near-perfect overlap matches; ambiguous matches are skipped.
122
+ c.nccConfidenceThreshold2d = 0.99;
123
+ c.enableNcc2dEmaSmoothing = NO; // 1B opt-in
124
+ c.ncc2dEmaAlpha = 0.4; // 60% prev / 40% current
125
+ c.enableNcc2dPanAxisLock = NO; // 1C opt-in
126
+ c.ncc2dCrossAxisLockPx = 5;
127
+ c.planeSource = RLISPlaneSourceDisabled;
128
+ c.virtualPlaneDepthMeters = 1.5;
129
+ c.arkitPlaneAlignmentThreshold = 0.6; // ~53° max off-camera
130
+ // V15.0g — Rectified is the default since Trapezoidal's
131
+ // tilt-induced distortion was the field-blocker on V15.0e/f.
132
+ // Operators can flip back to Trapezoidal for A/B comparison.
133
+ c.planeProjectionStyle = RLISPlaneProjectionStyleRectified;
134
+
135
+ if ([m isEqualToString:@"hybrid"]) {
136
+ // n/a slit-shaping; hybrid uses whole-frame projection.
137
+ c.kPanAxisFractionRect = 0.30; // unused for hybrid
138
+ c.kMinAcceptDeltaPx = 50;
139
+ c.enableTriangulation = NO;
140
+ c.enableTriAccumulator = NO;
141
+ c.enable1dNcc = NO;
142
+ c.nccSearchRadius1d = 15;
143
+ c.enable2dNcc = NO;
144
+ c.enableRansacHomography = NO;
145
+ c.paintMode = RLISPaintModeFeatherBlend; // V12.x feather
146
+ c.hybridProjection = RLISHybridProjectionPlanar; // V15: planar default
147
+ c.useDetectedPlane = NO;
148
+ c.sliverPosition = RLISSliverPositionCenter;
149
+ c.firstFrameFullFrame = NO;
150
+ } else if ([m isEqualToString:@"slitscan-rotate"]) {
151
+ // V13.0a baseline + 1D NCC. No tri, no 2D NCC, no homography.
152
+ c.kPanAxisFractionRect = 0.30;
153
+ c.kMinAcceptDeltaPx = 0; // accept on every frame
154
+ c.enableTriangulation = NO;
155
+ c.enableTriAccumulator = NO;
156
+ c.enable1dNcc = YES; // wobble correction
157
+ c.nccSearchRadius1d = 15;
158
+ c.enable2dNcc = NO;
159
+ c.enableRansacHomography = NO;
160
+ c.paintMode = RLISPaintModeFirstPaintedWins;
161
+ c.hybridProjection = RLISHybridProjectionPlanar; // unused
162
+ c.useDetectedPlane = NO;
163
+ c.sliverPosition = RLISSliverPositionCenter;
164
+ c.firstFrameFullFrame = NO;
165
+ } else {
166
+ // slitscan-both (V15.0c default). V13.0a baseline + no gate +
167
+ // first-painted-wins (Ram observation: feather often introduces
168
+ // ghosting; first-painted-wins is consistently the best for our
169
+ // typical retail fixture pans). Iterate via settings UI:
170
+ // enable tri / 2D NCC / RANSAC as needed.
171
+ c.kPanAxisFractionRect = 0.30;
172
+ c.kMinAcceptDeltaPx = 0; // accept on every frame
173
+ c.enableTriangulation = NO;
174
+ c.enableTriAccumulator = NO;
175
+ c.enable1dNcc = NO;
176
+ c.nccSearchRadius1d = 15;
177
+ c.enable2dNcc = NO;
178
+ c.enableRansacHomography = NO;
179
+ c.paintMode = RLISPaintModeFirstPaintedWins; // V15.0c default
180
+ c.hybridProjection = RLISHybridProjectionPlanar; // unused
181
+ c.useDetectedPlane = NO;
182
+ c.sliverPosition = RLISSliverPositionCenter;
183
+ c.firstFrameFullFrame = NO;
184
+ }
185
+
186
+ return c;
187
+ }
188
+
189
+ @end
190
+
191
+
192
+ // ── Acceptance thresholds ───────────────────────────────────────────
193
+ //
194
+ // All values empirical seeds from the design doc. Documented here
195
+ // alongside the code so a tuning pass during field testing can adjust
196
+ // them without hunting through the .h.
197
+
198
+ namespace {
199
+
200
+ // FoV gate window — slightly more permissive than the design doc's
201
+ // 30-50% sweet spot to handle pose noise + slow pans without
202
+ // rejecting valid candidates.
203
+ constexpr double kMinOverlapPct = 10.0; // below this → moved too far
204
+ constexpr double kMaxOverlapPct = 75.0; // above this → too close, wait
205
+ // Match-quality gates — relaxed from the design doc seeds because
206
+ // shelf scenes with light textures (white walls behind shelves,
207
+ // uniform packaging) produce fewer matches than the 80-feature
208
+ // "ideal". Field tuning in v3.
209
+ constexpr int kMinMatchesAccept = 10;
210
+ constexpr double kMinInlierRatioAccept = 0.18;
211
+ constexpr double kHighConfidenceMatches = 60;
212
+ constexpr double kHighConfidenceInlierRatio = 0.55;
213
+ constexpr int kOrbMaxFeatures = 1000;
214
+ constexpr float kLoweRatio = 0.75f;
215
+ constexpr double kRansacReprojThresh = 5.0;
216
+ // Similarity (4-DOF: scale, rotation, tx, ty) keeps the determinant
217
+ // equal to scale². Tight bounds reject degenerate fits aggressively
218
+ // while leaving slack for the natural ~0.9-1.1 scale that hand-held
219
+ // pans introduce (parallax + lens distortion residuals).
220
+ // V11 Gap #30: deleted dead kHomDetMin/kHomDetMax — pose-driven path
221
+ // doesn't fit a homography, so the determinant bounds were dead.
222
+
223
+ } // namespace
224
+
225
+ // ── Engine impl ─────────────────────────────────────────────────────
226
+
227
+ @implementation OpenCVIncrementalStitcher {
228
+ NSInteger _composeWidth;
229
+ NSInteger _composeHeight;
230
+ NSInteger _canvasWidth;
231
+ NSInteger _canvasHeight;
232
+ NSInteger _featherPx;
233
+ NSInteger _frameRotationDegrees;
234
+ /// V12.3 orientation-aware cylinder axis. Derived from
235
+ /// frameRotationDegrees: 0/180 → landscape (axis = pan_X,
236
+ /// transverse cylinder; pan direction is vertical world); 90/270 →
237
+ /// portrait (axis = pan_Y, vertical-axis cylinder; pan direction
238
+ /// is horizontal world). Apple's pano follows the same rule:
239
+ /// pan along the device's longer side, projection wraps around
240
+ /// the shorter side.
241
+ BOOL _isLandscape;
242
+
243
+ cv::Mat _canvas; // CV_8UC3 BGR — the running panorama
244
+ cv::Mat _canvasMask; // CV_8UC1 — 255 where canvas has been written
245
+
246
+ /// V9 pose-driven state — hand-rolled cylindrical projection.
247
+ /// Replaces v7's planar `H = K · R · K⁻¹` with cylindrical
248
+ /// remap into a gravity-aligned panorama frame. The panorama
249
+ /// frame is defined at first-frame time:
250
+ /// +Y = world-gravity-up (cylinder axis is vertical)
251
+ /// +Z = horizontal projection of first camera's forward
252
+ /// (theta=0 sits at first frame's centre; no wraparound)
253
+ /// +X = +Y × +Z (right-handed)
254
+ /// This avoids the v8 bug where cv::detail::CylindricalWarper
255
+ /// placed the cylinder seam directly in front of the camera.
256
+ cv::Mat _firstRotationArkit; // 3x3 CV_64F, ARKit camera-to-world
257
+ cv::Mat _K_compose; // 3x3 CV_64F, intrinsics scaled to compose dims
258
+ cv::Mat _M_arkitToCv; // diag(1, -1, -1) basis flip
259
+ cv::Mat _T_canvas; // (legacy from v7; unused in v9)
260
+ cv::Mat _R_panToWorld; // 3x3 CV_64F, panorama-to-world (cached at first frame)
261
+ double _focalCompose; // cylinder radius in compose pixels
262
+ int _canvasOriginCylX; // canvas (0,0) in cylindrical pixel space (origin offset)
263
+ int _canvasOriginCylY;
264
+ /// V11 Gap #5: last-accepted rotation matrix (ARKit cam-to-world).
265
+ /// The pose-delta gate computes the relative rotation between
266
+ /// frames in SENSOR FRAME (axes fixed to the device hardware),
267
+ /// not in world frame. World-frame yaw/pitch were being compared
268
+ /// against camera-frame FoV, which broke landscape pitch pans
269
+ /// (the dominant pan axis is rotation about world-Y, but in
270
+ /// landscape the sensor sees that as pitch-equivalent motion).
271
+ cv::Mat _lastAcceptedR;
272
+
273
+ double _lastAcceptedYaw;
274
+ double _lastAcceptedPitch;
275
+ bool _hasFirstFrame;
276
+
277
+ NSInteger _accepted;
278
+ /// Monotonic snapshot sequence — used to mint a unique path per
279
+ /// live snapshot. RN's <Image> caches `file://` URIs by path
280
+ /// alone and ignores cache-bust query strings, so writing to the
281
+ /// SAME path each accept made the live preview show the FIRST
282
+ /// frame forever. Bumping the path each snapshot side-steps
283
+ /// the cache.
284
+ NSInteger _snapshotSeq;
285
+
286
+ /// V15 — runtime config controlling projection (cylindrical vs
287
+ /// planar) and other hybrid-specific knobs. Set via -setConfig:
288
+ /// after init; defaults to hybrid factory config (planar) if
289
+ /// never set.
290
+ RLISStitcherConfig *_config;
291
+ }
292
+
293
+ - (instancetype)initWithComposeWidth:(NSInteger)composeWidth
294
+ composeHeight:(NSInteger)composeHeight
295
+ canvasWidth:(NSInteger)canvasWidth
296
+ canvasHeight:(NSInteger)canvasHeight
297
+ featherPx:(NSInteger)featherPx
298
+ frameRotationDegrees:(NSInteger)frameRotationDegrees
299
+ {
300
+ if (self = [super init]) {
301
+ // V7: frameRotationDegrees is now an OUTPUT-ONLY rotation —
302
+ // it's applied at snapshot/finalize time to orient the saved
303
+ // JPEG for display. The compute pipeline always works in
304
+ // sensor-native landscape compose space.
305
+ _frameRotationDegrees = frameRotationDegrees;
306
+ // V12.6 Step C: _isLandscape is no longer derived from the
307
+ // JS-passed frameRotationDegrees. V12.5 telemetry proved
308
+ // JS was sending the wrong value when iOS orientation-lock
309
+ // suppressed the rotation event (always reported portrait
310
+ // even in landscape). We now detect at first-frame init
311
+ // from R_panToCam directly — see the cylindricalWarp's
312
+ // first-frame branch. Default false here is just a safe
313
+ // initialiser; it WILL be overwritten before any warping
314
+ // happens.
315
+ _isLandscape = NO;
316
+ // Default compose dims preserve the 4:3 sensor aspect
317
+ // (1920x1440 → 960x720 at scale 0.5). Always landscape
318
+ // because we no longer rotate input; the canvas geometry
319
+ // matches the sensor's pixel-shift direction for either
320
+ // yaw or pitch pan, in either device orientation.
321
+ _composeWidth = composeWidth > 0 ? composeWidth : 960;
322
+ _composeHeight = composeHeight > 0 ? composeHeight : 720;
323
+ // Canvas: wide-landscape because for the typical shelf-scan
324
+ // use case (portrait phone, left-right yaw pan), the sensor
325
+ // sees content shifting along its X axis (the wide 1920
326
+ // axis). Canvas-X covers ~3 frame-widths of pan; canvas-Y
327
+ // covers one frame plus pitch-wobble headroom.
328
+ // V11 Gap #4: square canvas so EITHER pan axis fits. The
329
+ // primary use case (top-to-bottom landscape pan) needs canvas-Y
330
+ // ≥ 3000 px to cover ~90° pitch at typical compose focal — the
331
+ // earlier 2200 px clipped the pan after 4-5 frames. 5000² is
332
+ // ~88 MB (canvas + mask), comfortable on iPhone 13+ where the
333
+ // app is targeted. Real auto-grow is deferred — flat over-
334
+ // allocation is simpler and works for both use cases.
335
+ _canvasWidth = canvasWidth > 0 ? canvasWidth : 5000;
336
+ _canvasHeight = canvasHeight > 0 ? canvasHeight : 5000;
337
+ _featherPx = featherPx > 0 ? featherPx : 20;
338
+
339
+ // ARKit camera frame (Y-up, -Z forward) → OpenCV camera frame
340
+ // (Y-down, +Z forward). Pre-multiplying ARKit-rotation by
341
+ // M (and post-multiplying by M, since M is its own inverse)
342
+ // converts the rotation into OpenCV camera-frame conventions
343
+ // before we plug it into the pinhole projection K.
344
+ _M_arkitToCv = (cv::Mat_<double>(3, 3) <<
345
+ 1, 0, 0,
346
+ 0, -1, 0,
347
+ 0, 0, -1);
348
+
349
+ // V15 — default config (hybrid mode → planar projection).
350
+ // Caller should override via -setConfig: after init.
351
+ _config = [RLISStitcherConfig configForMode:@"hybrid"];
352
+
353
+ [self reset];
354
+ }
355
+ return self;
356
+ }
357
+
358
+ - (void)setConfig:(RLISStitcherConfig *)config {
359
+ if (config == nil) return;
360
+ _config = config;
361
+ NSLog(@"[V15-config] hybrid config applied: hybridProjection=%@",
362
+ _config.hybridProjection == RLISHybridProjectionPlanar
363
+ ? @"Planar" : @"Cylindrical");
364
+ }
365
+
366
+ - (void)reset {
367
+ _canvas = cv::Mat::zeros((int)_canvasHeight, (int)_canvasWidth, CV_8UC3);
368
+ _canvasMask = cv::Mat::zeros((int)_canvasHeight, (int)_canvasWidth, CV_8UC1);
369
+ _firstRotationArkit = cv::Mat();
370
+ _K_compose = cv::Mat();
371
+ _T_canvas = cv::Mat::eye(3, 3, CV_64F);
372
+ _lastAcceptedYaw = 0.0;
373
+ _lastAcceptedPitch = 0.0;
374
+ _lastAcceptedR = cv::Mat(); // V11 Gap #5: clear sensor-frame gate state
375
+ _hasFirstFrame = false;
376
+ _accepted = 0;
377
+ _snapshotSeq = 0;
378
+ }
379
+
380
+ - (NSInteger)acceptedCount { return _accepted; }
381
+
382
+ // ── Public: ingestPixelBuffer (V6 pose-driven) ─────────────────────
383
+
384
+ - (RLISFrameTelemetry *)ingestPixelBuffer:(CVPixelBufferRef)pixelBuffer
385
+ qx:(double)qx
386
+ qy:(double)qy
387
+ qz:(double)qz
388
+ qw:(double)qw
389
+ tx:(double)tx
390
+ ty:(double)ty
391
+ tz:(double)tz
392
+ fx:(double)fx
393
+ fy:(double)fy
394
+ cx:(double)cx
395
+ cy:(double)cy
396
+ imageWidth:(NSInteger)imageWidth
397
+ imageHeight:(NSInteger)imageHeight
398
+ yaw:(double)yaw
399
+ pitch:(double)pitch
400
+ fovHorizDegrees:(double)fovHorizDegrees
401
+ fovVertDegrees:(double)fovVertDegrees
402
+ trackingPoor:(BOOL)trackingPoor
403
+ {
404
+ // V13.0e — hybrid engine accepts tx/ty/tz for API symmetry with the
405
+ // slit-scan engine but does not (yet) use them. The Samsung-style
406
+ // hybrid path's robustness comes from feature-matching its overlap
407
+ // each frame; pose translation correction layered on top would be
408
+ // redundant. Suppress unused-warning explicitly so the call stays
409
+ // semantically tied to the slit engine.
410
+ (void)tx; (void)ty; (void)tz;
411
+
412
+ auto t0 = std::chrono::steady_clock::now();
413
+
414
+ RLISFrameTelemetry *tele = [[RLISFrameTelemetry alloc] init];
415
+ tele.overlapPercent = -1;
416
+
417
+ if (trackingPoor) {
418
+ tele.outcome = RLISFrameOutcomeSkippedTrackingPoor;
419
+ tele.processingMs = msSince(t0);
420
+ return tele;
421
+ }
422
+
423
+ cv::Mat frameBGR;
424
+ if (![self convertPixelBuffer:pixelBuffer toMat:frameBGR]) {
425
+ tele.outcome = RLISFrameOutcomeSkippedTrackingPoor;
426
+ tele.processingMs = msSince(t0);
427
+ return tele;
428
+ }
429
+
430
+ // Build R_new (3x3, ARKit-camera-to-world rotation) from the
431
+ // quaternion.
432
+ cv::Mat R_new = quaternionToRotationMat(qx, qy, qz, qw);
433
+
434
+ // First frame: place at canvas centre, store the reference pose
435
+ // and intrinsics, accept unconditionally. All subsequent frames
436
+ // compute their pose-driven homography RELATIVE to this first
437
+ // frame.
438
+ if (!_hasFirstFrame) {
439
+ _firstRotationArkit = R_new.clone();
440
+ // V11 Gap #1: per-axis intrinsics scaling (was averaging into
441
+ // a single scalar — silently distorts whenever compose dims
442
+ // ratio ≠ sensor dims ratio). K_compose = diag(sx,sy,1)·K.
443
+ double sx = (double)frameBGR.cols / std::max((NSInteger)1, imageWidth);
444
+ double sy = (double)frameBGR.rows / std::max((NSInteger)1, imageHeight);
445
+ _K_compose = (cv::Mat_<double>(3, 3) <<
446
+ fx * sx, 0, cx * sx,
447
+ 0, fy * sy, cy * sy,
448
+ 0, 0, 1);
449
+ // V11 Gap #2: cylinder radius = geometric mean of compose
450
+ // focals. Was just `fx * s`, which made canvas h-spacing
451
+ // inconsistent with theta-spacing for any anisotropic-pixel
452
+ // intrinsic.
453
+ _focalCompose = std::sqrt((fx * sx) * (fy * sy));
454
+
455
+ // V9: build the panorama-to-world rotation from the first
456
+ // ARKit pose. Panorama +Z = horizontal projection of first-
457
+ // camera forward. For PORTRAIT (cylinder axis = vertical):
458
+ // panorama +Y = world up. For LANDSCAPE (cylinder axis =
459
+ // horizontal pan rotation axis): V14.0pre — panorama +Y =
460
+ // first-camera +X (sensor X = phone long edge in landscape =
461
+ // pan rotation axis). This makes pano-Y the actual rotation
462
+ // axis, eliminating the V12.x landscape-projection roll-
463
+ // sensitivity bug (see 2026-05-07-v14-stitcher-plan.md).
464
+ cv::Mat fwdArkitCam = (cv::Mat_<double>(3, 1) << 0, 0, -1);
465
+ cv::Mat fwdWorld = _firstRotationArkit * fwdArkitCam;
466
+ double fwx = fwdWorld.at<double>(0);
467
+ double fwz = fwdWorld.at<double>(2);
468
+ double horiz = std::sqrt(fwx * fwx + fwz * fwz);
469
+ // V11 Gap #3: reject if camera looking nearly vertical. The
470
+ // panorama frame needs a horizontal +Z anchor; if camera
471
+ // forward is gravity-aligned, the horizontal projection is
472
+ // degenerate.
473
+ if (horiz < 0.1) {
474
+ tele.outcome = RLISFrameOutcomeRejectedAlignmentLost;
475
+ tele.processingMs = msSince(t0);
476
+ return tele;
477
+ }
478
+ double pzx = fwx / horiz;
479
+ double pzz = fwz / horiz;
480
+
481
+ // V14.0pre — orientation detection BEFORE _R_panToWorld
482
+ // construction (was V12.6 detection done AFTER, using
483
+ // R_panToCam_first which itself depended on _R_panToWorld —
484
+ // chicken-and-egg).
485
+ //
486
+ // V14.0pre.1 — comparison INVERTED after V14.0pre field test
487
+ // showed it firing backwards. Hardware geometry: the phone's
488
+ // sensor-Y axis (cam-Y) is along the SHORT edge of the phone.
489
+ // In LANDSCAPE the phone is held long-edge horizontal, so
490
+ // cam-Y points UP in the user's view (= along world-up =
491
+ // gravity). In PORTRAIT it points sideways (horizontal).
492
+ // V14.0pre had max(|X|,|Z|) > |Y| firing as "landscape" — that
493
+ // pattern actually identifies PORTRAIT. Field log showed
494
+ // |camY.worldY|=0.937 (clearly landscape) firing isLandscape=0.
495
+ // Inverted comparison matches V12.6 slit-scan detection's
496
+ // direction (absR11 > absR01).
497
+ cv::Mat camYInWorld = _firstRotationArkit *
498
+ (cv::Mat_<double>(3, 1) << 0, 1, 0);
499
+ const double absCamYInWorldX = std::fabs(camYInWorld.at<double>(0));
500
+ const double absCamYInWorldY = std::fabs(camYInWorld.at<double>(1));
501
+ const double absCamYInWorldZ = std::fabs(camYInWorld.at<double>(2));
502
+ _isLandscape = (absCamYInWorldY
503
+ > std::max(absCamYInWorldX, absCamYInWorldZ));
504
+ NSLog(@"[V14.0pre-orient] engine=hybrid isLandscape=%d "
505
+ @"|camY.worldX|=%.4f |camY.worldY|=%.4f |camY.worldZ|=%.4f",
506
+ (int)_isLandscape, absCamYInWorldX, absCamYInWorldY,
507
+ absCamYInWorldZ);
508
+
509
+ if (_isLandscape) {
510
+ // V14.0pre LANDSCAPE: pano-Y = first-cam +X axis (the pan
511
+ // rotation axis for vertical pan). Cylinder axis = pano-Y
512
+ // = cam-X → roll around cam-Z just slides pixels along
513
+ // theta with no asymmetric distortion. Pano-Z = horizontal
514
+ // projection of first-cam forward (already computed above).
515
+ // Pano-X = pano-Y × pano-Z (right-handed completion;
516
+ // approximately gravity-up for level first frame).
517
+ //
518
+ // pano-Y = R_first · (1,0,0) (cam-X in world coords)
519
+ cv::Mat camXInWorld = _firstRotationArkit *
520
+ (cv::Mat_<double>(3, 1) << 1, 0, 0);
521
+ const double pyx = camXInWorld.at<double>(0);
522
+ const double pyy = camXInWorld.at<double>(1);
523
+ const double pyz = camXInWorld.at<double>(2);
524
+ // pano-Z = (pzx, 0, pzz) by construction above.
525
+ // pano-X = pano-Y × pano-Z; with pano-Z having zero Y comp:
526
+ // px.x = pyy*pzz - pyz*0 = pyy*pzz
527
+ // px.y = pyz*pzx - pyx*pzz
528
+ // px.z = pyx*0 - pyy*pzx = -pyy*pzx
529
+ const double pxx = pyy * pzz;
530
+ const double pxy = pyz * pzx - pyx * pzz;
531
+ const double pxz = -pyy * pzx;
532
+ // Columns of _R_panToWorld are pano-X, pano-Y, pano-Z.
533
+ _R_panToWorld = (cv::Mat_<double>(3, 3) <<
534
+ pxx, pyx, pzx,
535
+ pxy, pyy, 0.0,
536
+ pxz, pyz, pzz);
537
+ } else {
538
+ // PORTRAIT (unchanged from V9): pano-Y = world up.
539
+ // pano-X = pano-Y × pano-Z = (0,1,0) × (pzx,0,pzz) = (pzz, 0, -pzx)
540
+ _R_panToWorld = (cv::Mat_<double>(3, 3) <<
541
+ pzz, 0, pzx,
542
+ 0, 1, 0,
543
+ -pzx, 0, pzz);
544
+ }
545
+
546
+ // V14.0pre — sanity check the constructed pano frame is
547
+ // orthonormal (det ≈ 1, R · R^T ≈ I). Logs once per capture
548
+ // (first-frame). If det ≈ -1 or off-diagonals are large, the
549
+ // pano-X cross-product computation has a sign or transcription
550
+ // bug — fix BEFORE proceeding.
551
+ const double pano_det = cv::determinant(_R_panToWorld);
552
+ cv::Mat pano_RRT = _R_panToWorld * _R_panToWorld.t();
553
+ NSLog(@"[V14.0pre-pano] det=%.4f I[0,0]=%.4f I[1,1]=%.4f I[2,2]=%.4f "
554
+ @"I[0,1]=%.4f I[0,2]=%.4f I[1,2]=%.4f",
555
+ pano_det,
556
+ pano_RRT.at<double>(0,0), pano_RRT.at<double>(1,1),
557
+ pano_RRT.at<double>(2,2), pano_RRT.at<double>(0,1),
558
+ pano_RRT.at<double>(0,2), pano_RRT.at<double>(1,2));
559
+
560
+ // Place first frame onto canvas via cylindrical warp. R for
561
+ // the warp is panorama→camera in OpenCV cam frame; for the
562
+ // first frame this is approximately identity (camera-forward
563
+ // = panorama +Z). The cylindrical warp gives us a warped
564
+ // image + a corner in cylindrical-pixel (theta, h)·f space.
565
+ cv::Mat warpedFirst, warpedFirstMask;
566
+ cv::Point firstCornerCyl =
567
+ [self cylindricalWarp:frameBGR rArkit:R_new
568
+ outImage:warpedFirst outMask:warpedFirstMask];
569
+
570
+ // Anchor the first frame at canvas centre.
571
+ int dstX = (int)(_canvas.cols - warpedFirst.cols) / 2;
572
+ int dstY = (int)(_canvas.rows - warpedFirst.rows) / 2;
573
+ cv::Rect roi(dstX, dstY, warpedFirst.cols, warpedFirst.rows);
574
+ roi &= cv::Rect(0, 0, _canvas.cols, _canvas.rows);
575
+ cv::Rect srcRoi(0, 0, roi.width, roi.height);
576
+ warpedFirst(srcRoi).copyTo(_canvas(roi), warpedFirstMask(srcRoi));
577
+ warpedFirstMask(srcRoi).copyTo(_canvasMask(roi),
578
+ warpedFirstMask(srcRoi));
579
+
580
+ // Track the cylindrical pixel that lives at canvas (0, 0).
581
+ // Subsequent frames' cylindrical corners → canvas position
582
+ // by subtracting this origin.
583
+ _canvasOriginCylX = firstCornerCyl.x - dstX;
584
+ _canvasOriginCylY = firstCornerCyl.y - dstY;
585
+
586
+ _lastAcceptedYaw = yaw;
587
+ _lastAcceptedPitch = pitch;
588
+ _lastAcceptedR = R_new.clone();
589
+ _hasFirstFrame = true;
590
+ _accepted = 1;
591
+ tele.outcome = RLISFrameOutcomeAcceptedHigh;
592
+ tele.confidence = 1.0;
593
+ tele.overlapPercent = 0;
594
+ tele.processingMs = msSince(t0);
595
+ return tele;
596
+ }
597
+
598
+ // V11 Gap #5: pose-delta gate in SENSOR FRAME.
599
+ //
600
+ // Compute the relative rotation between the last-accepted frame
601
+ // and this frame, expressed in the previous frame's CAMERA-LOCAL
602
+ // axes (these are fixed to the device sensor hardware regardless
603
+ // of how the user is holding the phone).
604
+ //
605
+ // R_relative_in_prev_cam = R_prev⁻¹ · R_new (column-vector convention)
606
+ //
607
+ // For ARKit camera axes (+X right, +Y up, −Z forward):
608
+ // rotation about sensor +Y → scene shifts horizontally on screen
609
+ // → compare against fovH
610
+ // rotation about sensor +X → scene shifts vertically on screen
611
+ // → compare against fovV
612
+ //
613
+ // For small-angle rotations (typical accept window 5-25°),
614
+ // cv::Rodrigues' axis-angle vector ≈ (rotX, rotY, rotZ) with
615
+ // each component being the rotation about that sensor axis.
616
+ cv::Mat R_relSensor = _lastAcceptedR.t() * R_new;
617
+ cv::Mat rvec;
618
+ cv::Rodrigues(R_relSensor, rvec);
619
+ double sensorRotX = std::fabs(rvec.at<double>(0));
620
+ double sensorRotY = std::fabs(rvec.at<double>(1));
621
+ double overlap = computeOverlapPctSensor(
622
+ sensorRotX,
623
+ sensorRotY,
624
+ fovHorizDegrees,
625
+ fovVertDegrees
626
+ );
627
+ tele.overlapPercent = overlap;
628
+
629
+ if (overlap > kMaxOverlapPct) {
630
+ tele.outcome = RLISFrameOutcomeSkippedTooClose;
631
+ tele.processingMs = msSince(t0);
632
+ return tele;
633
+ }
634
+ if (overlap < kMinOverlapPct) {
635
+ tele.outcome = RLISFrameOutcomeRejectedTooFar;
636
+ tele.processingMs = msSince(t0);
637
+ return tele;
638
+ }
639
+
640
+ // V12.2 cylindrical warp (with V12 mirror fix) + feather blend.
641
+ cv::Mat warpedNew, warpedNewMask;
642
+ cv::Point newCornerCyl =
643
+ [self cylindricalWarp:frameBGR rArkit:R_new
644
+ outImage:warpedNew outMask:warpedNewMask];
645
+ if (warpedNew.empty()) {
646
+ tele.outcome = RLISFrameOutcomeRejectedAlignmentLost;
647
+ tele.processingMs = msSince(t0);
648
+ return tele;
649
+ }
650
+
651
+ // Map cylindrical-pixel corner to canvas-pixel corner.
652
+ cv::Point newCornerCanvas(newCornerCyl.x - _canvasOriginCylX,
653
+ newCornerCyl.y - _canvasOriginCylY);
654
+
655
+ // V9b: optical-flow refinement. ARKit pose accuracy is ~1-2°,
656
+ // which translates to ~25-50 px residual misalignment at typical
657
+ // focal lengths. KLT flow on a sparse grid in the overlap
658
+ // region recovers sub-pixel accuracy without needing the full
659
+ // ORB+RANSAC machinery from the v1-v3 path. The result is a
660
+ // single (dx, dy) translation applied to the canvas placement.
661
+ cv::Point2f shift = [self refineWithOpticalFlow:warpedNew
662
+ newMask:warpedNewMask
663
+ canvasOrigin:newCornerCanvas];
664
+ newCornerCanvas.x += (int)std::round(shift.x);
665
+ newCornerCanvas.y += (int)std::round(shift.y);
666
+
667
+ cv::Rect dstRoi(newCornerCanvas.x, newCornerCanvas.y,
668
+ warpedNew.cols, warpedNew.rows);
669
+ cv::Rect canvasBounds(0, 0, _canvas.cols, _canvas.rows);
670
+ cv::Rect dstClipped = dstRoi & canvasBounds;
671
+ if (dstClipped.width <= 0 || dstClipped.height <= 0) {
672
+ tele.outcome = RLISFrameOutcomeRejectedAlignmentLost;
673
+ tele.processingMs = msSince(t0);
674
+ return tele;
675
+ }
676
+ cv::Rect srcRoi(dstClipped.x - dstRoi.x, dstClipped.y - dstRoi.y,
677
+ dstClipped.width, dstClipped.height);
678
+
679
+ [self featherBlendWarped:warpedNew(srcRoi)
680
+ mask:warpedNewMask(srcRoi)
681
+ intoCanvas:_canvas(dstClipped)
682
+ canvasMask:_canvasMask(dstClipped)];
683
+
684
+ _lastAcceptedYaw = yaw;
685
+ _lastAcceptedPitch = pitch;
686
+ _lastAcceptedR = R_new.clone(); // V11 Gap #5: sensor-frame gate state
687
+ _accepted += 1;
688
+
689
+ // Pose-driven path is geometrically exact when ARKit tracking is
690
+ // good (which we already gated on `trackingPoor`). Confidence
691
+ // is a function of the FoV-overlap quality: high near 50%, lower
692
+ // at the edges of the [10, 75]% acceptance window.
693
+ double midOverlap = 0.5 * (kMinOverlapPct + kMaxOverlapPct);
694
+ double overlapDistance = std::fabs(overlap - midOverlap)
695
+ / (kMaxOverlapPct - midOverlap);
696
+ double confidence = std::max(0.0, 1.0 - overlapDistance);
697
+ tele.confidence = confidence;
698
+ tele.matchCount = -1; // not applicable in pose-driven path
699
+ tele.inlierRatio = -1;
700
+ tele.outcome = (confidence >= 0.6)
701
+ ? RLISFrameOutcomeAcceptedHigh
702
+ : RLISFrameOutcomeAcceptedMedium;
703
+ tele.processingMs = msSince(t0);
704
+ return tele;
705
+ }
706
+
707
+ // ── Snapshot / finalize ─────────────────────────────────────────────
708
+
709
+ - (nullable RLISSnapshot *)snapshotWithJpegQuality:(NSInteger)quality
710
+ error:(NSError **)error
711
+ {
712
+ _snapshotSeq += 1;
713
+ return [self writeSnapshotToPath:[self currentSnapshotPath]
714
+ jpegQuality:quality
715
+ tightCrop:YES
716
+ applyExposureComp:NO
717
+ error:error];
718
+ }
719
+
720
+ - (nullable RLISSnapshot *)finalizeAtPath:(NSString *)outputPath
721
+ jpegQuality:(NSInteger)quality
722
+ error:(NSError **)error
723
+ {
724
+ // Final output: bounding-box crop + CLAHE. Runs off the AR
725
+ // delegate thread because the Swift wrapper dispatches finalize
726
+ // on workQueue. V12 Gap #2: dropped the O(W·H) inscribed-rect
727
+ // search — it produced a far thinner output than the actual
728
+ // painted region for any non-rectangular pan footprint, and the
729
+ // mask edges are clean (no per-pixel artefacts to crop away).
730
+ RLISSnapshot *snap = [self writeSnapshotToPath:outputPath
731
+ jpegQuality:quality
732
+ tightCrop:YES
733
+ applyExposureComp:YES
734
+ error:error];
735
+ [self reset];
736
+ return snap;
737
+ }
738
+
739
+ - (NSString *)currentSnapshotPath {
740
+ // Cycle through 4 filenames so RN's image cache sees a new URI
741
+ // on every snapshot but tmp dir doesn't grow unbounded over a
742
+ // long pan. 4 is enough to outpace RN's most aggressive
743
+ // image cache lifetimes; the OS reclaims tmp at app launch
744
+ // anyway.
745
+ NSString *tmpDir = NSTemporaryDirectory();
746
+ NSInteger slot = _snapshotSeq % 4;
747
+ NSString *filename = [NSString stringWithFormat:@"rlis-live-%ld.jpg", (long)slot];
748
+ return [tmpDir stringByAppendingPathComponent:filename];
749
+ }
750
+
751
+ - (nullable RLISSnapshot *)writeSnapshotToPath:(NSString *)outputPath
752
+ jpegQuality:(NSInteger)quality
753
+ tightCrop:(BOOL)tightCrop
754
+ applyExposureComp:(BOOL)applyExposureComp
755
+ error:(NSError **)error
756
+ {
757
+ if (_accepted == 0) {
758
+ if (error) {
759
+ *error = [NSError errorWithDomain:RNImageStitcherIncrementalErrorDomain
760
+ code:1
761
+ userInfo:@{NSLocalizedDescriptionKey:
762
+ @"No frames have been accepted yet."}];
763
+ }
764
+ return nil;
765
+ }
766
+
767
+ cv::Rect cropRect(0, 0, _canvas.cols, _canvas.rows);
768
+ if (tightCrop) {
769
+ cv::Rect bbox = cv::boundingRect(_canvasMask);
770
+ if (bbox.width > 0 && bbox.height > 0) {
771
+ cropRect = bbox;
772
+ }
773
+ }
774
+ cv::Mat cropped = _canvas(cropRect).clone();
775
+
776
+ // V11 Gap #14: NO output rotation needed.
777
+ //
778
+ // The earlier (v7) sensor-native canvas needed a gravity-derived
779
+ // rotation because the canvas was the camera buffer's pixel
780
+ // layout — buffer-Y was image-down for portrait, image-right for
781
+ // landscape, etc. V8+ switched to a panorama-frame canvas
782
+ // (gravity-up = +panorama-Y; the warp Y-flip puts world-up at
783
+ // image-top by construction). V12 briefly tried spherical but
784
+ // V12.2 reverted to cylindrical — the gravity-alignment guarantee
785
+ // is the same. The canvas IS already correctly oriented for
786
+ // any device hold.
787
+ cv::Mat out = cropped;
788
+
789
+ if (applyExposureComp && !out.empty()) {
790
+ // CLAHE on the L channel of Lab. Preserves colour, evens
791
+ // out luminance variation across the panorama. Conservative
792
+ // clipLimit=2.0 — enough to even out auto-exposure bands,
793
+ // not so much that it crushes highlight/shadow detail.
794
+ cv::Mat lab;
795
+ cv::cvtColor(out, lab, cv::COLOR_BGR2Lab);
796
+ std::vector<cv::Mat> labChannels(3);
797
+ cv::split(lab, labChannels);
798
+ cv::Ptr<cv::CLAHE> clahe = cv::createCLAHE(2.0, cv::Size(8, 8));
799
+ clahe->apply(labChannels[0], labChannels[0]);
800
+ cv::merge(labChannels, lab);
801
+ cv::cvtColor(lab, out, cv::COLOR_Lab2BGR);
802
+ }
803
+
804
+ int q = (int)std::clamp((long long)quality, 0LL, 100LL);
805
+ std::vector<int> params = {cv::IMWRITE_JPEG_QUALITY, q};
806
+ NSString *cleanPath = [outputPath hasPrefix:@"file://"]
807
+ ? [outputPath substringFromIndex:7]
808
+ : outputPath;
809
+ bool ok = cv::imwrite(std::string([cleanPath UTF8String]), out, params);
810
+ NSLog(@"[RLIS-PIP] imwrite path=%@ size=%dx%d quality=%d ok=%d",
811
+ cleanPath, out.cols, out.rows, q, (int)ok);
812
+ if (!ok) {
813
+ if (error) {
814
+ *error = [NSError errorWithDomain:RNImageStitcherIncrementalErrorDomain
815
+ code:2
816
+ userInfo:@{NSLocalizedDescriptionKey:
817
+ [NSString stringWithFormat:
818
+ @"Failed to write JPEG to %@", outputPath]}];
819
+ }
820
+ return nil;
821
+ }
822
+
823
+ RLISSnapshot *snap = [[RLISSnapshot alloc] init];
824
+ snap.panoramaPath = cleanPath;
825
+ snap.width = out.cols;
826
+ snap.height = out.rows;
827
+ snap.acceptedCount = _accepted;
828
+ return snap;
829
+ }
830
+
831
+ // ── Internals ───────────────────────────────────────────────────────
832
+
833
+ static double msSince(std::chrono::steady_clock::time_point t0) {
834
+ auto dt = std::chrono::steady_clock::now() - t0;
835
+ return std::chrono::duration_cast<std::chrono::microseconds>(dt).count() / 1000.0;
836
+ }
837
+
838
+ /// Quaternion (x, y, z, w) → 3x3 rotation matrix (CV_64F).
839
+ /// Defensive: normalises if the input isn't unit-length.
840
+ static cv::Mat quaternionToRotationMat(double qx, double qy, double qz, double qw) {
841
+ double n = std::sqrt(qx*qx + qy*qy + qz*qz + qw*qw);
842
+ if (n > 1e-9) { qx /= n; qy /= n; qz /= n; qw /= n; }
843
+ return (cv::Mat_<double>(3, 3) <<
844
+ 1 - 2*(qy*qy + qz*qz), 2*(qx*qy - qw*qz), 2*(qx*qz + qw*qy),
845
+ 2*(qx*qy + qw*qz), 1 - 2*(qx*qx + qz*qz), 2*(qy*qz - qw*qx),
846
+ 2*(qx*qz - qw*qy), 2*(qy*qz + qw*qx), 1 - 2*(qx*qx + qy*qy));
847
+ }
848
+
849
+ // `sensorRotationMatrix` was removed in V7 — the rotation chain it
850
+ // powered is no longer in the homography path. See the v7 commit
851
+ // for the architectural fix that replaced it with sensor-native
852
+ // compute + output-only rotation.
853
+
854
+ /// Compute fractional overlap between consecutive frames assuming the
855
+ /// camera is rotated about its centre by (deltaYaw, deltaPitch) in
856
+ /// radians. Output is in percent. We take the dominant axis (the
857
+ /// one with larger angular delta) as the pan axis — overlap on that
858
+ /// axis is what determines whether the frames have moved enough.
859
+ /// V11 Gap #5: sensor-frame gate.
860
+ ///
861
+ /// Inputs are absolute rotation magnitudes (radians) about the
862
+ /// sensor's +X and +Y axes — these axes are FIXED to the device
863
+ /// hardware regardless of how the operator is holding the phone.
864
+ ///
865
+ /// sensorRotXRad: rotation about sensor +X → vertical scene shift
866
+ /// → compare against fovV (sensor's vertical FoV)
867
+ /// sensorRotYRad: rotation about sensor +Y → horizontal scene shift
868
+ /// → compare against fovH (sensor's horizontal FoV)
869
+ ///
870
+ /// The "dominant axis = pan direction" heuristic still applies — pick
871
+ /// whichever rotation is larger and use the matching FoV to compute
872
+ /// the per-axis overlap. Output is overlap percent [0, 100].
873
+ static double computeOverlapPctSensor(double sensorRotXRad,
874
+ double sensorRotYRad,
875
+ double fovHorizDegrees,
876
+ double fovVertDegrees)
877
+ {
878
+ double fovH = fovHorizDegrees * M_PI / 180.0;
879
+ double fovV = fovVertDegrees * M_PI / 180.0;
880
+ if (fovH <= 1e-6) fovH = 65.0 * M_PI / 180.0;
881
+ if (fovV <= 1e-6) fovV = 50.0 * M_PI / 180.0;
882
+
883
+ double overlap;
884
+ if (sensorRotYRad >= sensorRotXRad) {
885
+ overlap = 1.0 - sensorRotYRad / fovH;
886
+ } else {
887
+ overlap = 1.0 - sensorRotXRad / fovV;
888
+ }
889
+ return std::clamp(overlap, 0.0, 1.0) * 100.0;
890
+ }
891
+
892
+ - (BOOL)convertPixelBuffer:(CVPixelBufferRef)pixelBuffer toMat:(cv::Mat &)outBGR {
893
+ if (pixelBuffer == NULL) return NO;
894
+ OSType pf = CVPixelBufferGetPixelFormatType(pixelBuffer);
895
+
896
+ CVReturn lockResult = CVPixelBufferLockBaseAddress(pixelBuffer,
897
+ kCVPixelBufferLock_ReadOnly);
898
+ if (lockResult != kCVReturnSuccess) return NO;
899
+
900
+ cv::Mat frame;
901
+ BOOL ok = NO;
902
+
903
+ if (pf == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange ||
904
+ pf == kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange) {
905
+ // ARKit's NV12 — Y plane in plane 0, interleaved CbCr in plane 1.
906
+ // OpenCV exposes a direct NV12 → BGR conversion.
907
+ size_t w = CVPixelBufferGetWidth(pixelBuffer);
908
+ size_t h = CVPixelBufferGetHeight(pixelBuffer);
909
+ size_t yStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0);
910
+ size_t cStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1);
911
+ uint8_t *yPlane = (uint8_t *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0);
912
+ uint8_t *cPlane = (uint8_t *)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1);
913
+
914
+ // Build a contiguous YUV buffer expected by cvtColorTwoPlane.
915
+ // OpenCV provides cvtColorTwoPlane which takes Y and UV planes
916
+ // separately — perfect for NV12 with potentially-different
917
+ // strides between planes.
918
+ cv::Mat yMat((int)h, (int)w, CV_8UC1, yPlane, yStride);
919
+ cv::Mat cMat((int)h / 2, (int)w / 2, CV_8UC2, cPlane, cStride);
920
+ int code = (pf == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)
921
+ ? cv::COLOR_YUV2BGR_NV12 : cv::COLOR_YUV2BGR_NV12;
922
+ cv::cvtColorTwoPlane(yMat, cMat, frame, code);
923
+ ok = YES;
924
+ } else if (pf == kCVPixelFormatType_32BGRA) {
925
+ size_t w = CVPixelBufferGetWidth(pixelBuffer);
926
+ size_t h = CVPixelBufferGetHeight(pixelBuffer);
927
+ size_t stride = CVPixelBufferGetBytesPerRow(pixelBuffer);
928
+ uint8_t *base = (uint8_t *)CVPixelBufferGetBaseAddress(pixelBuffer);
929
+ cv::Mat bgra((int)h, (int)w, CV_8UC4, base, stride);
930
+ cv::cvtColor(bgra, frame, cv::COLOR_BGRA2BGR);
931
+ ok = YES;
932
+ }
933
+ CVPixelBufferUnlockBaseAddress(pixelBuffer, kCVPixelBufferLock_ReadOnly);
934
+ if (!ok) return NO;
935
+
936
+ // V7: NO input rotation. ARKit delivers sensor-native landscape
937
+ // pixels and we keep them that way through the entire compute
938
+ // pipeline. This is the architectural fix that resolves the
939
+ // v6 rotation-vs-canvas mismatch — we no longer need an `R2S`
940
+ // chain in the homography to undo a rotation we shouldn't have
941
+ // applied in the first place.
942
+ //
943
+ // The sensor's native orientation is what the ARKit pose +
944
+ // intrinsics describe. Working directly in that frame keeps
945
+ // `H = K · R_rel · K⁻¹` clean and bug-free. Output rotation
946
+ // for display happens AT SNAPSHOT/FINALIZE time only.
947
+ //
948
+ // Uniform-scale downsample preserves the 4:3 sensor aspect ratio
949
+ // (no non-uniform stretch). Picks whichever dimension hits the
950
+ // compose budget first; the other comes out proportional.
951
+ double scale = std::min(
952
+ (double)_composeWidth / (double)frame.cols,
953
+ (double)_composeHeight / (double)frame.rows
954
+ );
955
+ if (scale > 1.0) scale = 1.0; // never upscale
956
+ int outW = std::max(1, (int)std::round(frame.cols * scale));
957
+ int outH = std::max(1, (int)std::round(frame.rows * scale));
958
+ cv::Size target(outW, outH);
959
+ if (frame.cols == outW && frame.rows == outH) {
960
+ outBGR = frame;
961
+ } else {
962
+ cv::resize(frame, outBGR, target, 0, 0, cv::INTER_AREA);
963
+ }
964
+ return YES;
965
+ }
966
+
967
+ // `placeFirstFrame` was removed in v6 — the first-frame logic is now
968
+ // inlined in `ingestPixelBuffer:` so the engine can capture the
969
+ // reference pose + intrinsics in the same place it positions the
970
+ // frame on the canvas.
971
+
972
+ /// V12.2 hand-rolled CYLINDRICAL projection (with V12 mirror fix
973
+ /// kept). V12 had tried spherical to handle extreme pitch, but
974
+ /// spherical bulges both axes and made every level frame look
975
+ /// fisheye. Reverted to cylindrical here; pitch-axis flexibility
976
+ /// will come from making the cylinder axis orientation-aware
977
+ /// (Step 3) rather than from changing the projection itself.
978
+ ///
979
+ /// Cylinder parameterised by:
980
+ /// theta = horizontal angle around panorama-Y, atan2(-wx, wz)
981
+ /// (the −wx is the V12 mirror fix —
982
+ /// panorama-X is "user's left" in our
983
+ /// right-handed setup, so we flip X before
984
+ /// atan2 to put user's-right at canvas-right)
985
+ /// h = wy / sqrt(wx² + wz²) (height up the cylinder)
986
+ /// pixel = (focal · theta, -focal · h)
987
+ /// (the −h is the Y-flip — panorama +Y is
988
+ /// gravity-up, image +Y is image-down)
989
+ ///
990
+ /// Inverse map:
991
+ /// theta = canvas_x / focal
992
+ /// h = -canvas_y / focal
993
+ /// ray = (-sin(theta), h, cos(theta))
994
+ ///
995
+ /// Vertical lines (perpendicular to the cylinder axis) stay
996
+ /// straight in the projection — that's why cylindrical produces a
997
+ /// natural-looking panorama for level scenes. Pitch close to ±90°
998
+ /// (looking straight up/down) makes h = wy/sqrt(wx²+wz²) blow up
999
+ /// and the bbox grows unbounded; the canvas-x2 sanity check rejects
1000
+ /// those frames.
1001
+ ///
1002
+ /// Returns the bbox's top-left in cylindrical-pixel coords; the
1003
+ /// caller adds the canvas origin offset to land it on the canvas.
1004
+ - (cv::Point)cylindricalWarp:(const cv::Mat &)src
1005
+ rArkit:(const cv::Mat &)rArkit
1006
+ outImage:(cv::Mat &)outImage
1007
+ outMask:(cv::Mat &)outMask
1008
+ {
1009
+ if (_R_panToWorld.empty() || _focalCompose <= 0) {
1010
+ outImage = cv::Mat();
1011
+ outMask = cv::Mat();
1012
+ return cv::Point(0, 0);
1013
+ }
1014
+
1015
+ // V14.0pre — cv::detail::CylindricalWarper does the projection.
1016
+ // R = camera-to-panorama rotation in OpenCV camera frame. K is
1017
+ // intrinsics in CV_32F (warper requires float). Cylinder radius
1018
+ // = focal length.
1019
+ //
1020
+ // Replaces ~240 lines of hand-rolled per-corner forward projection
1021
+ // + bbox computation + inverse-map remap. Same K + R inputs, same
1022
+ // cv::Point corner output (top-left of warped image in cylindrical-
1023
+ // pixel space). Battle-tested edge handling, antialiased remap.
1024
+ cv::Mat R_panToCam = _M_arkitToCv * rArkit.t() * _R_panToWorld;
1025
+ cv::Mat R_camToPan = R_panToCam.t();
1026
+
1027
+ cv::Mat K32, R32;
1028
+ _K_compose.convertTo(K32, CV_32F);
1029
+ R_camToPan.convertTo(R32, CV_32F);
1030
+
1031
+ // V15 — projection selectable via _config.hybridProjection.
1032
+ // Default is Planar (cv::detail::PlaneWarper) for V15 hybrid mode,
1033
+ // because cylindrical projection has the V12.x roll-asymmetry bug
1034
+ // that's been documented in the V14 spec. Planar is well-behaved
1035
+ // for pans <60°, which is the typical retail use case.
1036
+ cv::Point corner;
1037
+ cv::Mat whiteFrame(src.size(), CV_8UC1, cv::Scalar(255));
1038
+
1039
+ if (_config.hybridProjection == RLISHybridProjectionPlanar) {
1040
+ cv::detail::PlaneWarper warper((float)_focalCompose);
1041
+ corner = warper.warp(src, K32, R32,
1042
+ cv::INTER_LINEAR,
1043
+ cv::BORDER_REFLECT,
1044
+ outImage);
1045
+ warper.warp(whiteFrame, K32, R32,
1046
+ cv::INTER_NEAREST,
1047
+ cv::BORDER_CONSTANT,
1048
+ outMask);
1049
+ } else {
1050
+ cv::detail::CylindricalWarper warper((float)_focalCompose);
1051
+ corner = warper.warp(src, K32, R32,
1052
+ cv::INTER_LINEAR,
1053
+ cv::BORDER_REFLECT,
1054
+ outImage);
1055
+ warper.warp(whiteFrame, K32, R32,
1056
+ cv::INTER_NEAREST,
1057
+ cv::BORDER_CONSTANT,
1058
+ outMask);
1059
+ }
1060
+
1061
+ static bool _v14LoggedFirstWarp = false;
1062
+ if (!_v14LoggedFirstWarp) {
1063
+ _v14LoggedFirstWarp = true;
1064
+ NSLog(@"[V15-warp] hybrid projection=%@ corner=(%d,%d) outSize=%dx%d focal=%.1f",
1065
+ _config.hybridProjection == RLISHybridProjectionPlanar
1066
+ ? @"Planar" : @"Cylindrical",
1067
+ corner.x, corner.y, outImage.cols, outImage.rows, _focalCompose);
1068
+ NSLog(@"[V14.0pre-warp] OpenCV CylindricalWarper "
1069
+ @"corner=(%d,%d) outSize=%dx%d focal=%.1f",
1070
+ corner.x, corner.y, outImage.cols, outImage.rows,
1071
+ _focalCompose);
1072
+ }
1073
+
1074
+ return corner;
1075
+ }
1076
+
1077
+ /// V9b KLT optical flow refinement. Computes a residual translation
1078
+ /// (dx, dy) the new warped frame should be shifted by to align
1079
+ /// pixel-perfectly with the existing canvas in their overlap region.
1080
+ ///
1081
+ /// Algorithm:
1082
+ /// 1. Compute the overlap rect between warpedNew (placed at
1083
+ /// canvasOrigin) and the existing canvas mask.
1084
+ /// 2. Convert both regions to grayscale.
1085
+ /// 3. `cv::goodFeaturesToTrack` on the canvas overlap: find ~50
1086
+ /// strong corners.
1087
+ /// 4. `cv::calcOpticalFlowPyrLK` to track those corners into the
1088
+ /// warped overlap.
1089
+ /// 5. Median (dx, dy) over inlier tracks = residual shift.
1090
+ ///
1091
+ /// Returns (0, 0) if not enough tracks, or if the shift exceeds a
1092
+ /// sanity threshold (likely a bad frame, don't bias the placement).
1093
+ - (cv::Point2f)refineWithOpticalFlow:(const cv::Mat &)warpedNew
1094
+ newMask:(const cv::Mat &)warpedNewMask
1095
+ canvasOrigin:(cv::Point)canvasOrigin
1096
+ {
1097
+ if (_accepted == 0) return cv::Point2f(0, 0);
1098
+
1099
+ // Compute overlap rect (canvas coords).
1100
+ cv::Rect newRect(canvasOrigin.x, canvasOrigin.y,
1101
+ warpedNew.cols, warpedNew.rows);
1102
+ cv::Rect canvasBounds(0, 0, _canvas.cols, _canvas.rows);
1103
+ cv::Rect newOnCanvas = newRect & canvasBounds;
1104
+ if (newOnCanvas.width < 16 || newOnCanvas.height < 16) {
1105
+ return cv::Point2f(0, 0);
1106
+ }
1107
+
1108
+ cv::Mat canvasOverlap = _canvas(newOnCanvas);
1109
+ cv::Mat canvasMaskOverlap = _canvasMask(newOnCanvas);
1110
+ cv::Mat warpedOverlap = warpedNew(cv::Rect(
1111
+ newOnCanvas.x - newRect.x, newOnCanvas.y - newRect.y,
1112
+ newOnCanvas.width, newOnCanvas.height));
1113
+ cv::Mat warpedMaskOverlap = warpedNewMask(cv::Rect(
1114
+ newOnCanvas.x - newRect.x, newOnCanvas.y - newRect.y,
1115
+ newOnCanvas.width, newOnCanvas.height));
1116
+
1117
+ // Both regions need pixels — bail if either side is mostly empty.
1118
+ int canvasFilled = cv::countNonZero(canvasMaskOverlap);
1119
+ int warpedFilled = cv::countNonZero(warpedMaskOverlap);
1120
+ int totalPx = newOnCanvas.width * newOnCanvas.height;
1121
+ if (canvasFilled < totalPx / 4 || warpedFilled < totalPx / 4) {
1122
+ return cv::Point2f(0, 0);
1123
+ }
1124
+
1125
+ cv::Mat canvasGray, warpedGray;
1126
+ cv::cvtColor(canvasOverlap, canvasGray, cv::COLOR_BGR2GRAY);
1127
+ cv::cvtColor(warpedOverlap, warpedGray, cv::COLOR_BGR2GRAY);
1128
+
1129
+ // Find strong corners in canvas, restricted to the overlap mask.
1130
+ std::vector<cv::Point2f> canvasPts;
1131
+ cv::Mat featureMask;
1132
+ cv::bitwise_and(canvasMaskOverlap, warpedMaskOverlap, featureMask);
1133
+ cv::goodFeaturesToTrack(canvasGray, canvasPts, /*maxCorners=*/64,
1134
+ /*qualityLevel=*/0.01, /*minDistance=*/10,
1135
+ featureMask, /*blockSize=*/3, false, 0.04);
1136
+ if (canvasPts.size() < 8) return cv::Point2f(0, 0);
1137
+
1138
+ // Forward track: canvas → warped.
1139
+ std::vector<cv::Point2f> warpedPts;
1140
+ std::vector<uchar> statusFwd;
1141
+ std::vector<float> errFwd;
1142
+ cv::calcOpticalFlowPyrLK(canvasGray, warpedGray,
1143
+ canvasPts, warpedPts, statusFwd, errFwd,
1144
+ cv::Size(21, 21), 3,
1145
+ cv::TermCriteria(
1146
+ cv::TermCriteria::COUNT
1147
+ | cv::TermCriteria::EPS,
1148
+ 20, 0.03));
1149
+
1150
+ // V11 Gap #9: forward-backward bidirectional check. Track the
1151
+ // forward-tracked points BACK into the canvas frame; reject any
1152
+ // point whose round-trip error exceeds 1 px. Status flag and
1153
+ // patch-error threshold catch obvious failures, but trackers
1154
+ // can drift inside the search window without flipping status —
1155
+ // FB error is the standard reliability gate (every production
1156
+ // OF aligner uses it).
1157
+ std::vector<cv::Point2f> canvasPtsRev;
1158
+ std::vector<uchar> statusBwd;
1159
+ std::vector<float> errBwd;
1160
+ cv::calcOpticalFlowPyrLK(warpedGray, canvasGray,
1161
+ warpedPts, canvasPtsRev, statusBwd, errBwd,
1162
+ cv::Size(21, 21), 3,
1163
+ cv::TermCriteria(
1164
+ cv::TermCriteria::COUNT
1165
+ | cv::TermCriteria::EPS,
1166
+ 20, 0.03));
1167
+
1168
+ // Build the inlier set: forward + backward both succeeded, FB
1169
+ // error < 1 px, displacement in plausible range.
1170
+ std::vector<cv::Point2f> goodCanvas, goodWarped;
1171
+ goodCanvas.reserve(canvasPts.size());
1172
+ goodWarped.reserve(canvasPts.size());
1173
+ for (size_t i = 0; i < canvasPts.size(); i++) {
1174
+ if (!statusFwd[i] || !statusBwd[i]) continue;
1175
+ if (errFwd[i] > 30.0f || errBwd[i] > 30.0f) continue;
1176
+ float fbDx = canvasPtsRev[i].x - canvasPts[i].x;
1177
+ float fbDy = canvasPtsRev[i].y - canvasPts[i].y;
1178
+ if (fbDx*fbDx + fbDy*fbDy > 1.0f) continue; // > 1 px FB error
1179
+ float dx = warpedPts[i].x - canvasPts[i].x;
1180
+ float dy = warpedPts[i].y - canvasPts[i].y;
1181
+ if (std::fabs(dx) > 30.0f || std::fabs(dy) > 30.0f) continue;
1182
+ goodCanvas.push_back(canvasPts[i]);
1183
+ goodWarped.push_back(warpedPts[i]);
1184
+ }
1185
+ if (goodCanvas.size() < 6) return cv::Point2f(0, 0);
1186
+
1187
+ // V11 Gap #10: 2-D RANSAC translation fit (instead of per-axis
1188
+ // independent median, which can pick a (dx, dy) that no single
1189
+ // point voted for — an issue in multi-modal flow scenes like a
1190
+ // shelf with a moving customer).
1191
+ //
1192
+ // `estimateAffinePartial2D` fits a 2.5-DoF (rotation + uniform
1193
+ // scale + translation) similarity transform with RANSAC. We
1194
+ // use only the translation component; the rotation/scale fall-
1195
+ // out shouldn't be applied (cylindrical warp already handled
1196
+ // those — OF is only correcting residual translation).
1197
+ std::vector<uchar> ransacInliers;
1198
+ cv::Mat affine = cv::estimateAffinePartial2D(
1199
+ goodCanvas, goodWarped, ransacInliers, cv::RANSAC,
1200
+ /*ransacReprojThreshold=*/3.0,
1201
+ /*maxIters=*/2000,
1202
+ /*confidence=*/0.99,
1203
+ /*refineIters=*/10);
1204
+ if (affine.empty()) return cv::Point2f(0, 0);
1205
+ // Inlier ratio sanity check.
1206
+ int inlierCount = cv::countNonZero(ransacInliers);
1207
+ if (inlierCount < 6 || inlierCount * 2 < (int)goodCanvas.size()) {
1208
+ return cv::Point2f(0, 0);
1209
+ }
1210
+ float medDx = (float)affine.at<double>(0, 2);
1211
+ float medDy = (float)affine.at<double>(1, 2);
1212
+
1213
+ // The track tells us "to align canvas pixels with warped pixels,
1214
+ // shift WARPED by (-medDx, -medDy)". We shift the placement
1215
+ // of warped on canvas by the opposite to compensate.
1216
+ return cv::Point2f(-medDx, -medDy);
1217
+ }
1218
+
1219
+ /// V11 Gap #11: NARROW-band feather blend. Earlier versions used
1220
+ /// `alpha = distNew / (distNew + distCanvas)` over the FULL overlap,
1221
+ /// which smears every pixel of disagreement across the entire
1222
+ /// overlap region — the textbook ghosting source called out by
1223
+ /// Brown-Lowe 2007. At the typical ARKit ~1-2° pose error + KLT-
1224
+ /// refined ~5 px residual, full-overlap feather creates visible
1225
+ /// double-image.
1226
+ ///
1227
+ /// Narrow-band approach: define the SEAM as `distNew == distCanvas`
1228
+ /// (the locus of equal-distance-from-each-frame's-edge points).
1229
+ /// Within `kSeamBandPx` of the seam, smoothly transition alpha 0→1.
1230
+ /// Outside the band, alpha is binary (0 or 1). Each pixel comes
1231
+ /// from EXACTLY ONE frame, except in the small seam band — so any
1232
+ /// per-pixel misalignment can't produce ghosts.
1233
+ - (void)featherBlendWarped:(cv::Mat)warped
1234
+ mask:(cv::Mat)warpedMask
1235
+ intoCanvas:(cv::Mat)canvasRoi
1236
+ canvasMask:(cv::Mat)canvasMaskRoi
1237
+ {
1238
+ // V11 Gap #13: per-pair gain compensation BEFORE blending.
1239
+ // Frames captured 200ms apart often differ in luminance by
1240
+ // 5-15% due to auto-exposure drift; without compensation the
1241
+ // panorama shows visible vertical/horizontal banding at every
1242
+ // seam. Apply a per-channel mean ratio (canvas / warped)
1243
+ // computed on the overlap region. Conservative bounds to
1244
+ // avoid amplifying noise.
1245
+ cv::Mat warpedAdj;
1246
+ cv::Mat overlapMask;
1247
+ cv::bitwise_and(canvasMaskRoi, warpedMask, overlapMask);
1248
+ int overlapPx = cv::countNonZero(overlapMask);
1249
+ if (overlapPx > 100) {
1250
+ cv::Scalar canvasMean = cv::mean(canvasRoi, overlapMask);
1251
+ cv::Scalar warpedMean = cv::mean(warped, overlapMask);
1252
+ double gainB = (warpedMean[0] > 1.0) ? (canvasMean[0] / warpedMean[0]) : 1.0;
1253
+ double gainG = (warpedMean[1] > 1.0) ? (canvasMean[1] / warpedMean[1]) : 1.0;
1254
+ double gainR = (warpedMean[2] > 1.0) ? (canvasMean[2] / warpedMean[2]) : 1.0;
1255
+ // Clamp gains to ±25% to avoid blowing out highlights or
1256
+ // crushing shadows on a single noisy mean estimate.
1257
+ gainB = std::clamp(gainB, 0.75, 1.25);
1258
+ gainG = std::clamp(gainG, 0.75, 1.25);
1259
+ gainR = std::clamp(gainR, 0.75, 1.25);
1260
+ cv::Mat warpedF;
1261
+ warped.convertTo(warpedF, CV_32FC3);
1262
+ std::vector<cv::Mat> ch(3);
1263
+ cv::split(warpedF, ch);
1264
+ ch[0] *= gainB;
1265
+ ch[1] *= gainG;
1266
+ ch[2] *= gainR;
1267
+ cv::merge(ch, warpedF);
1268
+ warpedF.convertTo(warpedAdj, CV_8UC3);
1269
+ } else {
1270
+ warpedAdj = warped;
1271
+ }
1272
+
1273
+ cv::Mat distNew, distCanvas;
1274
+ cv::distanceTransform(warpedMask, distNew, cv::DIST_L2, 3);
1275
+ cv::distanceTransform(canvasMaskRoi, distCanvas, cv::DIST_L2, 3);
1276
+
1277
+ // Signed distance from seam: positive = "new wins side", negative
1278
+ // = "canvas wins side". Pixels >= +bandHalf use new (alpha=1),
1279
+ // pixels <= -bandHalf use canvas (alpha=0), in-between band gets
1280
+ // a smooth ramp.
1281
+ constexpr float kSeamBandPx = 5.0f;
1282
+ cv::Mat signedDist = distNew - distCanvas;
1283
+ cv::Mat alpha;
1284
+ // alpha = clamp((signedDist + bandHalf) / band, 0, 1)
1285
+ signedDist.convertTo(alpha, CV_32F,
1286
+ 1.0 / (2.0 * kSeamBandPx),
1287
+ 0.5);
1288
+ cv::min(alpha, 1.0f, alpha);
1289
+ cv::max(alpha, 0.0f, alpha);
1290
+
1291
+ // First-touch regions: alpha=1 unconditionally (canvas was empty
1292
+ // here, new frame is the only source).
1293
+ cv::Mat noPrior;
1294
+ cv::compare(canvasMaskRoi, 0, noPrior, cv::CMP_EQ);
1295
+ alpha.setTo(1.0f, noPrior);
1296
+
1297
+ // Outside-of-new regions: keep canvas (alpha=0).
1298
+ cv::Mat noNew;
1299
+ cv::compare(warpedMask, 0, noNew, cv::CMP_EQ);
1300
+ alpha.setTo(0.0f, noNew);
1301
+
1302
+ // Per-channel blend.
1303
+ cv::Mat alpha3, invAlpha3;
1304
+ cv::Mat ch[] = {alpha, alpha, alpha};
1305
+ cv::merge(ch, 3, alpha3);
1306
+ invAlpha3 = cv::Scalar(1, 1, 1) - alpha3;
1307
+
1308
+ cv::Mat warpedF, canvasF;
1309
+ warpedAdj.convertTo(warpedF, CV_32FC3); // V11 Gap #13: use gain-corrected
1310
+ canvasRoi.convertTo(canvasF, CV_32FC3);
1311
+ cv::Mat blendedF = warpedF.mul(alpha3) + canvasF.mul(invAlpha3);
1312
+ cv::Mat blended8;
1313
+ blendedF.convertTo(blended8, CV_8UC3);
1314
+
1315
+ // Write back: only where the union mask has content.
1316
+ cv::Mat unionMask;
1317
+ cv::bitwise_or(warpedMask, canvasMaskRoi, unionMask);
1318
+ blended8.copyTo(canvasRoi, unionMask);
1319
+ cv::bitwise_or(canvasMaskRoi, warpedMask, canvasMaskRoi);
1320
+ }
1321
+
1322
+ // V11 Gap #21: deleted ~85 lines of dead `warpAndBlend` (legacy v7
1323
+ // planar warp + Gaussian-blurred binary alpha-blend). Was never
1324
+ // called after v9 switched to cylindricalWarp + featherBlendWarped.
1325
+
1326
+ @end