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.
Files changed (38) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +41 -44
  3. package/android/build.gradle +34 -0
  4. package/android/src/main/cpp/image_stitcher_jni.cpp +52 -7
  5. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  6. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +135 -59
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  8. package/cpp/keyframe_gate.cpp +54 -15
  9. package/cpp/keyframe_gate.hpp +33 -0
  10. package/cpp/stitcher.cpp +481 -87
  11. package/cpp/stitcher.hpp +52 -0
  12. package/dist/camera/Camera.d.ts +13 -0
  13. package/dist/camera/Camera.js +9 -64
  14. package/dist/camera/CaptureFrameCounterOverlay.js +12 -1
  15. package/dist/camera/CaptureMemoryPill.d.ts +15 -7
  16. package/dist/camera/CaptureMemoryPill.js +34 -9
  17. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  18. package/dist/camera/PanoramaBandOverlay.js +9 -3
  19. package/dist/camera/PanoramaSettings.js +22 -25
  20. package/dist/camera/RectCropPreview.d.ts +3 -29
  21. package/dist/camera/RectCropPreview.js +20 -130
  22. package/dist/stitching/incremental.d.ts +29 -0
  23. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  24. package/dist/stitching/useIncrementalStitcher.js +7 -1
  25. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +103 -26
  26. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  27. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  28. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +21 -1
  29. package/package.json +1 -1
  30. package/src/camera/Camera.tsx +21 -70
  31. package/src/camera/CaptureFrameCounterOverlay.tsx +15 -1
  32. package/src/camera/CaptureMemoryPill.tsx +33 -9
  33. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  34. package/src/camera/PanoramaSettings.ts +22 -25
  35. package/src/camera/RectCropPreview.tsx +38 -220
  36. package/src/stitching/incremental.ts +29 -0
  37. package/src/stitching/useIncrementalStitcher.ts +13 -0
  38. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
@@ -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 iPhone 16 Pro per-process jetsam limit:
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
- * - green <1500 MB (comfortable)
9
- * - amber 1500–2200 (approaching pressure)
10
- * - red >2200 (close to limit capture may be killed)
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
- * Backed by the existing `getMemoryFootprintMB()` native module
13
- * (iOS: `task_info phys_footprint`, Android: `Debug.MemoryInfo
14
- * getTotalPss * 1024`). Returns -1 if the native call fails.
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 > 2200 ? 'rgba(239, 68, 68, 0.92)' // red
78
- : memMB > 1500 ? 'rgba(245, 158, 11, 0.92)' // amber
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
- export function PanoramaBandOverlay({
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 — PANORAMA by default (was 'auto'). The auto-resolver's SCANS
318
- // branch leans on double-integrated IMU translation, which is unreliable
319
- // during rotation (gravity leakage inflates the translation estimate); in
320
- // practice rotational pans are the common case and resolve to panorama
321
- // anyway. Defaulting to panorama is the robust choice host apps that
322
- // genuinely capture flat documents/walls can still opt into 'auto' or
323
- // 'scans' via the ⚙️ panel or the `defaultStitchMode` / `stitcher` props.
324
- stitchMode: 'panorama',
325
- // v0.16SPHERICAL by default (bounds both axes; the proven-robust wide/
326
- // vertical-pan projection). This is now the single source of truth the
327
- // native side no longer hardcodes a warper, so the ⚙️ panel + the host's
328
- // `defaultWarper` prop actually take effect. Note: choosing `plane` here
329
- // re-arms the dynamic plane→spherical fallback/divergence switch in the
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.2flatter, 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 — denser keyframes by default: a 15% novelty gate, up to 8 frames,
343
- // plus a 1.5 s time-budget force-accept (so a slow/static pan still lands a
344
- // keyframe every 1.5 s even when novelty is low). With 8 frames this bounds
345
- // a static/slow capture to ~8×1.5 12 s before the keyframe-count
346
- // auto-finalize. More overlap between consecutive keyframes stronger
347
- // feature matching more robust registration. Memory-checked: 8 frames fit
348
- // the BATCH held-set cap on both platforms. Overlap selectable in the
349
- // settings panel {10,15,20,30}% (native clamp floor 10%); cap clamps [3,10].
350
- maxKeyframes: 8,
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
  },