react-native-image-stitcher 0.15.2 → 0.16.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 +124 -1
- package/README.md +116 -5
- package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
- package/cpp/crop_quad.cpp +162 -0
- package/cpp/crop_quad.hpp +163 -0
- package/cpp/stitcher.cpp +651 -55
- package/cpp/stitcher.hpp +10 -0
- package/cpp/warp_guard.hpp +212 -0
- package/dist/camera/Camera.d.ts +196 -12
- package/dist/camera/Camera.js +629 -35
- package/dist/camera/CameraView.js +35 -16
- package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
- package/dist/camera/CaptureCountdownOverlay.js +239 -0
- package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
- package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
- package/dist/camera/CaptureMemoryPill.d.ts +9 -1
- package/dist/camera/CaptureMemoryPill.js +3 -3
- package/dist/camera/CapturePreview.js +2 -1
- package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
- package/dist/camera/CaptureStatusOverlay.js +22 -5
- package/dist/camera/CaptureThumbnailStrip.js +2 -1
- package/dist/camera/LateralMotionModal.d.ts +85 -0
- package/dist/camera/LateralMotionModal.js +134 -0
- package/dist/camera/PanHowToOverlay.d.ts +76 -0
- package/dist/camera/PanHowToOverlay.js +222 -0
- package/dist/camera/PanoramaSettings.d.ts +8 -6
- package/dist/camera/PanoramaSettings.js +26 -5
- package/dist/camera/PanoramaSettingsModal.js +4 -4
- package/dist/camera/RectCropPreview.d.ts +161 -0
- package/dist/camera/RectCropPreview.js +480 -0
- package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
- package/dist/camera/RotateToLandscapePrompt.js +138 -0
- package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
- package/dist/camera/buildPanoramaInitialSettings.js +9 -0
- package/dist/camera/cameraErrorMessages.d.ts +30 -1
- package/dist/camera/cameraErrorMessages.js +26 -10
- package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
- package/dist/camera/cameraGuidanceCopy.js +80 -0
- package/dist/camera/captureCountdown.d.ts +52 -0
- package/dist/camera/captureCountdown.js +76 -0
- package/dist/camera/captureWarnings.d.ts +90 -0
- package/dist/camera/captureWarnings.js +108 -0
- package/dist/camera/classifyStitchError.d.ts +30 -0
- package/dist/camera/classifyStitchError.js +42 -0
- package/dist/camera/cropGeometry.d.ts +136 -0
- package/dist/camera/cropGeometry.js +223 -0
- package/dist/camera/displayDecodeImageProps.d.ts +25 -0
- package/dist/camera/displayDecodeImageProps.js +29 -0
- package/dist/camera/guidanceGraphics.d.ts +58 -0
- package/dist/camera/guidanceGraphics.js +280 -0
- package/dist/camera/guidanceTokens.d.ts +54 -0
- package/dist/camera/guidanceTokens.js +58 -0
- package/dist/camera/panModeGate.d.ts +54 -0
- package/dist/camera/panModeGate.js +62 -0
- package/dist/camera/pickCaptureFormat.d.ts +71 -0
- package/dist/camera/pickCaptureFormat.js +85 -0
- package/dist/camera/stitchDebugInfo.d.ts +27 -0
- package/dist/camera/stitchDebugInfo.js +55 -0
- package/dist/camera/usePanMotion.d.ts +250 -0
- package/dist/camera/usePanMotion.js +451 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +33 -2
- package/dist/stitching/computeInscribedRect.d.ts +40 -0
- package/dist/stitching/computeInscribedRect.js +55 -0
- package/dist/stitching/cropQuad.d.ts +78 -0
- package/dist/stitching/cropQuad.js +116 -0
- package/dist/stitching/incremental.d.ts +45 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
- package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
- package/package.json +5 -1
- package/src/camera/Camera.tsx +994 -47
- package/src/camera/CameraView.tsx +48 -16
- package/src/camera/CaptureCountdownOverlay.tsx +272 -0
- package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
- package/src/camera/CaptureMemoryPill.tsx +17 -3
- package/src/camera/CapturePreview.tsx +5 -0
- package/src/camera/CaptureStatusOverlay.tsx +35 -7
- package/src/camera/CaptureThumbnailStrip.tsx +4 -0
- package/src/camera/LateralMotionModal.tsx +199 -0
- package/src/camera/PanHowToOverlay.tsx +246 -0
- package/src/camera/PanoramaSettings.ts +34 -11
- package/src/camera/PanoramaSettingsModal.tsx +4 -4
- package/src/camera/RectCropPreview.tsx +820 -0
- package/src/camera/RotateToLandscapePrompt.tsx +188 -0
- package/src/camera/buildPanoramaInitialSettings.ts +30 -1
- package/src/camera/cameraErrorMessages.ts +39 -2
- package/src/camera/cameraGuidanceCopy.ts +145 -0
- package/src/camera/captureCountdown.ts +83 -0
- package/src/camera/captureWarnings.ts +190 -0
- package/src/camera/classifyStitchError.ts +68 -0
- package/src/camera/cropGeometry.ts +268 -0
- package/src/camera/displayDecodeImageProps.ts +25 -0
- package/src/camera/guidanceGraphics.tsx +347 -0
- package/src/camera/guidanceTokens.ts +57 -0
- package/src/camera/panModeGate.ts +81 -0
- package/src/camera/pickCaptureFormat.ts +130 -0
- package/src/camera/stitchDebugInfo.ts +71 -0
- package/src/camera/usePanMotion.ts +667 -0
- package/src/index.ts +66 -3
- package/src/stitching/computeInscribedRect.ts +81 -0
- package/src/stitching/cropQuad.ts +167 -0
- package/src/stitching/incremental.ts +45 -0
- package/cpp/tests/CMakeLists.txt +0 -104
- package/cpp/tests/README.md +0 -86
- package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
- package/cpp/tests/pose_test.cpp +0 -74
- package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
- package/cpp/tests/stubs/jsi/jsi.h +0 -33
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
- package/cpp/tests/warp_guard_test.cpp +0 -48
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
- package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
- package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
- package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
- package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
- package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
- package/src/camera/__tests__/useContentRotation.test.ts +0 -89
- package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
- package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cropQuad — item-7 perspective crop: rectify a user-dragged
|
|
3
|
+
* quadrilateral to an upright rectangle.
|
|
4
|
+
*
|
|
5
|
+
* The post-capture crop editor (`src/camera/RectCropPreview.tsx`) lets the
|
|
6
|
+
* user drag 4 independent corners over the stitched result. When that
|
|
7
|
+
* quad isn't ~axis-aligned, the host calls THIS wrapper instead of the
|
|
8
|
+
* cheap `cropToRect`: it hands the 4 IMAGE-PIXEL corners to the native
|
|
9
|
+
* `BatchStitcher.cropToQuad`, which runs
|
|
10
|
+
* `cv::getPerspectiveTransform` + `cv::warpPerspective` to produce an
|
|
11
|
+
* upright rectangle (averaged opposite-edge dimensions) and overwrites the
|
|
12
|
+
* file in place.
|
|
13
|
+
*
|
|
14
|
+
* This is the typed twin of the `cropToRect` call in
|
|
15
|
+
* `example/InscribedRectDebug.tsx` — same native module (`BatchStitcher`),
|
|
16
|
+
* same in-place overwrite + `{ width, height }` result contract, same
|
|
17
|
+
* platform-availability fallback posture as
|
|
18
|
+
* `src/quality/normaliseOrientation.ts`.
|
|
19
|
+
*
|
|
20
|
+
* Corner-order contract: `quadImagePoints` MUST be in canonical
|
|
21
|
+
* [TL, TR, BR, BL] (clockwise from top-left) order — exactly what
|
|
22
|
+
* `cropGeometry.ts:orderQuadCorners` produces and `RectCropResult.quad`
|
|
23
|
+
* carries. The native side rectifies into a rectangle whose corners map
|
|
24
|
+
* TL→(0,0), TR→(w,0), BR→(w,h), BL→(0,h); pass un-ordered points and the
|
|
25
|
+
* output is mirrored / rotated.
|
|
26
|
+
*/
|
|
27
|
+
import type { Quad } from '../camera/cropGeometry';
|
|
28
|
+
/** Options for {@link cropQuad}. */
|
|
29
|
+
export interface CropQuadOptions {
|
|
30
|
+
/**
|
|
31
|
+
* JPEG quality for the re-encoded output, 1–100. Defaults to 90 (the
|
|
32
|
+
* native default, matching `cropToRect`).
|
|
33
|
+
*/
|
|
34
|
+
quality?: number;
|
|
35
|
+
}
|
|
36
|
+
/** Resolved result of a successful {@link cropQuad}. */
|
|
37
|
+
export interface CropQuadResult {
|
|
38
|
+
/**
|
|
39
|
+
* The file the rectified image was written to. Equals the input
|
|
40
|
+
* `imagePath` (the native crop overwrites in place) — surfaced
|
|
41
|
+
* explicitly so callers don't have to assume the in-place contract.
|
|
42
|
+
*/
|
|
43
|
+
outputPath: string;
|
|
44
|
+
/** Width of the rectified rectangle, in pixels. */
|
|
45
|
+
width: number;
|
|
46
|
+
/** Height of the rectified rectangle, in pixels. */
|
|
47
|
+
height: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Flatten the 4 ordered ([TL, TR, BR, BL]) image-pixel corners into the
|
|
51
|
+
* `[tlX, tlY, trX, trY, brX, brY, blX, blY]` array the native module
|
|
52
|
+
* expects. Exported for unit tests + reuse.
|
|
53
|
+
*/
|
|
54
|
+
export declare function flattenQuad(quad: Quad): number[];
|
|
55
|
+
/**
|
|
56
|
+
* Perspective-rectify `quadImagePoints` out of `imagePath` into an upright
|
|
57
|
+
* rectangle, overwriting the file in place, and resolve the output path +
|
|
58
|
+
* rectified dimensions.
|
|
59
|
+
*
|
|
60
|
+
* @param imagePath file:// URI (or bare path) of the image to crop.
|
|
61
|
+
* @param quadImagePoints the 4 corners in IMAGE-PIXEL space, canonically
|
|
62
|
+
* ordered [TL, TR, BR, BL] (use
|
|
63
|
+
* `orderQuadCorners`). This is exactly
|
|
64
|
+
* `RectCropResult.quad`.
|
|
65
|
+
* @param outPath where to write the result. The native crop
|
|
66
|
+
* OVERWRITES IN PLACE, so this currently MUST equal
|
|
67
|
+
* `imagePath` (or be omitted — defaults to it).
|
|
68
|
+
* Passing a different path throws, surfacing the
|
|
69
|
+
* limitation rather than silently ignoring it; see
|
|
70
|
+
* the integrator note in the item-7 handoff.
|
|
71
|
+
* @param opts optional `{ quality }`.
|
|
72
|
+
*
|
|
73
|
+
* @throws if the native module isn't registered, if `outPath` differs from
|
|
74
|
+
* `imagePath`, or if the native crop rejects (degenerate quad,
|
|
75
|
+
* canvas guard, write failure).
|
|
76
|
+
*/
|
|
77
|
+
export declare function cropQuad(imagePath: string, quadImagePoints: Quad, outPath?: string, opts?: CropQuadOptions): Promise<CropQuadResult>;
|
|
78
|
+
//# sourceMappingURL=cropQuad.d.ts.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* cropQuad — item-7 perspective crop: rectify a user-dragged
|
|
5
|
+
* quadrilateral to an upright rectangle.
|
|
6
|
+
*
|
|
7
|
+
* The post-capture crop editor (`src/camera/RectCropPreview.tsx`) lets the
|
|
8
|
+
* user drag 4 independent corners over the stitched result. When that
|
|
9
|
+
* quad isn't ~axis-aligned, the host calls THIS wrapper instead of the
|
|
10
|
+
* cheap `cropToRect`: it hands the 4 IMAGE-PIXEL corners to the native
|
|
11
|
+
* `BatchStitcher.cropToQuad`, which runs
|
|
12
|
+
* `cv::getPerspectiveTransform` + `cv::warpPerspective` to produce an
|
|
13
|
+
* upright rectangle (averaged opposite-edge dimensions) and overwrites the
|
|
14
|
+
* file in place.
|
|
15
|
+
*
|
|
16
|
+
* This is the typed twin of the `cropToRect` call in
|
|
17
|
+
* `example/InscribedRectDebug.tsx` — same native module (`BatchStitcher`),
|
|
18
|
+
* same in-place overwrite + `{ width, height }` result contract, same
|
|
19
|
+
* platform-availability fallback posture as
|
|
20
|
+
* `src/quality/normaliseOrientation.ts`.
|
|
21
|
+
*
|
|
22
|
+
* Corner-order contract: `quadImagePoints` MUST be in canonical
|
|
23
|
+
* [TL, TR, BR, BL] (clockwise from top-left) order — exactly what
|
|
24
|
+
* `cropGeometry.ts:orderQuadCorners` produces and `RectCropResult.quad`
|
|
25
|
+
* carries. The native side rectifies into a rectangle whose corners map
|
|
26
|
+
* TL→(0,0), TR→(w,0), BR→(w,h), BL→(0,h); pass un-ordered points and the
|
|
27
|
+
* output is mirrored / rotated.
|
|
28
|
+
*/
|
|
29
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
exports.flattenQuad = flattenQuad;
|
|
31
|
+
exports.cropQuad = cropQuad;
|
|
32
|
+
const react_native_1 = require("react-native");
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the native `cropToQuad` function off `NativeModules.BatchStitcher`,
|
|
35
|
+
* or `null` when the module / method isn't registered (e.g. an older native
|
|
36
|
+
* build). Same defensive lookup as `normaliseOrientation`.
|
|
37
|
+
*/
|
|
38
|
+
function resolveCropToQuad() {
|
|
39
|
+
const native = react_native_1.NativeModules['BatchStitcher'];
|
|
40
|
+
if (native
|
|
41
|
+
&& typeof native === 'object'
|
|
42
|
+
&& typeof native.cropToQuad === 'function') {
|
|
43
|
+
return native.cropToQuad;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Flatten the 4 ordered ([TL, TR, BR, BL]) image-pixel corners into the
|
|
49
|
+
* `[tlX, tlY, trX, trY, brX, brY, blX, blY]` array the native module
|
|
50
|
+
* expects. Exported for unit tests + reuse.
|
|
51
|
+
*/
|
|
52
|
+
function flattenQuad(quad) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const p of quad) {
|
|
55
|
+
out.push(p.x, p.y);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Perspective-rectify `quadImagePoints` out of `imagePath` into an upright
|
|
61
|
+
* rectangle, overwriting the file in place, and resolve the output path +
|
|
62
|
+
* rectified dimensions.
|
|
63
|
+
*
|
|
64
|
+
* @param imagePath file:// URI (or bare path) of the image to crop.
|
|
65
|
+
* @param quadImagePoints the 4 corners in IMAGE-PIXEL space, canonically
|
|
66
|
+
* ordered [TL, TR, BR, BL] (use
|
|
67
|
+
* `orderQuadCorners`). This is exactly
|
|
68
|
+
* `RectCropResult.quad`.
|
|
69
|
+
* @param outPath where to write the result. The native crop
|
|
70
|
+
* OVERWRITES IN PLACE, so this currently MUST equal
|
|
71
|
+
* `imagePath` (or be omitted — defaults to it).
|
|
72
|
+
* Passing a different path throws, surfacing the
|
|
73
|
+
* limitation rather than silently ignoring it; see
|
|
74
|
+
* the integrator note in the item-7 handoff.
|
|
75
|
+
* @param opts optional `{ quality }`.
|
|
76
|
+
*
|
|
77
|
+
* @throws if the native module isn't registered, if `outPath` differs from
|
|
78
|
+
* `imagePath`, or if the native crop rejects (degenerate quad,
|
|
79
|
+
* canvas guard, write failure).
|
|
80
|
+
*/
|
|
81
|
+
async function cropQuad(imagePath, quadImagePoints, outPath, opts) {
|
|
82
|
+
if (outPath !== undefined && outPath !== imagePath) {
|
|
83
|
+
// The native cropToQuad (like cropToRect) only overwrites in place.
|
|
84
|
+
// Fail loudly rather than silently writing to imagePath and returning
|
|
85
|
+
// a path the file isn't at.
|
|
86
|
+
throw new Error('[capture-sdk] cropQuad: native crop overwrites in place; '
|
|
87
|
+
+ 'outPath must equal imagePath (or be omitted).');
|
|
88
|
+
}
|
|
89
|
+
const fn = resolveCropToQuad();
|
|
90
|
+
if (!fn) {
|
|
91
|
+
throw new Error(`[capture-sdk] cropQuad: native module BatchStitcher.cropToQuad not `
|
|
92
|
+
+ `available on ${react_native_1.Platform.OS}. Ensure the native module is registered.`);
|
|
93
|
+
}
|
|
94
|
+
const quality = clampQuality(opts?.quality);
|
|
95
|
+
const dims = await fn({
|
|
96
|
+
imagePath,
|
|
97
|
+
quad: flattenQuad(quadImagePoints),
|
|
98
|
+
quality,
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
outputPath: imagePath,
|
|
102
|
+
width: dims.width,
|
|
103
|
+
height: dims.height,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Clamp the requested JPEG quality into [1, 100]; default 90. */
|
|
107
|
+
function clampQuality(quality) {
|
|
108
|
+
if (quality === undefined || Number.isNaN(quality))
|
|
109
|
+
return 90;
|
|
110
|
+
if (quality < 1)
|
|
111
|
+
return 1;
|
|
112
|
+
if (quality > 100)
|
|
113
|
+
return 100;
|
|
114
|
+
return Math.round(quality);
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=cropQuad.js.map
|
|
@@ -639,6 +639,33 @@ export interface IncrementalFinalizeResult {
|
|
|
639
639
|
* on the just-completed capture.
|
|
640
640
|
*/
|
|
641
641
|
stitchModeResolved?: 'panorama' | 'scans';
|
|
642
|
+
/**
|
|
643
|
+
* 2026-06-14 (DEV overlay) — a semicolon-separated `key=value` trace of the
|
|
644
|
+
* stitcher's RUNTIME choices for this output, e.g.
|
|
645
|
+
* `"pipe=manual;warp=spherical;route=batch;seam=graphcut;blend=multiband"`.
|
|
646
|
+
* pipe: `manual` (cv::detail) | `highlevel` (cv::Stitcher)
|
|
647
|
+
* warp: `plane` | `cylindrical` | `spherical`
|
|
648
|
+
* route: `batch` (warp-all + seam) | `stream` (low-memory per-frame)
|
|
649
|
+
* seam: `graphcut` | `none`
|
|
650
|
+
* blend: `multiband` | `feather`
|
|
651
|
+
* Intended for a __DEV__-only overlay so the operator can see HOW the
|
|
652
|
+
* panorama was built (which warper, whether the low-memory stream/feather
|
|
653
|
+
* fallback kicked in, etc.). iOS only for now; undefined elsewhere.
|
|
654
|
+
*/
|
|
655
|
+
debugSummary?: string;
|
|
656
|
+
/**
|
|
657
|
+
* 2026-06-15 (iOS) — the exact keyframe JPEG paths used for this stitch.
|
|
658
|
+
* Lets the host re-stitch the SAME frames on demand via `refinePanorama`
|
|
659
|
+
* (e.g. the high-level preview tab) without re-running the capture or
|
|
660
|
+
* enumerating the session directory. iOS only; undefined elsewhere.
|
|
661
|
+
*/
|
|
662
|
+
batchKeyframePaths?: string[];
|
|
663
|
+
/**
|
|
664
|
+
* 2026-06-15 (iOS) — the capture orientation this stitch baked into the
|
|
665
|
+
* output. An on-demand re-stitch (refinePanorama) MUST pass this back or the
|
|
666
|
+
* result comes out in the raw sensor landscape (sideways). iOS only.
|
|
667
|
+
*/
|
|
668
|
+
captureOrientation?: string;
|
|
642
669
|
}
|
|
643
670
|
/**
|
|
644
671
|
* 2026-05-16 — input to `refinePanorama`. Mirrors the subset of
|
|
@@ -701,6 +728,15 @@ export interface IncrementalRefineOptions {
|
|
|
701
728
|
stitchMode?: 'auto' | 'panorama' | 'scans';
|
|
702
729
|
/** JPEG quality 1..100, default 90. */
|
|
703
730
|
jpegQuality?: number;
|
|
731
|
+
/**
|
|
732
|
+
* 2026-06-15 (iOS) — which stitch pipeline to run. `true` = the manual
|
|
733
|
+
* `cv::detail` pipeline (the default batch-capture output); `false` = stock
|
|
734
|
+
* high-level `cv::Stitcher`. Default `false` on the refine path. This is
|
|
735
|
+
* how the on-demand "high-level" preview tab re-stitches the captured
|
|
736
|
+
* keyframes via cv::Stitcher without re-running the whole capture. iOS only
|
|
737
|
+
* (Android refine is always cv::Stitcher).
|
|
738
|
+
*/
|
|
739
|
+
useManualPipeline?: boolean;
|
|
704
740
|
}
|
|
705
741
|
/**
|
|
706
742
|
* 2026-05-16 — result of an explicit `refinePanorama` call. Mirrors
|
|
@@ -720,6 +756,15 @@ export interface IncrementalRefineResult {
|
|
|
720
756
|
framesDropped: number;
|
|
721
757
|
/** The confidence threshold that succeeded. -1 when not applicable. */
|
|
722
758
|
finalConfidenceThresh: number;
|
|
759
|
+
/**
|
|
760
|
+
* 2026-06-15 (DEV overlay A/B-aware) — the stitcher's own semicolon-separated
|
|
761
|
+
* `key=value` runtime recipe for THIS refined output, e.g.
|
|
762
|
+
* `"pipe=highlevel;warp=spherical;route=batch;seam=graphcut;blend=multiband"`.
|
|
763
|
+
* Mirrors `IncrementalFinalizeResult.debugSummary`. Lets the on-demand
|
|
764
|
+
* high-level preview tab show its OWN recipe in the __DEV__ overlay pill
|
|
765
|
+
* instead of the manual primary's recipe. iOS only; undefined elsewhere.
|
|
766
|
+
*/
|
|
767
|
+
debugSummary?: string;
|
|
723
768
|
}
|
|
724
769
|
/**
|
|
725
770
|
* V15.0e — ARKit plane detection state, polled by the capture screen
|
|
@@ -943,7 +943,7 @@ public final class IncrementalStitcher: NSObject {
|
|
|
943
943
|
} else if let v = configOverrides["maxKeyframeIntervalMs"] as? Int {
|
|
944
944
|
self.keyframeGate.maxKeyframeIntervalMs = max(0.0, Double(v))
|
|
945
945
|
} else {
|
|
946
|
-
self.keyframeGate.maxKeyframeIntervalMs =
|
|
946
|
+
self.keyframeGate.maxKeyframeIntervalMs = 1500.0
|
|
947
947
|
}
|
|
948
948
|
// V16 — novelty aggregation percentile. Clamp at start to
|
|
949
949
|
// [0.5, 0.99]; the bridge re-clamps but matching it here
|
|
@@ -1438,7 +1438,10 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1438
1438
|
seamFinderType: payload.batchSeamFinderType,
|
|
1439
1439
|
captureOrientation: payload.captureOrientation,
|
|
1440
1440
|
useInscribedRectCrop: payload.batchEnableInscribedRectCrop,
|
|
1441
|
-
stitchMode: payload.batchStitchModeResolved
|
|
1441
|
+
stitchMode: payload.batchStitchModeResolved,
|
|
1442
|
+
// Batch capture = the default output = MANUAL pipeline
|
|
1443
|
+
// (graphcut + multiband + the full memory-guard set).
|
|
1444
|
+
useManualPipeline: true
|
|
1442
1445
|
)
|
|
1443
1446
|
// V16 fix-attempt 9 (verified on device,
|
|
1444
1447
|
// 2026-05-13) — sentinel-result detection.
|
|
@@ -1501,6 +1504,17 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1501
1504
|
"batchKeyframeSessionDir":
|
|
1502
1505
|
payload.collector?.sessionDir ?? "",
|
|
1503
1506
|
"batchKeyframeCount": payload.paths.count,
|
|
1507
|
+
// 2026-06-15 — the exact keyframe JPEG paths used for
|
|
1508
|
+
// this stitch, so JS can re-stitch them ON DEMAND via
|
|
1509
|
+
// refinePanorama (the high-level tab) without listing
|
|
1510
|
+
// the session dir itself.
|
|
1511
|
+
"batchKeyframePaths": payload.paths,
|
|
1512
|
+
// The orientation this stitch baked into the output.
|
|
1513
|
+
// The on-demand high-level re-stitch MUST pass the
|
|
1514
|
+
// same value or it comes out in the raw sensor
|
|
1515
|
+
// landscape (sideways) — refinePanorama otherwise
|
|
1516
|
+
// defaults to "portrait" (no bake-rotation).
|
|
1517
|
+
"captureOrientation": payload.captureOrientation,
|
|
1504
1518
|
]
|
|
1505
1519
|
if r.framesRequested >= 0 {
|
|
1506
1520
|
batchDict["framesRequested"] = Int(r.framesRequested)
|
|
@@ -1523,6 +1537,12 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1523
1537
|
// helps the operator understand why the
|
|
1524
1538
|
// panorama looks the way it does.
|
|
1525
1539
|
batchDict["stitchModeResolved"] = payload.batchStitchModeResolved
|
|
1540
|
+
// 2026-06-14 (DEV overlay) — the stitcher's runtime
|
|
1541
|
+
// choices (pipeline/warper/route/seam/blend) for this
|
|
1542
|
+
// output, shown on the preview in __DEV__.
|
|
1543
|
+
if !r.debugSummary.isEmpty {
|
|
1544
|
+
batchDict["debugSummary"] = r.debugSummary
|
|
1545
|
+
}
|
|
1526
1546
|
completion(batchDict, nil)
|
|
1527
1547
|
} catch let stitchErr as NSError {
|
|
1528
1548
|
completion(nil, stitchErr)
|
|
@@ -1641,6 +1661,11 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1641
1661
|
// at line 738 of src/stitching/incremental.ts). JS callers
|
|
1642
1662
|
// can override by passing config["stitchMode"].
|
|
1643
1663
|
let refineStitchMode = (config["stitchMode"] as? String) ?? "scans"
|
|
1664
|
+
// 2026-06-15 — pipeline is caller-selectable. The on-demand high-level
|
|
1665
|
+
// tab calls refinePanorama with useManualPipeline:false to re-stitch the
|
|
1666
|
+
// captured keyframes via stock cv::Stitcher. Default false (high-level)
|
|
1667
|
+
// preserves the refine path's historical cv::Stitcher behaviour.
|
|
1668
|
+
let refineManual = (config["useManualPipeline"] as? Bool) ?? false
|
|
1644
1669
|
let quality = max(1, min(100, (config["jpegQuality"] as? Int) ?? 90))
|
|
1645
1670
|
let cleanedOutput = outputPath.hasPrefix("file://")
|
|
1646
1671
|
? String(outputPath.dropFirst(7))
|
|
@@ -1679,7 +1704,8 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1679
1704
|
seamFinderType: seam,
|
|
1680
1705
|
captureOrientation: orientation,
|
|
1681
1706
|
useInscribedRectCrop: useInscribed,
|
|
1682
|
-
stitchMode: refineStitchMode
|
|
1707
|
+
stitchMode: refineStitchMode,
|
|
1708
|
+
useManualPipeline: refineManual
|
|
1683
1709
|
)
|
|
1684
1710
|
// fix-9 sentinel detection — see the finalize() path
|
|
1685
1711
|
// for the full rationale. A 0×0 result means
|
|
@@ -1717,7 +1743,13 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1717
1743
|
frames: frameCount,
|
|
1718
1744
|
errorMessage: nil
|
|
1719
1745
|
)
|
|
1720
|
-
|
|
1746
|
+
// 2026-06-15 (DEV overlay A/B-aware) — carry the stitcher's
|
|
1747
|
+
// own runtime recipe up to JS so the preview's DEV pill shows
|
|
1748
|
+
// the HIGH-LEVEL recipe (pipe=highlevel;warp=spherical;…) while
|
|
1749
|
+
// the user views the high-level tab, instead of the manual
|
|
1750
|
+
// primary's recipe. Mirrors the batch finalize's batchDict
|
|
1751
|
+
// (guard empty — empty string means unavailable).
|
|
1752
|
+
var refineDict: [String: Any] = [
|
|
1721
1753
|
"panoramaPath": r.outputPath,
|
|
1722
1754
|
"width": Int(r.width),
|
|
1723
1755
|
"height": Int(r.height),
|
|
@@ -1725,7 +1757,11 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1725
1757
|
"framesIncluded": frameCount,
|
|
1726
1758
|
"framesDropped": 0,
|
|
1727
1759
|
"finalConfidenceThresh": -1.0,
|
|
1728
|
-
]
|
|
1760
|
+
]
|
|
1761
|
+
if !r.debugSummary.isEmpty {
|
|
1762
|
+
refineDict["debugSummary"] = r.debugSummary
|
|
1763
|
+
}
|
|
1764
|
+
completion(refineDict, nil)
|
|
1729
1765
|
} catch let err as NSError {
|
|
1730
1766
|
self?.emitRefineProgress(
|
|
1731
1767
|
stage: "error",
|
|
@@ -2450,11 +2486,23 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2450
2486
|
let denom = tScore + rScore
|
|
2451
2487
|
if denom <= 1e-9 { return "panorama" } // no motion either way
|
|
2452
2488
|
let ratio = tScore / denom
|
|
2489
|
+
|
|
2490
|
+
// 2026-06-15 — LOW-ROTATION GUARD. The gyro rotation (rRadians) is
|
|
2491
|
+
// trustworthy; the IMU translation (tMeters, in non-AR) is NOT — a
|
|
2492
|
+
// continuous rotation leaks gravity into the double-integrated accel and
|
|
2493
|
+
// inflates it, which can falsely push `ratio` over 0.55 → SCANS, whose
|
|
2494
|
+
// affine warper can't represent the rotation. When the gyro shows a
|
|
2495
|
+
// clear pan (> ~20°) with only modest translation, force PANORAMA
|
|
2496
|
+
// regardless of the (possibly-inflated) translation. Genuine shelf
|
|
2497
|
+
// scans (low rotation, large real translation) skip this and still
|
|
2498
|
+
// reach SCANS via the ratio.
|
|
2499
|
+
let lowRotationGuard = rRadians > 0.35 && tMeters < 0.25
|
|
2500
|
+
let mode = (!lowRotationGuard && ratio >= 0.55) ? "scans" : "panorama"
|
|
2453
2501
|
os_log(.fault, log: Self.diagLog,
|
|
2454
|
-
"[stitchMode.auto] tPose=%.3fm tImu=%.3fm r=%.3frad ratio=%.3f → %{public}@",
|
|
2502
|
+
"[stitchMode.auto] tPose=%.3fm tImu=%.3fm r=%.3frad ratio=%.3f rotGuard=%d → %{public}@",
|
|
2455
2503
|
tPose, imuTranslationMetres, rRadians, ratio,
|
|
2456
|
-
|
|
2457
|
-
return
|
|
2504
|
+
lowRotationGuard ? 1 : 0, mode)
|
|
2505
|
+
return mode
|
|
2458
2506
|
}
|
|
2459
2507
|
|
|
2460
2508
|
/// Closed-form q · (0,0,-1) · q⁻¹ — rotates the camera-forward
|
|
@@ -151,9 +151,9 @@ final class KeyframeGate {
|
|
|
151
151
|
/// overlapThreshold. Unlike `flowMaxTranslationCm` this applies to
|
|
152
152
|
/// BOTH the Pose and Flow strategies, and is passed STRAIGHT
|
|
153
153
|
/// THROUGH to the bridge (the unit is already what C++ expects — no
|
|
154
|
-
/// cm→m style conversion). Default
|
|
154
|
+
/// cm→m style conversion). Default 1500 ms; 0 = disabled. The C++
|
|
155
155
|
/// setter clamps to ≥ 0.
|
|
156
|
-
var maxKeyframeIntervalMs: Double =
|
|
156
|
+
var maxKeyframeIntervalMs: Double = 1500.0 {
|
|
157
157
|
didSet {
|
|
158
158
|
bridge.setMaxKeyframeIntervalMs(maxKeyframeIntervalMs)
|
|
159
159
|
}
|
|
@@ -12,6 +12,16 @@
|
|
|
12
12
|
#include <opencv2/imgcodecs.hpp>
|
|
13
13
|
#pragma pop_macro("NO")
|
|
14
14
|
|
|
15
|
+
// v0.16 — keyframe long-edge clamp (px) applied before the JPEG is written.
|
|
16
|
+
// The stitcher composites at ~1 MP (COMPOSE_MP) and `compose_scale` never
|
|
17
|
+
// upscales, so a keyframe larger than ~1.2 MP only inflates the held-set RAM
|
|
18
|
+
// (N × decoded frame) without sharpening the panorama — the 0.5× ultra-wide
|
|
19
|
+
// otherwise lands ~8 MP/frame here. 1280 px sits just above the compose
|
|
20
|
+
// target, so it reclaims ~6× of that RAM with zero quality loss. (Android's
|
|
21
|
+
// equivalent clamp is 640 px — a tighter low-RAM budget for A35-class
|
|
22
|
+
// devices; iOS can afford the full compose resolution.)
|
|
23
|
+
static const int kKeyframeMaxLongEdge = 1280;
|
|
24
|
+
|
|
15
25
|
// V16 Phase 1.fix2 — write a JPEG with an EXIF Orientation tag so
|
|
16
26
|
// iOS image renderers display the saved frame correctly while
|
|
17
27
|
// cv::imread (with IMREAD_IGNORE_ORIENTATION) gets raw landscape
|
|
@@ -119,17 +129,26 @@ static BOOL WriteJPEGWithEXIF(const cv::Mat &bgr,
|
|
|
119
129
|
|
|
120
130
|
- (nullable instancetype)initWithError:(NSError **)error {
|
|
121
131
|
if ((self = [super init])) {
|
|
122
|
-
|
|
123
|
-
|
|
132
|
+
// DEBUG builds write keyframes under Documents so they are inspectable in
|
|
133
|
+
// the Files app (gated by the example's Info.plist UIFileSharingEnabled +
|
|
134
|
+
// LSSupportsOpeningDocumentsInPlace). RELEASE keeps them in the private,
|
|
135
|
+
// auto-cleaned ApplicationSupport dir. See `cleanup` (retains in DEBUG).
|
|
136
|
+
#if DEBUG
|
|
137
|
+
NSSearchPathDirectory baseDirType = NSDocumentDirectory;
|
|
138
|
+
#else
|
|
139
|
+
NSSearchPathDirectory baseDirType = NSApplicationSupportDirectory;
|
|
140
|
+
#endif
|
|
141
|
+
NSURL *baseDir = [[NSFileManager defaultManager]
|
|
142
|
+
URLForDirectory:baseDirType
|
|
124
143
|
inDomain:NSUserDomainMask
|
|
125
144
|
appropriateForURL:nil
|
|
126
145
|
create:YES
|
|
127
146
|
error:error];
|
|
128
|
-
if (!
|
|
147
|
+
if (!baseDir) return nil;
|
|
129
148
|
NSString *captureUUID = [[NSUUID UUID] UUIDString];
|
|
130
149
|
NSString *sessionPath =
|
|
131
|
-
[[
|
|
132
|
-
|
|
150
|
+
[[baseDir.path stringByAppendingPathComponent:@"Captures"]
|
|
151
|
+
stringByAppendingPathComponent:captureUUID];
|
|
133
152
|
BOOL ok = [[NSFileManager defaultManager]
|
|
134
153
|
createDirectoryAtPath:sessionPath
|
|
135
154
|
withIntermediateDirectories:YES
|
|
@@ -190,6 +209,22 @@ static BOOL WriteJPEGWithEXIF(const cv::Mat &bgr,
|
|
|
190
209
|
rotated = bgr;
|
|
191
210
|
}
|
|
192
211
|
|
|
212
|
+
// Clamp the keyframe's long edge (see kKeyframeMaxLongEdge). Uniform
|
|
213
|
+
// downscale — same factor on both axes — so it preserves aspect ratio AND
|
|
214
|
+
// orientation (no transpose/flip); the rotate above and the EXIF tag below
|
|
215
|
+
// are unaffected, only the pixel count shrinks. INTER_AREA is the correct
|
|
216
|
+
// filter for downsampling.
|
|
217
|
+
{
|
|
218
|
+
const int longEdge =
|
|
219
|
+
rotated.cols > rotated.rows ? rotated.cols : rotated.rows;
|
|
220
|
+
if (longEdge > kKeyframeMaxLongEdge) {
|
|
221
|
+
const double s = (double)kKeyframeMaxLongEdge / (double)longEdge;
|
|
222
|
+
cv::Mat scaled;
|
|
223
|
+
cv::resize(rotated, scaled, cv::Size(), s, s, cv::INTER_AREA);
|
|
224
|
+
rotated = scaled;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
193
228
|
NSInteger idx = self.acceptedCount;
|
|
194
229
|
NSString *filename =
|
|
195
230
|
[NSString stringWithFormat:@"keyframe-%03ld.jpg", (long)idx];
|
|
@@ -230,8 +265,16 @@ static BOOL WriteJPEGWithEXIF(const cv::Mat &bgr,
|
|
|
230
265
|
|
|
231
266
|
- (void)cleanup {
|
|
232
267
|
if (self.sessionDir.length == 0) return;
|
|
268
|
+
#if DEBUG
|
|
269
|
+
// DEBUG: keep the session's keyframes on disk so they can be inspected in
|
|
270
|
+
// the Files app (Documents/Captures/<uuid>/keyframe-NNN.jpg). Each capture
|
|
271
|
+
// is a fresh UUID folder; delete old ones via Files when done.
|
|
272
|
+
NSLog(@"[KeyframeCollector] DEBUG — retaining keyframes for inspection: %@",
|
|
273
|
+
self.sessionDir);
|
|
274
|
+
#else
|
|
233
275
|
[[NSFileManager defaultManager] removeItemAtPath:self.sessionDir
|
|
234
276
|
error:nil];
|
|
277
|
+
#endif
|
|
235
278
|
}
|
|
236
279
|
|
|
237
280
|
// ── CVPixelBuffer → cv::Mat (BGR) ──────────────────────────────────
|
|
@@ -44,6 +44,10 @@ extern NSString *const RNImageStitcherErrorDomain;
|
|
|
44
44
|
@property (nonatomic, assign, readonly) NSInteger framesRequested;
|
|
45
45
|
@property (nonatomic, assign, readonly) NSInteger framesIncluded;
|
|
46
46
|
@property (nonatomic, assign, readonly) double finalConfidenceThresh;
|
|
47
|
+
/// 2026-06-14 (DEV overlay) — semicolon-separated `key=value` trace of the
|
|
48
|
+
/// stitcher's runtime choices for this output (pipeline/warper/route/seam/
|
|
49
|
+
/// blend), surfaced on the preview in __DEV__. Empty string when unavailable.
|
|
50
|
+
@property (nonatomic, copy, readonly) NSString *debugSummary;
|
|
47
51
|
- (instancetype)initWithOutputPath:(NSString *)outputPath
|
|
48
52
|
width:(NSInteger)width
|
|
49
53
|
height:(NSInteger)height
|
|
@@ -108,6 +112,10 @@ extern NSString *const RNImageStitcherErrorDomain;
|
|
|
108
112
|
/// them. With `useInscribedRectCrop:YES` we find the largest
|
|
109
113
|
/// axis-aligned rectangle entirely inside the non-zero region
|
|
110
114
|
/// and crop to that — clean output with no black corners.
|
|
115
|
+
/// `useManualPipeline`: YES → the manual cv::detail pipeline (graphcut +
|
|
116
|
+
/// multiband, with the full memory-guard machinery); NO → stock high-level
|
|
117
|
+
/// cv::Stitcher. The batch capture passes YES (the default output); the
|
|
118
|
+
/// on-demand high-level tab re-stitches the same keyframes with NO.
|
|
111
119
|
+ (nullable RNStitchResult *)stitchFramePaths:(NSArray<NSString *> *)framePaths
|
|
112
120
|
outputPath:(NSString *)outputPath
|
|
113
121
|
jpegQuality:(NSInteger)quality
|
|
@@ -117,6 +125,7 @@ extern NSString *const RNImageStitcherErrorDomain;
|
|
|
117
125
|
captureOrientation:(nullable NSString *)captureOrientation
|
|
118
126
|
useInscribedRectCrop:(BOOL)useInscribedRectCrop
|
|
119
127
|
stitchMode:(nullable NSString *)stitchMode
|
|
128
|
+
useManualPipeline:(BOOL)useManualPipeline
|
|
120
129
|
error:(NSError **)error;
|
|
121
130
|
|
|
122
131
|
/// Extract `maxFrames` evenly-spaced frames from the video at
|
|
@@ -194,6 +203,24 @@ extern NSString *const RNImageStitcherErrorDomain;
|
|
|
194
203
|
quality:(NSInteger)quality
|
|
195
204
|
error:(NSError **)error;
|
|
196
205
|
|
|
206
|
+
/// item-7 — free-quad perspective crop. Takes 4 IMAGE-PIXEL corners
|
|
207
|
+
/// (ordered TL, TR, BR, BL) and rectifies the enclosed quadrilateral to
|
|
208
|
+
/// an upright rectangle (cv::getPerspectiveTransform + warpPerspective),
|
|
209
|
+
/// re-encodes at `quality`, overwrites in place. Returns the rectified
|
|
210
|
+
/// `{ width, height }`. Rejects a degenerate / non-convex / out-of-bounds
|
|
211
|
+
/// quad, and guards the output canvas with the shared canvasExceedsGuard.
|
|
212
|
+
+ (nullable NSDictionary<NSString *, NSNumber *> *)cropToQuadAtPath:(NSString *)imagePath
|
|
213
|
+
tlX:(double)tlX
|
|
214
|
+
tlY:(double)tlY
|
|
215
|
+
trX:(double)trX
|
|
216
|
+
trY:(double)trY
|
|
217
|
+
brX:(double)brX
|
|
218
|
+
brY:(double)brY
|
|
219
|
+
blX:(double)blX
|
|
220
|
+
blY:(double)blY
|
|
221
|
+
quality:(NSInteger)quality
|
|
222
|
+
error:(NSError **)error;
|
|
223
|
+
|
|
197
224
|
/// v0.15 debug — write a red-tinted overlay JPEG (excluded / sub-threshold
|
|
198
225
|
/// pixels rendered red) next to `imagePath` (suffix ".mask.jpg") so the
|
|
199
226
|
/// harness can show WHY the inscribed rect lands where it does. Returns
|