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.
- package/CHANGELOG.md +96 -0
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +189 -0
- package/RNImageStitcher.podspec +76 -0
- package/android/build.gradle +224 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/cpp/CMakeLists.txt +124 -0
- package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
- package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
- package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
- package/cpp/ar_frame_pose.h +63 -0
- package/cpp/keyframe_gate.cpp +927 -0
- package/cpp/keyframe_gate.hpp +240 -0
- package/cpp/stitcher.cpp +2207 -0
- package/cpp/stitcher.hpp +275 -0
- package/dist/ar/useARSession.d.ts +102 -0
- package/dist/ar/useARSession.js +133 -0
- package/dist/camera/ARCameraView.d.ts +93 -0
- package/dist/camera/ARCameraView.js +170 -0
- package/dist/camera/Camera.d.ts +134 -0
- package/dist/camera/Camera.js +688 -0
- package/dist/camera/CameraShutter.d.ts +80 -0
- package/dist/camera/CameraShutter.js +237 -0
- package/dist/camera/CameraView.d.ts +65 -0
- package/dist/camera/CameraView.js +117 -0
- package/dist/camera/CaptureControlsBar.d.ts +87 -0
- package/dist/camera/CaptureControlsBar.js +82 -0
- package/dist/camera/CaptureHeader.d.ts +62 -0
- package/dist/camera/CaptureHeader.js +81 -0
- package/dist/camera/CapturePreview.d.ts +70 -0
- package/dist/camera/CapturePreview.js +188 -0
- package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
- package/dist/camera/CaptureStatusOverlay.js +326 -0
- package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
- package/dist/camera/CaptureThumbnailStrip.js +177 -0
- package/dist/camera/IncrementalPanGuide.d.ts +83 -0
- package/dist/camera/IncrementalPanGuide.js +267 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
- package/dist/camera/PanoramaBandOverlay.js +399 -0
- package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
- package/dist/camera/PanoramaConfirmModal.js +128 -0
- package/dist/camera/PanoramaGuidance.d.ts +79 -0
- package/dist/camera/PanoramaGuidance.js +246 -0
- package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
- package/dist/camera/PanoramaSettingsModal.js +611 -0
- package/dist/camera/ViewportCropOverlay.d.ts +46 -0
- package/dist/camera/ViewportCropOverlay.js +67 -0
- package/dist/camera/useCapture.d.ts +111 -0
- package/dist/camera/useCapture.js +160 -0
- package/dist/camera/useDeviceOrientation.d.ts +48 -0
- package/dist/camera/useDeviceOrientation.js +131 -0
- package/dist/camera/useVideoCapture.d.ts +79 -0
- package/dist/camera/useVideoCapture.js +151 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +39 -0
- package/dist/quality/normaliseOrientation.d.ts +36 -0
- package/dist/quality/normaliseOrientation.js +62 -0
- package/dist/quality/runQualityCheck.d.ts +41 -0
- package/dist/quality/runQualityCheck.js +98 -0
- package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
- package/dist/sensors/useIMUTranslationGate.js +235 -0
- package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
- package/dist/stitching/IncrementalStitcherView.js +157 -0
- package/dist/stitching/incremental.d.ts +930 -0
- package/dist/stitching/incremental.js +133 -0
- package/dist/stitching/stitchFrames.d.ts +55 -0
- package/dist/stitching/stitchFrames.js +56 -0
- package/dist/stitching/stitchVideo.d.ts +119 -0
- package/dist/stitching/stitchVideo.js +57 -0
- package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
- package/dist/stitching/useIncrementalJSDriver.js +199 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
- package/dist/stitching/useIncrementalStitcher.js +172 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +15 -0
- package/ios/Package.swift +72 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
- package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
- package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
- package/package.json +73 -0
- package/react-native.config.js +34 -0
- package/scripts/opencv-version.txt +1 -0
- package/scripts/postinstall-fetch-binaries.js +286 -0
- package/src/ar/useARSession.ts +210 -0
- package/src/camera/.gitkeep +0 -0
- package/src/camera/ARCameraView.tsx +256 -0
- package/src/camera/Camera.tsx +1053 -0
- package/src/camera/CameraShutter.tsx +292 -0
- package/src/camera/CameraView.tsx +157 -0
- package/src/camera/CaptureControlsBar.tsx +204 -0
- package/src/camera/CaptureHeader.tsx +184 -0
- package/src/camera/CapturePreview.tsx +318 -0
- package/src/camera/CaptureStatusOverlay.tsx +391 -0
- package/src/camera/CaptureThumbnailStrip.tsx +277 -0
- package/src/camera/IncrementalPanGuide.tsx +328 -0
- package/src/camera/PanoramaBandOverlay.tsx +498 -0
- package/src/camera/PanoramaConfirmModal.tsx +206 -0
- package/src/camera/PanoramaGuidance.tsx +327 -0
- package/src/camera/PanoramaSettingsModal.tsx +1357 -0
- package/src/camera/ViewportCropOverlay.tsx +81 -0
- package/src/camera/useCapture.ts +279 -0
- package/src/camera/useDeviceOrientation.ts +140 -0
- package/src/camera/useVideoCapture.ts +236 -0
- package/src/index.ts +53 -0
- package/src/quality/.gitkeep +0 -0
- package/src/quality/normaliseOrientation.ts +79 -0
- package/src/quality/runQualityCheck.ts +131 -0
- package/src/sensors/useIMUTranslationGate.ts +347 -0
- package/src/stitching/.gitkeep +0 -0
- package/src/stitching/IncrementalStitcherView.tsx +198 -0
- package/src/stitching/incremental.ts +1021 -0
- package/src/stitching/stitchFrames.ts +88 -0
- package/src/stitching/stitchVideo.ts +153 -0
- package/src/stitching/useIncrementalJSDriver.ts +273 -0
- package/src/stitching/useIncrementalStitcher.ts +252 -0
- 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
|