react-native-image-stitcher 0.16.0 → 0.16.1
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 +47 -0
- package/README.md +25 -10
- 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
package/src/camera/Camera.tsx
CHANGED
|
@@ -223,6 +223,19 @@ export type CameraCaptureResult =
|
|
|
223
223
|
* cv::Stitcher at finalize).
|
|
224
224
|
*/
|
|
225
225
|
stitchModeResolved?: 'panorama' | 'scans';
|
|
226
|
+
/**
|
|
227
|
+
* 2026-06-15 (DEV) — gyro rotation magnitude of the capture, in radians.
|
|
228
|
+
* Shown on the dev preview so the panorama-vs-SCANS rotation threshold can
|
|
229
|
+
* be tuned. `0` = no pose-derived rotation signal (non-AR with no poses).
|
|
230
|
+
*/
|
|
231
|
+
rRadians?: number;
|
|
232
|
+
/**
|
|
233
|
+
* 2026-06-16 (DEV) — translation magnitude (m) + auto decision ratio
|
|
234
|
+
* (`>=0.55` → SCANS) that drove panorama-vs-SCANS. Shown on the dev
|
|
235
|
+
* readout alongside `rRadians` to tune the threshold from real captures.
|
|
236
|
+
*/
|
|
237
|
+
tMeters?: number;
|
|
238
|
+
decisionRatio?: number;
|
|
226
239
|
/**
|
|
227
240
|
* 2026-06-14 (DEV overlay) — semicolon-separated `key=value` trace of the
|
|
228
241
|
* stitcher's runtime choices (pipe/warp/route/seam/blend) for this
|
|
@@ -1251,64 +1264,6 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1251
1264
|
warnings: CaptureWarning[];
|
|
1252
1265
|
} | null>(null);
|
|
1253
1266
|
|
|
1254
|
-
// 2026-06-15 — ON-DEMAND high-level preview. Manual is the default/eager
|
|
1255
|
-
// output; when the user switches to the "high-level" tab in the preview we
|
|
1256
|
-
// re-stitch the SAME captured keyframes through stock cv::Stitcher via
|
|
1257
|
-
// `refinePanorama` (useManualPipeline:false). Resolves with the high-level
|
|
1258
|
-
// JPEG's file:// uri AND its OWN DEV-overlay recipe (so the preview pill shows
|
|
1259
|
-
// the high-level recipe — pipe=highlevel;… — while that tab is viewed, not the
|
|
1260
|
-
// manual primary's recipe), or null when unavailable (no keyframe paths —
|
|
1261
|
-
// e.g. Android — or the stitch failed). Computed lazily so it costs nothing
|
|
1262
|
-
// unless the user actually asks for it.
|
|
1263
|
-
const requestHighLevelAlt = useCallback(async (): Promise<{
|
|
1264
|
-
uri: string;
|
|
1265
|
-
debugInfo: string;
|
|
1266
|
-
} | null> => {
|
|
1267
|
-
const pending = cropPending;
|
|
1268
|
-
const kf = pending?.captureResultObj.keyframePaths;
|
|
1269
|
-
if (!pending || !kf || kf.length < 2) return null;
|
|
1270
|
-
const native = getIncrementalNativeModule();
|
|
1271
|
-
if (!native) return null;
|
|
1272
|
-
const outputPath = `${toBareFilePath(pending.uri).replace(/\.jpg$/i, '')}-highlevel.jpg`;
|
|
1273
|
-
try {
|
|
1274
|
-
const r = await native.refinePanorama({
|
|
1275
|
-
framePaths: kf,
|
|
1276
|
-
outputPath,
|
|
1277
|
-
config: {
|
|
1278
|
-
useManualPipeline: false,
|
|
1279
|
-
warperType: 'spherical',
|
|
1280
|
-
stitchMode: 'panorama',
|
|
1281
|
-
// Match the manual output's rotation — without this the high-level
|
|
1282
|
-
// re-stitch bakes "portrait" (no rotation) and comes out sideways.
|
|
1283
|
-
captureOrientation: pending.captureResultObj.captureOrientation as
|
|
1284
|
-
| 'portrait'
|
|
1285
|
-
| 'portrait-upside-down'
|
|
1286
|
-
| 'landscape-left'
|
|
1287
|
-
| 'landscape-right'
|
|
1288
|
-
| undefined,
|
|
1289
|
-
},
|
|
1290
|
-
});
|
|
1291
|
-
// Plain file:// uri — the path is unique per capture and computed once, so
|
|
1292
|
-
// no cache-bust here (the accept handler adds one when emitting). The
|
|
1293
|
-
// DEV pill text is the HIGH-LEVEL stitch's own recipe (only the fields
|
|
1294
|
-
// IncrementalRefineResult carries; buildStitchDebugInfo tolerates the rest
|
|
1295
|
-
// being absent).
|
|
1296
|
-
return {
|
|
1297
|
-
uri: toFileUri(r.panoramaPath),
|
|
1298
|
-
debugInfo: buildStitchDebugInfo({
|
|
1299
|
-
debugSummary: r.debugSummary,
|
|
1300
|
-
finalConfidenceThresh: r.finalConfidenceThresh,
|
|
1301
|
-
framesIncluded: r.framesIncluded,
|
|
1302
|
-
framesRequested: r.framesRequested,
|
|
1303
|
-
width: r.width,
|
|
1304
|
-
height: r.height,
|
|
1305
|
-
}),
|
|
1306
|
-
};
|
|
1307
|
-
} catch {
|
|
1308
|
-
return null;
|
|
1309
|
-
}
|
|
1310
|
-
}, [cropPending]);
|
|
1311
|
-
|
|
1312
1267
|
// 2026-05-22 (audit F9 + F3) — debug stitch-stats toast. Hook
|
|
1313
1268
|
// exposes an imperative API; we fire `showResult(finalizeResult)`
|
|
1314
1269
|
// on every successful finalize when settings.debug is on (gated
|
|
@@ -2086,6 +2041,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
2086
2041
|
90, // default JPEG quality
|
|
2087
2042
|
deviceOrientation,
|
|
2088
2043
|
imuTotalTranslationM,
|
|
2044
|
+
lens, // 2026-06-16 — explicit '1x'|'0.5x' for the high-level warper tree
|
|
2089
2045
|
);
|
|
2090
2046
|
if (
|
|
2091
2047
|
typeof result.framesRequested === 'number'
|
|
@@ -2125,6 +2081,9 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
2125
2081
|
finalConfidenceThresh: result.finalConfidenceThresh ?? -1,
|
|
2126
2082
|
durationMs: Date.now() - (recordingStartedAt ?? Date.now()),
|
|
2127
2083
|
stitchModeResolved: result.stitchModeResolved,
|
|
2084
|
+
rRadians: result.rRadians,
|
|
2085
|
+
tMeters: result.tMeters,
|
|
2086
|
+
decisionRatio: result.decisionRatio,
|
|
2128
2087
|
debugSummary: result.debugSummary,
|
|
2129
2088
|
keyframePaths: result.batchKeyframePaths,
|
|
2130
2089
|
captureOrientation: result.captureOrientation,
|
|
@@ -2228,6 +2187,10 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
2228
2187
|
isNonAR,
|
|
2229
2188
|
imuGate,
|
|
2230
2189
|
stitchToast,
|
|
2190
|
+
// 2026-06-16 — the finalize passes `lens` (the high-level warper tree's zoom
|
|
2191
|
+
// signal); without it here the closure would send a STALE lens if the user
|
|
2192
|
+
// switched 1x↔0.5x after this callback was last memoized.
|
|
2193
|
+
lens,
|
|
2231
2194
|
// feature/pano-ux-guidance — the release also tears down the
|
|
2232
2195
|
// pan-duration timer + a pending rotate-gate, and decides whether to
|
|
2233
2196
|
// route the result through the crop editor.
|
|
@@ -2864,18 +2827,6 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
2864
2827
|
imageUri={cropPending?.uri ?? ''}
|
|
2865
2828
|
imageWidth={cropPending?.width ?? 0}
|
|
2866
2829
|
imageHeight={cropPending?.height ?? 0}
|
|
2867
|
-
// 2026-06-15 — manual is the default/eager output. The high-level tab
|
|
2868
|
-
// is ON DEMAND: RectCropPreview calls onRequestAlt() (which re-stitches
|
|
2869
|
-
// the captured keyframes via cv::Stitcher) only when the user switches
|
|
2870
|
-
// to it. DEBUG-ONLY: it's a pipeline-comparison tool (dev-jargon
|
|
2871
|
-
// "Manual"/"High-level" labels), gated behind `settings.debug` like the
|
|
2872
|
-
// rest of the diagnostic UI. Also requires keyframePaths, so it only
|
|
2873
|
-
// appears where it can run (iOS); Android returns no paths → no tab.
|
|
2874
|
-
onRequestAlt={
|
|
2875
|
-
settings.debug && cropPending?.captureResultObj.keyframePaths?.length
|
|
2876
|
-
? requestHighLevelAlt
|
|
2877
|
-
: undefined
|
|
2878
|
-
}
|
|
2879
2830
|
initialRect={cropPending?.initialRect}
|
|
2880
2831
|
warnings={cropPending?.warnings.map((w) => w.message) ?? []}
|
|
2881
2832
|
showCropControls={rectCrop}
|
|
@@ -36,6 +36,14 @@ import { GUIDANCE_COUNTDOWN, GUIDANCE_PILL, GUIDANCE_TOKENS } from './guidanceTo
|
|
|
36
36
|
import { type DeviceOrientation } from './useDeviceOrientation';
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Extra distance (px) to drop the counter from the user-top in landscape so it
|
|
41
|
+
* clears the pan how-to coach-mark's bouncing arrow. Landscape only; portrait
|
|
42
|
+
* is unaffected. 72 px (the symmetric lift) over-cleared, so this is smaller.
|
|
43
|
+
*/
|
|
44
|
+
const COUNTER_LANDSCAPE_EXTRA_INSET = 40;
|
|
45
|
+
|
|
46
|
+
|
|
39
47
|
export interface CaptureFrameCounterOverlayProps {
|
|
40
48
|
/** Show / hide. Driven by the host while a capture is recording. */
|
|
41
49
|
visible: boolean;
|
|
@@ -63,9 +71,15 @@ export function CaptureFrameCounterOverlay({
|
|
|
63
71
|
// briefly report the cap-th accept before the parent finalizes.
|
|
64
72
|
const k = Math.max(0, Math.min(framesCaptured, framesMax));
|
|
65
73
|
|
|
74
|
+
// 2026-06-16 — in LANDSCAPE, push the counter further from the user-top so it
|
|
75
|
+
// clears the pan how-to coach-mark's bouncing amber arrow, which sits near the
|
|
76
|
+
// top there and otherwise overlaps it. Portrait keeps the standard inset.
|
|
77
|
+
// Tune COUNTER_LANDSCAPE_EXTRA_INSET if the gap is too small / too large.
|
|
78
|
+
const isLandscape =
|
|
79
|
+
orientation === 'landscape-left' || orientation === 'landscape-right';
|
|
66
80
|
const { container, rotate } = topCenterForOrientation(
|
|
67
81
|
orientation,
|
|
68
|
-
GUIDANCE_COUNTDOWN.inset,
|
|
82
|
+
GUIDANCE_COUNTDOWN.inset + (isLandscape ? COUNTER_LANDSCAPE_EXTRA_INSET : 0),
|
|
69
83
|
);
|
|
70
84
|
|
|
71
85
|
return (
|
|
@@ -3,15 +3,23 @@
|
|
|
3
3
|
* CaptureMemoryPill — top-right diagnostic pill showing native
|
|
4
4
|
* process memory footprint in MB, polled at 500 ms.
|
|
5
5
|
*
|
|
6
|
-
* Color-coded against the
|
|
6
|
+
* Color-coded against the device's per-process memory budget, which is read
|
|
7
|
+
* once at mount via `getDeviceTotalRamMB()` (RAM-aware):
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
9
|
+
* budget = max(RAM × 0.42, 900 MB) (mirrors warp_guard.hpp
|
|
10
|
+
* perProcessMemoryBudgetMB)
|
|
11
|
+
* - green < 55 % of budget (comfortable)
|
|
12
|
+
* - amber 55–70 % of budget (approaching pressure)
|
|
13
|
+
* - red > 70 % of budget (close to limit — capture may be killed)
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* Why RAM-aware: the old fixed 1500/2200 MB thresholds were tuned for the
|
|
16
|
+
* iPhone 16 Pro and NEVER tripped on a 4 GB Android phone that jetsams ~1.3 GB
|
|
17
|
+
* (false comfort exactly where OOM happens). Falls back to 1500/2200 if the
|
|
18
|
+
* RAM read is unavailable.
|
|
19
|
+
*
|
|
20
|
+
* Backed by the `getMemoryFootprintMB()` native module (iOS: `task_info`
|
|
21
|
+
* `phys_footprint`; Android: `/proc/self/statm` RSS — the SAME number the C++
|
|
22
|
+
* `[memstat]` logs report). Returns -1 if the native call fails.
|
|
15
23
|
*
|
|
16
24
|
* Mount this pill inside a `settings.debug`-gated branch — it
|
|
17
25
|
* polls native every 500 ms and is unwanted in production builds.
|
|
@@ -50,11 +58,21 @@ export function CaptureMemoryPill({
|
|
|
50
58
|
style,
|
|
51
59
|
}: CaptureMemoryPillProps): React.JSX.Element | null {
|
|
52
60
|
const [memMB, setMemMB] = useState<number | null>(null);
|
|
61
|
+
// Device total RAM (MB), read once — drives the RAM-aware pressure bands.
|
|
62
|
+
const [ramMB, setRamMB] = useState<number | null>(null);
|
|
53
63
|
|
|
54
64
|
useEffect(() => {
|
|
55
65
|
const native = getIncrementalNativeModule();
|
|
56
66
|
if (!native?.getMemoryFootprintMB) return undefined;
|
|
57
67
|
let cancelled = false;
|
|
68
|
+
// One-time RAM read for the bands (optional native method — older bridges
|
|
69
|
+
// without it just keep the fixed-threshold fallback).
|
|
70
|
+
native
|
|
71
|
+
.getDeviceTotalRamMB?.()
|
|
72
|
+
.then((r) => {
|
|
73
|
+
if (!cancelled && r > 0) setRamMB(r);
|
|
74
|
+
})
|
|
75
|
+
.catch(() => {});
|
|
58
76
|
const tick = async () => {
|
|
59
77
|
try {
|
|
60
78
|
const mb = await native.getMemoryFootprintMB();
|
|
@@ -73,9 +91,15 @@ export function CaptureMemoryPill({
|
|
|
73
91
|
|
|
74
92
|
if (memMB === null || memMB < 0) return null;
|
|
75
93
|
|
|
94
|
+
// RAM-aware bands: budget = max(RAM × 0.42, 900) (mirrors warp_guard.hpp
|
|
95
|
+
// perProcessMemoryBudgetMB); amber at 55 %, red at 70 %. Fall back to the
|
|
96
|
+
// iPhone-tuned fixed thresholds when RAM is unknown.
|
|
97
|
+
const budget = ramMB != null ? Math.max(ramMB * 0.42, 900) : null;
|
|
98
|
+
const redAt = budget != null ? budget * 0.7 : 2200;
|
|
99
|
+
const amberAt = budget != null ? budget * 0.55 : 1500;
|
|
76
100
|
const bg =
|
|
77
|
-
memMB >
|
|
78
|
-
: memMB >
|
|
101
|
+
memMB > redAt ? 'rgba(239, 68, 68, 0.92)' // red
|
|
102
|
+
: memMB > amberAt ? 'rgba(245, 158, 11, 0.92)' // amber
|
|
79
103
|
: 'rgba(34, 197, 94, 0.92)'; // green
|
|
80
104
|
|
|
81
105
|
return (
|
|
@@ -361,7 +361,7 @@ function layoutFor(
|
|
|
361
361
|
}
|
|
362
362
|
|
|
363
363
|
|
|
364
|
-
|
|
364
|
+
function PanoramaBandOverlayImpl({
|
|
365
365
|
state,
|
|
366
366
|
frameUris,
|
|
367
367
|
captureOrientation,
|
|
@@ -585,6 +585,14 @@ export function PanoramaBandOverlay({
|
|
|
585
585
|
);
|
|
586
586
|
}
|
|
587
587
|
|
|
588
|
+
// 2026-06-16 (audit #7) — memoized. This is the lone ~6 Hz consumer that mounts
|
|
589
|
+
// in PRODUCTION (the debug pills are settings.debug-gated), and most engine ticks
|
|
590
|
+
// are REJECTED frames that don't change its visible inputs (frameUris /
|
|
591
|
+
// acceptedCount / orientation). React.memo skips the re-render on those, so the
|
|
592
|
+
// ~6×/sec engine emits no longer re-render this overlay's subtree on the hot
|
|
593
|
+
// capture path (battery/heat on long captures).
|
|
594
|
+
export const PanoramaBandOverlay = React.memo(PanoramaBandOverlayImpl);
|
|
595
|
+
|
|
588
596
|
|
|
589
597
|
const styles = StyleSheet.create({
|
|
590
598
|
// Properties common to every layout — uniform border-radius so the
|
|
@@ -314,21 +314,19 @@ export const DEFAULT_PANORAMA_SETTINGS: PanoramaSettings = {
|
|
|
314
314
|
captureSource: 'ar',
|
|
315
315
|
debug: false,
|
|
316
316
|
stitcher: {
|
|
317
|
-
// v0.16 —
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// v0.
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
// manual pipeline (it only fires when warperType != spherical).
|
|
331
|
-
warperType: 'spherical',
|
|
317
|
+
// v0.16 — AUTO by default. Reverted from the brief 'panorama' default after
|
|
318
|
+
// on-device comparison (matches the v0.15.2 behaviour, which produced better
|
|
319
|
+
// results for these captures). The auto-resolver now carries the
|
|
320
|
+
// low-rotation guard (rRadians>0.35 && t<0.25 → force PANORAMA), so the old
|
|
321
|
+
// IMU-gravity-leak SCANS misclassification on rotational pans is fixed; auto
|
|
322
|
+
// can again safely pick SCANS (high-level affine) for genuine flat scans.
|
|
323
|
+
stitchMode: 'auto',
|
|
324
|
+
// v0.16 — PLANE by default. Reverted from 'spherical' after on-device
|
|
325
|
+
// comparison (matches v0.15.2 — flatter, more natural for the common 1x
|
|
326
|
+
// pan). Plane is unbounded, so this re-arms the manual pipeline's dynamic
|
|
327
|
+
// plane→SPHERICAL divergence/quality fallback (it fires only when
|
|
328
|
+
// warperType != 'spherical'), keeping wide/off-axis pans safe.
|
|
329
|
+
warperType: 'plane',
|
|
332
330
|
blenderType: 'multiband',
|
|
333
331
|
seamFinderType: 'graphcut',
|
|
334
332
|
// v0.15 — inscribed-rect crop is OFF by default (bbox crop keeps all
|
|
@@ -339,16 +337,15 @@ export const DEFAULT_PANORAMA_SETTINGS: PanoramaSettings = {
|
|
|
339
337
|
},
|
|
340
338
|
frameSelection: {
|
|
341
339
|
mode: 'flow-based',
|
|
342
|
-
// v0.16 —
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
// the
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
overlapThreshold: 0.15,
|
|
340
|
+
// v0.16 — keyframe gate: a 20% novelty gate, up to 6 frames, plus a 1.5 s
|
|
341
|
+
// time-budget force-accept (so a slow/static pan still lands a keyframe every
|
|
342
|
+
// 1.5 s even when novelty is low). These match the leaner v0.15.2 cadence (6
|
|
343
|
+
// frames / 20% overlap) — fewer, more-novel keyframes = lighter memory + less
|
|
344
|
+
// redundant overlap. With 6 frames this bounds a static/slow capture to
|
|
345
|
+
// ~6×1.5 ≈ 9 s before the keyframe-count auto-finalize. Overlap selectable in
|
|
346
|
+
// the settings panel {10,15,20,30}% (native clamp floor 10%); cap clamps [3,10].
|
|
347
|
+
maxKeyframes: 6,
|
|
348
|
+
overlapThreshold: 0.20,
|
|
352
349
|
maxKeyframeIntervalMs: 1500,
|
|
353
350
|
flow: DEFAULT_FLOW_GATE_SETTINGS,
|
|
354
351
|
},
|