react-native-image-stitcher 0.16.0 → 0.16.2
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 +80 -0
- package/README.md +41 -44
- package/android/build.gradle +34 -0
- package/android/src/main/cpp/image_stitcher_jni.cpp +52 -7
- package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +135 -59
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
- package/cpp/keyframe_gate.cpp +54 -15
- package/cpp/keyframe_gate.hpp +33 -0
- package/cpp/stitcher.cpp +481 -87
- package/cpp/stitcher.hpp +52 -0
- package/dist/camera/Camera.d.ts +13 -0
- package/dist/camera/Camera.js +9 -64
- package/dist/camera/CaptureFrameCounterOverlay.js +12 -1
- package/dist/camera/CaptureMemoryPill.d.ts +15 -7
- package/dist/camera/CaptureMemoryPill.js +34 -9
- package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
- package/dist/camera/PanoramaBandOverlay.js +9 -3
- package/dist/camera/PanoramaSettings.js +22 -25
- package/dist/camera/RectCropPreview.d.ts +3 -29
- package/dist/camera/RectCropPreview.js +20 -130
- package/dist/stitching/incremental.d.ts +29 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
- package/dist/stitching/useIncrementalStitcher.js +7 -1
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +103 -26
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +21 -1
- package/package.json +1 -1
- package/src/camera/Camera.tsx +21 -70
- package/src/camera/CaptureFrameCounterOverlay.tsx +15 -1
- package/src/camera/CaptureMemoryPill.tsx +33 -9
- package/src/camera/PanoramaBandOverlay.tsx +9 -1
- package/src/camera/PanoramaSettings.ts +22 -25
- package/src/camera/RectCropPreview.tsx +38 -220
- package/src/stitching/incremental.ts +29 -0
- package/src/stitching/useIncrementalStitcher.ts +13 -0
- package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
|
@@ -109,56 +109,8 @@ function seedImageQuad(imageWidth, imageHeight, initialRect) {
|
|
|
109
109
|
];
|
|
110
110
|
}
|
|
111
111
|
function RectCropPreview(props) {
|
|
112
|
-
const { imageUri, imageWidth, imageHeight,
|
|
112
|
+
const { imageUri, imageWidth, imageHeight, visible, onConfirm, onUseOriginal, onRetake, warnings, showCropControls = true, initialRect, copy, topInset = 0, bottomInset = 0, debugInfo, showMemoryPill, } = props;
|
|
113
113
|
const resolvedCopy = (0, react_1.useMemo)(() => (0, cameraGuidanceCopy_1.mergeGuidanceCopy)(copy), [copy]);
|
|
114
|
-
// ── A/B comparison — the PRIMARY (imageUri) is the MANUAL pipeline (the
|
|
115
|
-
// default output). The alt is HIGH-LEVEL cv::Stitcher, produced either
|
|
116
|
-
// EAGERLY (`altImageUri`, legacy) or ON DEMAND (`onRequestAlt`, re-stitched
|
|
117
|
-
// the first time the user opens the high-level tab). `altSize` is fetched
|
|
118
|
-
// once the alt uri exists; when the alt is showing we use its dims for the
|
|
119
|
-
// contain-fit and hide the crop quad.
|
|
120
|
-
const [showingAlt, setShowingAlt] = (0, react_1.useState)(false);
|
|
121
|
-
const [lazyAltUri, setLazyAltUri] = (0, react_1.useState)(null);
|
|
122
|
-
// The high-level (alt) stitch's OWN DEV-overlay recipe, resolved alongside
|
|
123
|
-
// its uri from `onRequestAlt`. Shown in the params pill in place of the
|
|
124
|
-
// manual primary's `debugInfo` while the high-level tab is being viewed.
|
|
125
|
-
const [lazyAltDebugInfo, setLazyAltDebugInfo] = (0, react_1.useState)(null);
|
|
126
|
-
const [altLoading, setAltLoading] = (0, react_1.useState)(false);
|
|
127
|
-
const [altFailed, setAltFailed] = (0, react_1.useState)(false);
|
|
128
|
-
const altUri = altImageUri ?? lazyAltUri ?? null;
|
|
129
|
-
const altOffered = !!altImageUri || !!onRequestAlt;
|
|
130
|
-
const [altSize, setAltSize] = (0, react_1.useState)(null);
|
|
131
|
-
react_1.default.useEffect(() => {
|
|
132
|
-
if (!altUri) {
|
|
133
|
-
setAltSize(null);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
react_native_1.Image.getSize(altUri, (w, h) => setAltSize({ w, h }), () => setAltSize(null));
|
|
137
|
-
}, [altUri]);
|
|
138
|
-
// Switch to the high-level (alt) view; compute it lazily on first request.
|
|
139
|
-
const showHighLevel = react_1.default.useCallback(() => {
|
|
140
|
-
setShowingAlt(true);
|
|
141
|
-
if (altUri || altLoading || !onRequestAlt)
|
|
142
|
-
return;
|
|
143
|
-
setAltFailed(false);
|
|
144
|
-
setAltLoading(true);
|
|
145
|
-
onRequestAlt()
|
|
146
|
-
.then((result) => {
|
|
147
|
-
if (result) {
|
|
148
|
-
setLazyAltUri(result.uri);
|
|
149
|
-
setLazyAltDebugInfo(result.debugInfo);
|
|
150
|
-
}
|
|
151
|
-
else {
|
|
152
|
-
setAltFailed(true);
|
|
153
|
-
}
|
|
154
|
-
})
|
|
155
|
-
.catch(() => setAltFailed(true))
|
|
156
|
-
.finally(() => setAltLoading(false));
|
|
157
|
-
}, [altUri, altLoading, onRequestAlt]);
|
|
158
|
-
const showAlt = showingAlt && !!altUri && !!altSize;
|
|
159
|
-
const activeUri = showAlt ? altUri : imageUri;
|
|
160
|
-
const activeW = showAlt ? altSize.w : imageWidth;
|
|
161
|
-
const activeH = showAlt ? altSize.h : imageHeight;
|
|
162
114
|
// The 4 corners live in IMAGE-PIXEL space (the source of truth) so they
|
|
163
115
|
// survive layout-box changes (rotation, keyboard) without drift. We map
|
|
164
116
|
// to screen for rendering and back on every drag via cropGeometry.
|
|
@@ -244,19 +196,18 @@ function RectCropPreview(props) {
|
|
|
244
196
|
let imageBox = null;
|
|
245
197
|
let screenCorners = null;
|
|
246
198
|
if (box) {
|
|
247
|
-
const fit = (0, cropGeometry_1.containFit)(box,
|
|
199
|
+
const fit = (0, cropGeometry_1.containFit)(box, imageWidth, imageHeight);
|
|
248
200
|
if (fit) {
|
|
249
201
|
imageBox = {
|
|
250
202
|
left: fit.offX,
|
|
251
203
|
top: fit.offY,
|
|
252
|
-
width:
|
|
253
|
-
height:
|
|
204
|
+
width: imageWidth * fit.scale,
|
|
205
|
+
height: imageHeight * fit.scale,
|
|
254
206
|
};
|
|
255
|
-
// Quad corners only apply
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
: imageQuad.map((p) => (0, cropGeometry_1.imageToScreen)(p, box, imageWidth, imageHeight));
|
|
207
|
+
// Quad corners only apply in crop mode.
|
|
208
|
+
screenCorners = showCropControls
|
|
209
|
+
? imageQuad.map((p) => (0, cropGeometry_1.imageToScreen)(p, box, imageWidth, imageHeight))
|
|
210
|
+
: null;
|
|
260
211
|
}
|
|
261
212
|
}
|
|
262
213
|
// Outline path (a <View> per edge — RN core has no <Polygon>; this
|
|
@@ -282,32 +233,16 @@ function RectCropPreview(props) {
|
|
|
282
233
|
react_1.default.createElement(react_native_1.View, { style: [styles.root, { paddingTop: topInset }] },
|
|
283
234
|
showMemoryPill ? (react_1.default.createElement(CaptureMemoryPill_1.CaptureMemoryPill, { style: {
|
|
284
235
|
position: 'absolute',
|
|
285
|
-
top: topInset +
|
|
236
|
+
top: topInset + 8,
|
|
286
237
|
left: 12,
|
|
287
238
|
zIndex: 21,
|
|
288
239
|
} })) : null,
|
|
289
|
-
((
|
|
290
|
-
|
|
291
|
-
return pillText ? (react_1.default.createElement(react_native_1.View, { style: [
|
|
292
|
-
styles.debugPill,
|
|
293
|
-
{ top: topInset + (altImageUri && altSize ? 76 : 8) },
|
|
294
|
-
], pointerEvents: "none", accessibilityRole: "text" },
|
|
295
|
-
react_1.default.createElement(react_native_1.Text, { style: styles.debugPillText }, pillText))) : null;
|
|
296
|
-
})(),
|
|
240
|
+
debugInfo ? (react_1.default.createElement(react_native_1.View, { style: [styles.debugPill, { top: topInset + 8 }], pointerEvents: "none", accessibilityRole: "text" },
|
|
241
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.debugPillText }, debugInfo))) : null,
|
|
297
242
|
warnings && warnings.length > 0 && (react_1.default.createElement(react_native_1.View, { style: styles.warningBanner, accessibilityRole: "alert" }, warnings.map((w, i) => (react_1.default.createElement(react_native_1.Text, { key: `warn-${i}`, style: styles.warningText }, w))))),
|
|
298
|
-
altOffered && (react_1.default.createElement(react_native_1.View, { style: styles.abBar },
|
|
299
|
-
react_1.default.createElement(react_native_1.Text, { style: styles.abBarLabel }, altLoading
|
|
300
|
-
? 'Stitching high-level… (manual shown meanwhile)'
|
|
301
|
-
: altFailed
|
|
302
|
-
? 'High-level stitch failed — showing manual'
|
|
303
|
-
: 'Viewing the highlighted pipeline — tap to switch:'),
|
|
304
|
-
react_1.default.createElement(react_native_1.View, { style: styles.abSegments },
|
|
305
|
-
react_1.default.createElement(react_native_1.Pressable, { style: [styles.abSeg, !showAlt && styles.abSegActive], onPress: () => setShowingAlt(false), accessibilityRole: "button", accessibilityState: { selected: !showAlt }, accessibilityLabel: "View manual pipeline (default)" },
|
|
306
|
-
react_1.default.createElement(react_native_1.Text, { style: [styles.abSegText, !showAlt && styles.abSegTextActive] }, "Manual")),
|
|
307
|
-
react_1.default.createElement(react_native_1.Pressable, { style: [styles.abSeg, showAlt && styles.abSegActive], onPress: showHighLevel, accessibilityRole: "button", accessibilityState: { selected: showAlt, busy: altLoading }, accessibilityLabel: "View high-level pipeline (computed on demand)" }, altLoading ? (react_1.default.createElement(react_native_1.ActivityIndicator, { size: "small", color: "#fff" })) : (react_1.default.createElement(react_native_1.Text, { style: [styles.abSegText, showAlt && styles.abSegTextActive] }, "High-level")))))),
|
|
308
243
|
react_1.default.createElement(react_native_1.View, { style: styles.canvas, onLayout: onLayout },
|
|
309
|
-
imageBox && (react_1.default.createElement(react_native_1.Image, { source: { uri:
|
|
310
|
-
showCropControls &&
|
|
244
|
+
imageBox && (react_1.default.createElement(react_native_1.Image, { source: { uri: imageUri }, style: [styles.image, imageBox], resizeMode: "stretch" })),
|
|
245
|
+
showCropControls && (react_1.default.createElement(react_1.default.Fragment, null,
|
|
311
246
|
edges.map((e, i) => (react_1.default.createElement(react_native_1.View, { key: `edge-${i}`, style: [styles.edge, e], pointerEvents: "none" }))),
|
|
312
247
|
screenCorners
|
|
313
248
|
&& screenCorners.map((c, i) => (react_1.default.createElement(react_native_1.View, { key: `handle-${i}`, ...responders[i].panHandlers, hitSlop: {
|
|
@@ -327,26 +262,18 @@ function RectCropPreview(props) {
|
|
|
327
262
|
react_1.default.createElement(react_native_1.View, { style: styles.buttons },
|
|
328
263
|
react_1.default.createElement(react_native_1.Pressable, { style: ({ pressed }) => [styles.btn, pressed && styles.btnPressed], onPress: onRetake, accessibilityRole: "button", accessibilityLabel: resolvedCopy.cropRetake },
|
|
329
264
|
react_1.default.createElement(react_native_1.Text, { style: styles.btnText }, resolvedCopy.cropRetake)),
|
|
330
|
-
showCropControls &&
|
|
265
|
+
showCropControls && (react_1.default.createElement(react_native_1.Pressable, { style: ({ pressed }) => [styles.btn, pressed && styles.btnPressed], onPress: () => onUseOriginal(), accessibilityRole: "button", accessibilityLabel: resolvedCopy.cropUseOriginal },
|
|
331
266
|
react_1.default.createElement(react_native_1.Text, { style: styles.btnText }, resolvedCopy.cropUseOriginal))),
|
|
332
267
|
react_1.default.createElement(react_native_1.Pressable, { style: ({ pressed }) => [
|
|
333
268
|
styles.btn,
|
|
334
269
|
styles.primary,
|
|
335
270
|
pressed && styles.btnPressed,
|
|
336
|
-
], onPress:
|
|
337
|
-
?
|
|
338
|
-
:
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
: showCropControls
|
|
343
|
-
? resolvedCopy.cropConfirm
|
|
344
|
-
: resolvedCopy.previewConfirm },
|
|
345
|
-
react_1.default.createElement(react_native_1.Text, { style: styles.btnText }, showAlt
|
|
346
|
-
? 'Use this'
|
|
347
|
-
: showCropControls
|
|
348
|
-
? resolvedCopy.cropConfirm
|
|
349
|
-
: resolvedCopy.previewConfirm)))))));
|
|
271
|
+
], onPress: showCropControls ? handleConfirm : () => onUseOriginal(), accessibilityRole: "button", accessibilityLabel: showCropControls
|
|
272
|
+
? resolvedCopy.cropConfirm
|
|
273
|
+
: resolvedCopy.previewConfirm },
|
|
274
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.btnText }, showCropControls
|
|
275
|
+
? resolvedCopy.cropConfirm
|
|
276
|
+
: resolvedCopy.previewConfirm)))))));
|
|
350
277
|
}
|
|
351
278
|
/**
|
|
352
279
|
* Absolute-positioned style for a 1-px-thick edge line between two
|
|
@@ -404,43 +331,6 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
404
331
|
fontSize: 13,
|
|
405
332
|
fontWeight: '600',
|
|
406
333
|
},
|
|
407
|
-
abBar: {
|
|
408
|
-
backgroundColor: '#1a1a1a',
|
|
409
|
-
paddingVertical: 10,
|
|
410
|
-
paddingHorizontal: 12,
|
|
411
|
-
borderBottomWidth: react_native_1.StyleSheet.hairlineWidth,
|
|
412
|
-
borderBottomColor: '#333',
|
|
413
|
-
},
|
|
414
|
-
abBarLabel: {
|
|
415
|
-
color: '#aaa',
|
|
416
|
-
fontSize: 11,
|
|
417
|
-
fontWeight: '600',
|
|
418
|
-
textAlign: 'center',
|
|
419
|
-
marginBottom: 8,
|
|
420
|
-
},
|
|
421
|
-
abSegments: {
|
|
422
|
-
flexDirection: 'row',
|
|
423
|
-
alignSelf: 'center',
|
|
424
|
-
backgroundColor: '#000',
|
|
425
|
-
borderRadius: 9,
|
|
426
|
-
padding: 3,
|
|
427
|
-
},
|
|
428
|
-
abSeg: {
|
|
429
|
-
paddingVertical: 7,
|
|
430
|
-
paddingHorizontal: 22,
|
|
431
|
-
borderRadius: 7,
|
|
432
|
-
},
|
|
433
|
-
abSegActive: {
|
|
434
|
-
backgroundColor: '#0A84FF',
|
|
435
|
-
},
|
|
436
|
-
abSegText: {
|
|
437
|
-
color: '#9aa',
|
|
438
|
-
fontSize: 14,
|
|
439
|
-
fontWeight: '700',
|
|
440
|
-
},
|
|
441
|
-
abSegTextActive: {
|
|
442
|
-
color: '#fff',
|
|
443
|
-
},
|
|
444
334
|
canvas: { flex: 1 },
|
|
445
335
|
image: { position: 'absolute' },
|
|
446
336
|
edge: {
|
|
@@ -639,6 +639,23 @@ export interface IncrementalFinalizeResult {
|
|
|
639
639
|
* on the just-completed capture.
|
|
640
640
|
*/
|
|
641
641
|
stitchModeResolved?: 'panorama' | 'scans';
|
|
642
|
+
/**
|
|
643
|
+
* 2026-06-15 (DEV) — gyro rotation magnitude of the capture, in RADIANS
|
|
644
|
+
* (angle between the first and last accepted keyframe camera-forward vectors).
|
|
645
|
+
* Surfaced so a dev tool can display it and tune the panorama-vs-SCANS
|
|
646
|
+
* rotation threshold from real captures. `0` when there is no pose-derived
|
|
647
|
+
* rotation signal (non-AR with no poses) — not necessarily "no rotation".
|
|
648
|
+
*/
|
|
649
|
+
rRadians?: number;
|
|
650
|
+
/**
|
|
651
|
+
* 2026-06-16 (DEV) — translation magnitude (metres) and the auto decision
|
|
652
|
+
* ratio (`tScore/(tScore+rScore)`, `>=0.55` → SCANS) that drove the
|
|
653
|
+
* panorama-vs-SCANS choice. Surfaced alongside `rRadians` so a dev tool can
|
|
654
|
+
* display the full decision inputs and tune the threshold from real captures.
|
|
655
|
+
* `0` when there is no motion signal (non-AR with no poses / no movement).
|
|
656
|
+
*/
|
|
657
|
+
tMeters?: number;
|
|
658
|
+
decisionRatio?: number;
|
|
642
659
|
/**
|
|
643
660
|
* 2026-06-14 (DEV overlay) — a semicolon-separated `key=value` trace of the
|
|
644
661
|
* stitcher's RUNTIME choices for this output, e.g.
|
|
@@ -844,6 +861,14 @@ interface NativeIncrementalModule {
|
|
|
844
861
|
* are zero, matching legacy behaviour.
|
|
845
862
|
*/
|
|
846
863
|
imuTranslationMetres?: number;
|
|
864
|
+
/**
|
|
865
|
+
* 2026-06-16 — the explicit lens the user selected (`'1x'` | `'0.5x'`).
|
|
866
|
+
* The reliable zoom signal for the high-level warper tree: `'0.5x'`
|
|
867
|
+
* (ultra-wide) → spherical warper. Replaces deriving zoom from the
|
|
868
|
+
* intrinsics FOV (unreliable on multi-cam 0.5x / non-AR fx=0). Omitted →
|
|
869
|
+
* treated as `'1x'`.
|
|
870
|
+
*/
|
|
871
|
+
lens?: string;
|
|
847
872
|
}): Promise<IncrementalFinalizeResult>;
|
|
848
873
|
cancel(): Promise<{
|
|
849
874
|
ok: true;
|
|
@@ -875,6 +900,10 @@ interface NativeIncrementalModule {
|
|
|
875
900
|
* one-true-number for "how close are we to OOM?". Returns -1
|
|
876
901
|
* on task_info failure (very rare). Resolves immediately. */
|
|
877
902
|
getMemoryFootprintMB(): Promise<number>;
|
|
903
|
+
/** 2026-06-16 — total physical RAM in MB. Lets the DEV memory pill derive
|
|
904
|
+
* RAM-aware pressure bands instead of iPhone-fixed thresholds. -1 on
|
|
905
|
+
* failure. Resolves immediately. */
|
|
906
|
+
getDeviceTotalRamMB?(): Promise<number>;
|
|
878
907
|
/**
|
|
879
908
|
* 2026-05-16 — realtime+batch fusion API foundation. Run the
|
|
880
909
|
* shared C++ `cv::Stitcher` pipeline over a caller-supplied list
|
|
@@ -61,7 +61,13 @@ export interface UseIncrementalStitcherReturn {
|
|
|
61
61
|
* (e.g. in AR mode the native side has its own pose-driven
|
|
62
62
|
* translation magnitude and prefers that).
|
|
63
63
|
*/
|
|
64
|
-
imuTranslationMetres?: number
|
|
64
|
+
imuTranslationMetres?: number,
|
|
65
|
+
/**
|
|
66
|
+
* 2026-06-16 — the EXPLICIT lens the user selected (`'1x'` | `'0.5x'`).
|
|
67
|
+
* The reliable zoom signal for the high-level warper tree (`'0.5x'`
|
|
68
|
+
* ultra-wide → spherical). Omit ⇒ treated as `'1x'`.
|
|
69
|
+
*/
|
|
70
|
+
lens?: string) => Promise<IncrementalFinalizeResult>;
|
|
65
71
|
/** Abort the capture without producing output. */
|
|
66
72
|
cancel: () => Promise<void>;
|
|
67
73
|
}
|
|
@@ -110,7 +110,7 @@ function useIncrementalStitcher() {
|
|
|
110
110
|
setState(null);
|
|
111
111
|
lastHintRef.current = null;
|
|
112
112
|
}, [native]);
|
|
113
|
-
const finalize = (0, react_1.useCallback)(async (outputPath, quality = 90, captureOrientation, imuTranslationMetres) => {
|
|
113
|
+
const finalize = (0, react_1.useCallback)(async (outputPath, quality = 90, captureOrientation, imuTranslationMetres, lens) => {
|
|
114
114
|
if (!native) {
|
|
115
115
|
throw new Error('useIncrementalStitcher: native module unavailable');
|
|
116
116
|
}
|
|
@@ -128,6 +128,12 @@ function useIncrementalStitcher() {
|
|
|
128
128
|
// doesn't carry tx/ty/tz, so pose-derived translation is 0).
|
|
129
129
|
// Native side treats it as a magnitude (always ≥ 0).
|
|
130
130
|
imuTranslationMetres: Math.max(0, imuTranslationMetres ?? 0),
|
|
131
|
+
// 2026-06-16 — the EXPLICIT lens the user selected ('1x' | '0.5x').
|
|
132
|
+
// This is the reliable zoom signal for the high-level warper tree
|
|
133
|
+
// (0.5x ultra-wide → spherical); deriving zoom from intrinsics FOV was
|
|
134
|
+
// unreliable (multi-cam 0.5x reaches the ultra-wide by zoom without
|
|
135
|
+
// changing the reported fx, and the non-AR path may supply fx=0).
|
|
136
|
+
lens,
|
|
131
137
|
});
|
|
132
138
|
setIsRunning(false);
|
|
133
139
|
// Clear React state on finalize so the next start doesn't
|
|
@@ -223,6 +223,15 @@ struct FinalizePayload {
|
|
|
223
223
|
/// resolved upstream by `resolveStitchModeAuto` before this snapshot
|
|
224
224
|
/// is captured; this field never carries 'auto'.
|
|
225
225
|
let batchStitchModeResolved: String
|
|
226
|
+
/// Gyro rotation magnitude (radians) of the capture — surfaced to JS for the
|
|
227
|
+
/// dev 3-tab preview's rRadians readout (threshold tuning). 0.0 when there
|
|
228
|
+
/// is no pose-derived rotation signal (non-AR with no poses).
|
|
229
|
+
let rRadians: Double
|
|
230
|
+
/// Translation magnitude (metres) + the auto decision ratio
|
|
231
|
+
/// (tScore/(tScore+rScore), >=0.55 → SCANS) that drove the panorama-vs-SCANS
|
|
232
|
+
/// choice — surfaced to JS for the dev tuning readout alongside rRadians.
|
|
233
|
+
let tMeters: Double
|
|
234
|
+
let decisionRatio: Double
|
|
226
235
|
let keyframeExifOrientation: Int
|
|
227
236
|
/// AR-STITCHING-TWO-MODES (memory/ar-stitching-two-modes.md):
|
|
228
237
|
/// capture-time hold orientation for the bake-rotation pass.
|
|
@@ -430,6 +439,10 @@ public final class IncrementalStitcher: NSObject {
|
|
|
430
439
|
/// AR mode (where pose-derived tx/ty/tz is always 0). Set to 0
|
|
431
440
|
/// at start() and overwritten at finalize() entry.
|
|
432
441
|
private var batchImuTranslationMetres: Double = 0.0
|
|
442
|
+
/// 2026-06-16 — the explicit lens the user selected ('1x' | '0.5x'), set at
|
|
443
|
+
/// finalize() entry from JS. The zoom signal for the high-level warper tree
|
|
444
|
+
/// (0.5x ultra-wide → spherical). Defaults to '1x'.
|
|
445
|
+
private var batchLens: String = "1x"
|
|
433
446
|
/// AR-STITCHING-TWO-MODES — see memory/ar-stitching-two-modes.md
|
|
434
447
|
///
|
|
435
448
|
/// Physical phone orientation at start() time, sourced from the
|
|
@@ -492,6 +505,14 @@ public final class IncrementalStitcher: NSObject {
|
|
|
492
505
|
stateLock.unlock()
|
|
493
506
|
}
|
|
494
507
|
|
|
508
|
+
/// 2026-06-16 — store the explicit lens ('1x' | '0.5x') JS supplies at
|
|
509
|
+
/// finalize() entry; the high-level warper tree reads it (0.5x → spherical).
|
|
510
|
+
@objc public func updateLens(_ lens: String) {
|
|
511
|
+
stateLock.lock()
|
|
512
|
+
self.batchLens = lens
|
|
513
|
+
stateLock.unlock()
|
|
514
|
+
}
|
|
515
|
+
|
|
495
516
|
/// 2026-05-18 (Iss 3) — return the current capture's keyframe
|
|
496
517
|
/// session directory, or nil if no capture is in flight / engine
|
|
497
518
|
/// isn't using a per-session keyframe collector.
|
|
@@ -829,6 +850,7 @@ public final class IncrementalStitcher: NSObject {
|
|
|
829
850
|
// too. Updated at finalize() entry from JS-supplied
|
|
830
851
|
// option value.
|
|
831
852
|
self.batchImuTranslationMetres = 0.0
|
|
853
|
+
self.batchLens = "1x" // overwritten at finalize() from JS (updateLens)
|
|
832
854
|
self.batchKeyframeMode = true
|
|
833
855
|
os_log(.fault, log: Self.diagLog,
|
|
834
856
|
"[V16-batch-keyframe] start mode=batch-keyframe rotation=0 (was %d, forced to 0 to match pose intrinsics) sessionDir=%{public}@",
|
|
@@ -1147,20 +1169,33 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1147
1169
|
// translation/rotation magnitude ratio between first + last
|
|
1148
1170
|
// accepted keyframe poses → SCANS (translation-heavy) or
|
|
1149
1171
|
// PANORAMA (rotation-heavy). Non-auto values pass through.
|
|
1172
|
+
// Resolve once so the dev readout gets the SAME tMeters / ratio / rRadians
|
|
1173
|
+
// that drove the decision — and gets them even when the mode is forced
|
|
1174
|
+
// (informative: shows what auto WOULD have picked). Captured into the
|
|
1175
|
+
// payload here so the C2-invariant finalize closure can read them via
|
|
1176
|
+
// payload (no self/ivar access inside the closure).
|
|
1177
|
+
let autoResolution = resolveStitchModeAuto(
|
|
1178
|
+
first: batchFirstAcceptedPose,
|
|
1179
|
+
last: batchLastAcceptedPose,
|
|
1180
|
+
imuTranslationMetres: batchImuTranslationMetres)
|
|
1150
1181
|
let stitchModeResolved: String
|
|
1151
1182
|
switch batchStitchMode {
|
|
1152
1183
|
case "panorama": stitchModeResolved = "panorama"
|
|
1153
1184
|
case "scans": stitchModeResolved = "scans"
|
|
1154
|
-
default: stitchModeResolved =
|
|
1155
|
-
first: batchFirstAcceptedPose,
|
|
1156
|
-
last: batchLastAcceptedPose,
|
|
1157
|
-
imuTranslationMetres: batchImuTranslationMetres
|
|
1158
|
-
)
|
|
1185
|
+
default: stitchModeResolved = autoResolution.mode
|
|
1159
1186
|
}
|
|
1187
|
+
let rRadiansResolved = autoResolution.rRadians
|
|
1188
|
+
// 2026-06-16 — HIGH-LEVEL ACROSS THE BOARD (mirrors Android). Pick the
|
|
1189
|
+
// warper from the (motion, Mode A/B, lens) tree; the dispatch below now
|
|
1190
|
+
// forces useManualPipeline=false + stitchMode="panorama". batchWarperType
|
|
1191
|
+
// (settings) is superseded by the tree.
|
|
1192
|
+
let highLevelWarper = pickHighLevelWarper(
|
|
1193
|
+
orientation: captureOrientation,
|
|
1194
|
+
lens: batchLens)
|
|
1160
1195
|
os_log(.fault, log: Self.diagLog,
|
|
1161
|
-
"[V16-batch-keyframe.stitchMode] configured=%{public}@ resolved=%{public}@ paths=%d imuT=%.3fm",
|
|
1162
|
-
batchStitchMode, stitchModeResolved,
|
|
1163
|
-
batchImuTranslationMetres)
|
|
1196
|
+
"[V16-batch-keyframe.stitchMode] configured=%{public}@ resolved=%{public}@ warper=%{public}@ lens=%{public}@ paths=%d imuT=%.3fm",
|
|
1197
|
+
batchStitchMode, stitchModeResolved, highLevelWarper, batchLens,
|
|
1198
|
+
Int32(paths.count), batchImuTranslationMetres)
|
|
1164
1199
|
|
|
1165
1200
|
let payload = FinalizePayload(
|
|
1166
1201
|
cleaned: cleaned,
|
|
@@ -1168,11 +1203,14 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1168
1203
|
inBatchKeyframeMode: inBatchKeyframeMode,
|
|
1169
1204
|
collector: collector,
|
|
1170
1205
|
paths: paths,
|
|
1171
|
-
batchWarperType:
|
|
1206
|
+
batchWarperType: highLevelWarper,
|
|
1172
1207
|
batchBlenderType: batchBlenderType,
|
|
1173
1208
|
batchSeamFinderType: batchSeamFinderType,
|
|
1174
1209
|
batchEnableInscribedRectCrop: batchEnableInscribedRectCrop,
|
|
1175
1210
|
batchStitchModeResolved: stitchModeResolved,
|
|
1211
|
+
rRadians: rRadiansResolved,
|
|
1212
|
+
tMeters: autoResolution.tMeters,
|
|
1213
|
+
decisionRatio: autoResolution.ratio,
|
|
1176
1214
|
keyframeExifOrientation: keyframeExifOrientation,
|
|
1177
1215
|
captureOrientation: captureOrientation,
|
|
1178
1216
|
drops: drops,
|
|
@@ -1438,10 +1476,15 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1438
1476
|
seamFinderType: payload.batchSeamFinderType,
|
|
1439
1477
|
captureOrientation: payload.captureOrientation,
|
|
1440
1478
|
useInscribedRectCrop: payload.batchEnableInscribedRectCrop,
|
|
1441
|
-
|
|
1442
|
-
//
|
|
1443
|
-
//
|
|
1444
|
-
|
|
1479
|
+
// 2026-06-16 — HIGH-LEVEL ACROSS THE BOARD (mirrors
|
|
1480
|
+
// Android): always cv::Stitcher PANORAMA with the
|
|
1481
|
+
// tree-chosen warper (payload.batchWarperType is now
|
|
1482
|
+
// highLevelWarper). The manual path's OOM hardening
|
|
1483
|
+
// was ported to high-level (catch ladder + two-phase
|
|
1484
|
+
// canvas guard + RAM-aware compositingResol + spherical
|
|
1485
|
+
// rescue), so this is now memory-safe.
|
|
1486
|
+
stitchMode: "panorama",
|
|
1487
|
+
useManualPipeline: false
|
|
1445
1488
|
)
|
|
1446
1489
|
// V16 fix-attempt 9 (verified on device,
|
|
1447
1490
|
// 2026-05-13) — sentinel-result detection.
|
|
@@ -1537,6 +1580,11 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1537
1580
|
// helps the operator understand why the
|
|
1538
1581
|
// panorama looks the way it does.
|
|
1539
1582
|
batchDict["stitchModeResolved"] = payload.batchStitchModeResolved
|
|
1583
|
+
batchDict["rRadians"] = payload.rRadians
|
|
1584
|
+
// Dev tuning readout — translation magnitude + the auto
|
|
1585
|
+
// decision ratio that drove panorama-vs-SCANS.
|
|
1586
|
+
batchDict["tMeters"] = payload.tMeters
|
|
1587
|
+
batchDict["decisionRatio"] = payload.decisionRatio
|
|
1540
1588
|
// 2026-06-14 (DEV overlay) — the stitcher's runtime
|
|
1541
1589
|
// choices (pipeline/warper/route/seam/blend) for this
|
|
1542
1590
|
// output, shown on the preview in __DEV__.
|
|
@@ -2449,13 +2497,13 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2449
2497
|
first: [Double]?,
|
|
2450
2498
|
last: [Double]?,
|
|
2451
2499
|
imuTranslationMetres: Double
|
|
2452
|
-
) -> String {
|
|
2500
|
+
) -> (mode: String, rRadians: Double, tMeters: Double, ratio: Double) {
|
|
2453
2501
|
guard let firstPose = first, firstPose.count == 7,
|
|
2454
2502
|
let lastPose = last, lastPose.count == 7 else {
|
|
2455
2503
|
// No pose data at all — fall back on whichever signal we
|
|
2456
2504
|
// do have. imuTranslationMetres > 0 hints "scans"; 0
|
|
2457
|
-
// hints "panorama".
|
|
2458
|
-
return imuTranslationMetres > 0.05 ? "scans" : "panorama"
|
|
2505
|
+
// hints "panorama". rRadians 0.0 — no gyro signal.
|
|
2506
|
+
return (imuTranslationMetres > 0.05 ? "scans" : "panorama", 0.0, 0.0, 0.0)
|
|
2459
2507
|
}
|
|
2460
2508
|
// Translation magnitude (Euclidean, in metres).
|
|
2461
2509
|
let dtx = lastPose[0] - firstPose[0]
|
|
@@ -2467,14 +2515,7 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2467
2515
|
// the only signal we have.
|
|
2468
2516
|
let tMeters = max(tPose, imuTranslationMetres)
|
|
2469
2517
|
// Rotation magnitude — angle between camera-forward vectors.
|
|
2470
|
-
|
|
2471
|
-
let fwdFirst = qrotForwardZneg(
|
|
2472
|
-
firstPose[3], firstPose[4], firstPose[5], firstPose[6])
|
|
2473
|
-
let fwdLast = qrotForwardZneg(
|
|
2474
|
-
lastPose[3], lastPose[4], lastPose[5], lastPose[6])
|
|
2475
|
-
let dot = max(-1.0, min(1.0,
|
|
2476
|
-
fwdFirst.0 * fwdLast.0 + fwdFirst.1 * fwdLast.1 + fwdFirst.2 * fwdLast.2))
|
|
2477
|
-
let rRadians = acos(dot)
|
|
2518
|
+
let rRadians = rotationRadians(first: firstPose, last: lastPose)
|
|
2478
2519
|
// Normalisation: 10 cm of translation ≈ 1 rad of rotation as
|
|
2479
2520
|
// "equivalent magnitude" for the ratio. Shelf scans cover
|
|
2480
2521
|
// ~30 cm translation with ~10° (0.17 rad) rotation:
|
|
@@ -2484,7 +2525,7 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2484
2525
|
let tScore = tMeters / 0.10
|
|
2485
2526
|
let rScore = rRadians / 1.00
|
|
2486
2527
|
let denom = tScore + rScore
|
|
2487
|
-
if denom <= 1e-9 { return "panorama" } // no motion either way
|
|
2528
|
+
if denom <= 1e-9 { return ("panorama", rRadians, tMeters, 0.0) } // no motion either way
|
|
2488
2529
|
let ratio = tScore / denom
|
|
2489
2530
|
|
|
2490
2531
|
// 2026-06-15 — LOW-ROTATION GUARD. The gyro rotation (rRadians) is
|
|
@@ -2502,7 +2543,43 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2502
2543
|
"[stitchMode.auto] tPose=%.3fm tImu=%.3fm r=%.3frad ratio=%.3f rotGuard=%d → %{public}@",
|
|
2503
2544
|
tPose, imuTranslationMetres, rRadians, ratio,
|
|
2504
2545
|
lowRotationGuard ? 1 : 0, mode)
|
|
2505
|
-
return mode
|
|
2546
|
+
return (mode, rRadians, tMeters, ratio)
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
/// 2026-06-16 — high-level warper decision tree (mirrors Android's
|
|
2550
|
+
/// pickHighLevelWarper). The pipeline is now ALWAYS high-level cv::Stitcher
|
|
2551
|
+
/// PANORAMA. Warper is a pure function of (lens, pan direction); the
|
|
2552
|
+
/// rotation-vs-translation (ex-SCANS) distinction was DROPPED as redundant —
|
|
2553
|
+
/// at 1x the same direction-based warpers serve both, and 0.5x is always
|
|
2554
|
+
/// spherical. orientation = capture hold ("landscape*" = Mode A vertical
|
|
2555
|
+
/// pan; else Mode B horizontal); lens = the EXPLICIT lens ("0.5x" | "1x").
|
|
2556
|
+
///
|
|
2557
|
+
/// 0.5x ultra-wide → spherical (bounded both axes; any pan)
|
|
2558
|
+
/// 1x + Mode A (vertical) → plane
|
|
2559
|
+
/// 1x + Mode B (horizontal) → cylindrical
|
|
2560
|
+
///
|
|
2561
|
+
/// Quality-preferred warper; the C++ memory ladder force-falls to spherical
|
|
2562
|
+
/// (and downscales compositingResol) under pressure.
|
|
2563
|
+
private func pickHighLevelWarper(
|
|
2564
|
+
orientation: String,
|
|
2565
|
+
lens: String
|
|
2566
|
+
) -> String {
|
|
2567
|
+
if lens == "0.5x" { return "spherical" } // ultra-wide → always spherical
|
|
2568
|
+
let verticalPanModeA = orientation.hasPrefix("landscape")
|
|
2569
|
+
return verticalPanModeA ? "plane" : "cylindrical" // 1x: A→plane, B→cylindrical
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
/// Gyro rotation magnitude (radians) between two 7-element poses
|
|
2573
|
+
/// `[tx,ty,tz,qx,qy,qz,qw]` — angle between camera-forward vectors.
|
|
2574
|
+
/// Returns 0.0 if either pose is missing/malformed (non-AR, no pose).
|
|
2575
|
+
/// Shared by `resolveStitchModeAuto` + the finalize `rRadians` readout (DRY).
|
|
2576
|
+
private func rotationRadians(first: [Double]?, last: [Double]?) -> Double {
|
|
2577
|
+
guard let f = first, f.count == 7, let l = last, l.count == 7 else { return 0.0 }
|
|
2578
|
+
let fwdFirst = qrotForwardZneg(f[3], f[4], f[5], f[6])
|
|
2579
|
+
let fwdLast = qrotForwardZneg(l[3], l[4], l[5], l[6])
|
|
2580
|
+
let dot = max(-1.0, min(1.0,
|
|
2581
|
+
fwdFirst.0 * fwdLast.0 + fwdFirst.1 * fwdLast.1 + fwdFirst.2 * fwdLast.2))
|
|
2582
|
+
return acos(dot)
|
|
2506
2583
|
}
|
|
2507
2584
|
|
|
2508
2585
|
/// Closed-form q · (0,0,-1) · q⁻¹ — rotates the camera-forward
|
|
@@ -56,6 +56,10 @@ RCT_EXTERN_METHOD(markNextFrameAsLastKeyframe:(RCTPromiseResolveBlock)resolver
|
|
|
56
56
|
RCT_EXTERN_METHOD(getMemoryFootprintMB:(RCTPromiseResolveBlock)resolver
|
|
57
57
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
|
58
58
|
|
|
59
|
+
// 2026-06-16 — total physical RAM (MB) for the pill's RAM-aware pressure bands.
|
|
60
|
+
RCT_EXTERN_METHOD(getDeviceTotalRamMB:(RCTPromiseResolveBlock)resolver
|
|
61
|
+
rejecter:(RCTPromiseRejectBlock)rejecter)
|
|
62
|
+
|
|
59
63
|
// 2026-05-16 — realtime+batch fusion (Option A "Replace on completion").
|
|
60
64
|
// Run the shared C++ stitcher over a caller-supplied list of keyframe
|
|
61
65
|
// JPEG paths and write a refined panorama to `outputPath`. See JS
|
|
@@ -217,6 +217,10 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
|
|
|
217
217
|
// and to PANORAMA when both are 0).
|
|
218
218
|
let imuT = (options["imuTranslationMetres"] as? Double) ?? 0.0
|
|
219
219
|
IncrementalStitcher.shared.updateImuTranslationMetres(imuT)
|
|
220
|
+
// 2026-06-16 — the EXPLICIT lens the user selected ('1x'|'0.5x'): the
|
|
221
|
+
// reliable zoom signal for the high-level warper tree (0.5x → spherical).
|
|
222
|
+
let lens = (options["lens"] as? String) ?? "1x"
|
|
223
|
+
IncrementalStitcher.shared.updateLens(lens)
|
|
220
224
|
IncrementalStitcher.shared.finalize(
|
|
221
225
|
toPath: outputPath,
|
|
222
226
|
jpegQuality: quality
|
|
@@ -325,6 +329,17 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
|
|
|
325
329
|
resolver(mb)
|
|
326
330
|
}
|
|
327
331
|
|
|
332
|
+
/// Total physical RAM in MB. Lets the DEV memory pill derive RAM-aware
|
|
333
|
+
/// pressure bands (iOS jetsam scales with device RAM) instead of fixed
|
|
334
|
+
/// thresholds. NSProcessInfo.physicalMemory is exact + cheap.
|
|
335
|
+
@objc(getDeviceTotalRamMB:rejecter:)
|
|
336
|
+
public func getDeviceTotalRamMB(
|
|
337
|
+
resolver: @escaping RCTPromiseResolveBlock,
|
|
338
|
+
rejecter: @escaping RCTPromiseRejectBlock
|
|
339
|
+
) {
|
|
340
|
+
resolver(Double(ProcessInfo.processInfo.physicalMemory) / (1024.0 * 1024.0))
|
|
341
|
+
}
|
|
342
|
+
|
|
328
343
|
/// 2026-05-16 — realtime+batch fusion (Option A) bridge. Marshal
|
|
329
344
|
/// the options dictionary into the engine layer, dispatch the
|
|
330
345
|
/// refinement off the bridge thread so the JS Promise doesn't block
|
|
@@ -509,6 +509,17 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
|
|
|
509
509
|
// output); the on-demand high-level tab re-stitches with NO.
|
|
510
510
|
cfg.useManualPipeline = useManualPipeline;
|
|
511
511
|
|
|
512
|
+
// 2026-06-16 — iOS resident-memory probe. iOS has no /proc/self/statm, so the
|
|
513
|
+
// shared rss_mb() returned -1 — which (a) blinded the per-stitch profiling and
|
|
514
|
+
// (b) silently DISABLED the runtime-pressure half of the manual pipeline's OOM
|
|
515
|
+
// router (the lowBatchHeadroom STREAM trigger), on the very platform (jetsam)
|
|
516
|
+
// it protects. Plug task_info(TASK_VM_INFO).phys_footprint (the metric jetsam
|
|
517
|
+
// evaluates) as the probe. Set UNCONDITIONALLY — the OOM guards must work in
|
|
518
|
+
// release too; only the sampler + per-stitch record are gated by the compile
|
|
519
|
+
// flag (debug-on, release-off).
|
|
520
|
+
cfg.memProbeFn = []() -> double { return StitcherResidentMB(); };
|
|
521
|
+
cfg.enableMemoryProfiling = (RNIS_MEMORY_PROFILING != 0);
|
|
522
|
+
|
|
512
523
|
// Marshal NSArray<NSString*> → std::vector<std::string>. Strip the
|
|
513
524
|
// `file://` scheme that some callers attach so the shared C++ can
|
|
514
525
|
// cv::imread the raw filesystem path.
|
|
@@ -590,8 +601,17 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
|
|
|
590
601
|
framesIncluded:(NSInteger)r.framesIncluded
|
|
591
602
|
finalConfidenceThresh:r.finalConfidenceThresh];
|
|
592
603
|
if (!r.debugSummary.empty()) {
|
|
604
|
+
std::string dbg = r.debugSummary;
|
|
605
|
+
// iOS has no mallopt purge; the post-stitch settle read IS the leak
|
|
606
|
+
// floor (memFloor). Append it so it rides debugSummary to JS like
|
|
607
|
+
// Android's post-purge value (gated; debug-only).
|
|
608
|
+
if (RNIS_MEMORY_PROFILING != 0) {
|
|
609
|
+
char fbuf[40];
|
|
610
|
+
snprintf(fbuf, sizeof(fbuf), ";memFloor=%.1f", StitcherResidentMB());
|
|
611
|
+
dbg += fbuf;
|
|
612
|
+
}
|
|
593
613
|
result.debugSummary =
|
|
594
|
-
[NSString stringWithUTF8String:
|
|
614
|
+
[NSString stringWithUTF8String:dbg.c_str()];
|
|
595
615
|
}
|
|
596
616
|
// 2026-06-15 — the eager A/B harness that ALSO stitched the high-level
|
|
597
617
|
// alt on EVERY capture has been REMOVED. Manual is now the default (this
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-image-stitcher",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.2",
|
|
4
4
|
"description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|