react-native-image-stitcher 0.2.1 → 0.4.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +511 -1
  2. package/README.md +1 -1
  3. package/android/src/main/cpp/keyframe_gate_jni.cpp +138 -0
  4. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +412 -40
  5. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +128 -0
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +87 -45
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +46 -4
  8. package/cpp/stitcher.cpp +101 -1
  9. package/cpp/stitcher.hpp +8 -0
  10. package/dist/camera/Camera.d.ts +9 -0
  11. package/dist/camera/Camera.js +165 -43
  12. package/dist/camera/CaptureDebugOverlay.d.ts +45 -0
  13. package/dist/camera/CaptureDebugOverlay.js +146 -0
  14. package/dist/camera/CaptureKeyframePill.d.ts +28 -0
  15. package/dist/camera/CaptureKeyframePill.js +60 -0
  16. package/dist/camera/CaptureMemoryPill.d.ts +28 -0
  17. package/dist/camera/CaptureMemoryPill.js +109 -0
  18. package/dist/camera/CaptureOrientationPill.d.ts +22 -0
  19. package/dist/camera/CaptureOrientationPill.js +44 -0
  20. package/dist/camera/CaptureStitchStatsToast.d.ts +45 -0
  21. package/dist/camera/CaptureStitchStatsToast.js +133 -0
  22. package/dist/camera/PanoramaSettings.d.ts +478 -0
  23. package/dist/camera/PanoramaSettings.js +120 -0
  24. package/dist/camera/PanoramaSettingsBridge.d.ts +84 -0
  25. package/dist/camera/PanoramaSettingsBridge.js +208 -0
  26. package/dist/camera/PanoramaSettingsModal.d.ts +50 -298
  27. package/dist/camera/PanoramaSettingsModal.js +189 -354
  28. package/dist/camera/buildPanoramaInitialSettings.d.ts +70 -0
  29. package/dist/camera/buildPanoramaInitialSettings.js +97 -0
  30. package/dist/camera/lowMemDevice.d.ts +24 -0
  31. package/dist/camera/lowMemDevice.js +69 -0
  32. package/dist/index.d.ts +16 -2
  33. package/dist/index.js +37 -2
  34. package/dist/sensors/useIMUTranslationGate.d.ts +26 -0
  35. package/dist/sensors/useIMUTranslationGate.js +83 -1
  36. package/dist/stitching/incremental.d.ts +25 -0
  37. package/dist/stitching/useIncrementalStitcher.d.ts +12 -1
  38. package/dist/stitching/useIncrementalStitcher.js +7 -1
  39. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +321 -7
  40. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +8 -0
  41. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +12 -0
  42. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +13 -0
  43. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +15 -0
  44. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +1 -0
  45. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +17 -4
  46. package/ios/Sources/RNImageStitcher/Stitcher.swift +6 -1
  47. package/package.json +6 -2
  48. package/src/camera/Camera.tsx +220 -54
  49. package/src/camera/CaptureDebugOverlay.tsx +180 -0
  50. package/src/camera/CaptureKeyframePill.tsx +77 -0
  51. package/src/camera/CaptureMemoryPill.tsx +96 -0
  52. package/src/camera/CaptureOrientationPill.tsx +57 -0
  53. package/src/camera/CaptureStitchStatsToast.tsx +155 -0
  54. package/src/camera/PanoramaSettings.ts +605 -0
  55. package/src/camera/PanoramaSettingsBridge.ts +238 -0
  56. package/src/camera/PanoramaSettingsModal.tsx +296 -988
  57. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +375 -0
  58. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +119 -0
  59. package/src/camera/__tests__/lowMemDevice.test.ts +52 -0
  60. package/src/camera/buildPanoramaInitialSettings.ts +139 -0
  61. package/src/camera/lowMemDevice.ts +71 -0
  62. package/src/index.ts +61 -3
  63. package/src/sensors/useIMUTranslationGate.ts +112 -1
  64. package/src/stitching/incremental.ts +25 -0
  65. package/src/stitching/useIncrementalStitcher.ts +18 -0
package/CHANGELOG.md CHANGED
@@ -16,6 +16,515 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.4.0] — 2026-05-23
20
+
21
+ ### v0.4 settings revamp (F10)
22
+
23
+ > [!WARNING]
24
+ > **Breaking type change.** The flat 45-field `PanoramaSettings`
25
+ > interface from v0.3 has been replaced with three engine-discriminated
26
+ > hierarchical types (`PanoramaSettings`, `SlitscanSettings`,
27
+ > `HybridSettings`). Consumers passing custom settings literals to
28
+ > `<Camera>` or to a Layer 2 modal must migrate to the new shape; the
29
+ > v0.3 type is deleted, not aliased. The C++ engine wire format is
30
+ > unchanged — only the JS-side type surface moved.
31
+ >
32
+ > **Migration guide:** [`docs/migrations/v0.3-to-v0.4-panorama-settings.md`](docs/migrations/v0.3-to-v0.4-panorama-settings.md)
33
+ > walks through every recipe (default-only hosts, custom-literal
34
+ > hosts, slit-scan / hybrid hosts, storage migration for persisted
35
+ > settings).
36
+
37
+ #### Why
38
+
39
+ The 2026-05-22 audit (entry below in v0.3.0) traced every
40
+ `PanoramaSettings` field's native consumer and proved the flat type
41
+ mixed three engines' (batch-keyframe, slit-scan, hybrid) settings into
42
+ one bag of disjoint subsets. Hosts had no way to know at the type
43
+ level which settings their chosen engine would even read; the modal
44
+ exposed knobs that were silently ignored on the active engine. The
45
+ revamp splits the type along engine boundaries so the types match what
46
+ each engine actually consumes.
47
+
48
+ #### What changed
49
+
50
+ - **New file:** `src/camera/PanoramaSettings.ts` — `CaptureBaseSettings`
51
+ + three top-level types (`PanoramaSettings`, `SlitscanSettings`,
52
+ `HybridSettings`), each with co-located `DEFAULT_*_SETTINGS`. Sub-trees
53
+ group related knobs: `stitcher` / `frameSelection.flow` (panorama);
54
+ `painting` / `registration.ncc1d` / `registration.ncc2d.emaSmoothing` /
55
+ `registration.ncc2d.panAxisLock` / `plane` / `advanced` (slitscan).
56
+ - **New file:** `src/camera/PanoramaSettingsBridge.ts` — three pure
57
+ adapter functions (`panoramaSettingsToNativeConfig`,
58
+ `slitscanSettingsToNativeConfig`, `hybridSettingsToNativeConfig`)
59
+ that translate the typed JS tree → the flat
60
+ `Record<string, primitive>` the native bridges consume. Handles
61
+ presence-as-enable (`ncc1d` defined ⇒ `enable1dNcc: true` on the
62
+ wire) and source-conditional plane optionals.
63
+ - **New file:** `src/camera/buildPanoramaInitialSettings.ts` — pure
64
+ helper that translates `<Camera>`'s `default*` props into the
65
+ initial `PanoramaSettings` snapshot. Takes the device's low-mem
66
+ classification as an argument so the function stays pure and
67
+ testable.
68
+ - **Rewritten:** `src/camera/PanoramaSettingsModal.tsx` — now consumes
69
+ the new `PanoramaSettings` shape. UI sections mirror the type tree
70
+ (Capture source, Debug, Stitcher accordion, Frame Selection
71
+ accordion with nested Flow tunables). ~600 LOC smaller than v0.3
72
+ because dead slit-scan / hybrid / video-recording fields are gone.
73
+ - **Rewired:** `src/camera/Camera.tsx` — settings state uses the new
74
+ type; `incremental.start({ config })` now passes
75
+ `panoramaSettingsToNativeConfig(settings)` instead of an inline flat
76
+ dict. IMU translation gate reads
77
+ `settings.frameSelection.flow?.maxTranslationCm`. Debug overlay
78
+ reads `settings.frameSelection.mode` + `settings.stitcher.stitchMode`.
79
+ - **Updated:** `src/index.ts` — exports the new types + adapters; drops
80
+ the deleted v0.3 type.
81
+ - **Test infra:** added `jest` + `ts-jest` + `@types/jest` devDeps; new
82
+ `jest.config.js`, `tsconfig.test.json`, `tsconfig.build.json` (the
83
+ latter excludes `__tests__/` from the shipped `dist/`). 19 tests
84
+ across two suites cover the bridge round-trips, presence-as-enable
85
+ cases, plane-source variants, and prop→settings-tree translation.
86
+
87
+ #### Migration table — v0.3 flat → v0.4 hierarchical
88
+
89
+ For `<Camera>`-consuming hosts (the only public path that took
90
+ `PanoramaSettings` in v0.3):
91
+
92
+ | v0.3 field | v0.4 path |
93
+ |----------------------------------|-------------------------------------------------|
94
+ | `captureSource` | `captureSource` (unchanged) |
95
+ | `debug` | `debug` (unchanged) |
96
+ | `stitchMode` | `stitcher.stitchMode` |
97
+ | `warperType` | `stitcher.warperType` |
98
+ | `blenderType` | `stitcher.blenderType` |
99
+ | `seamFinderType` | `stitcher.seamFinderType` |
100
+ | `enableMaxInscribedRectCrop` | `stitcher.enableMaxInscribedRectCrop` |
101
+ | `frameSelectionMode` | `frameSelection.mode` |
102
+ | `keyframeMaxCount` | `frameSelection.maxKeyframes` |
103
+ | `keyframeOverlapThreshold` | `frameSelection.overlapThreshold` |
104
+ | `flowNoveltyPercentile` | `frameSelection.flow.noveltyPercentile` |
105
+ | `flowEvalEveryNFrames` | `frameSelection.flow.evalEveryNFrames` |
106
+ | `flowMaxTranslationCm` | `frameSelection.flow.maxTranslationCm` |
107
+ | `flowMaxCorners` | `frameSelection.flow.maxCorners` |
108
+ | `flowQualityLevel` | `frameSelection.flow.qualityLevel` |
109
+ | `flowMinDistance` | `frameSelection.flow.minDistance` |
110
+
111
+ #### Deleted from the public type surface
112
+
113
+ These fields were consumed only by slit-scan or hybrid engines (or
114
+ not consumed at all per the audit) and were dead surface on
115
+ `<Camera>`'s batch-keyframe path:
116
+
117
+ - `incrementalEngine` — `<Camera>` always uses `batch-keyframe`; the
118
+ knob never reached this component. Hosts that want slit-scan or
119
+ hybrid build their own capture flow on `incremental.start()` and
120
+ pass `SlitscanSettings` / `HybridSettings` instead.
121
+ - `useARPreview` — superseded by `captureSource` ('ar' / 'non-ar').
122
+ - `useDetectedPlane` — superseded by `SlitscanSettings.plane.source`.
123
+ - `planeSource`, `virtualPlaneDepthMeters`, `arkitPlaneAlignmentThreshold`,
124
+ `planeProjectionStyle` — slit-scan only; on `SlitscanSettings.plane.*`.
125
+ - `slitWidthFraction`, `sliverPosition`, `firstFrameFullFrame`,
126
+ `paintMode` — slit-scan only; on `SlitscanSettings.painting.*`.
127
+ - `acceptGate`, `enableTriangulation`, `enableTriAccumulator`,
128
+ `enable2dNcc`, `enableRansacHomography`, `nccSearchRadius1d`,
129
+ `nccSearchMargin2d`, `nccConfidenceThreshold2d`,
130
+ `enableNcc2dEmaSmoothing`, `ncc2dEmaAlpha`,
131
+ `enableNcc2dPanAxisLock`, `ncc2dCrossAxisLockPx` — slit-scan only;
132
+ on `SlitscanSettings.registration.*`.
133
+ - `hybridProjection` — hybrid only; on `HybridSettings.projection`.
134
+ - `maxRecordingMs`, `framesPerSecond`, `minFrames`, `maxFrames`,
135
+ `quality` — historical video-recording fallback fields with no
136
+ consumer on `<Camera>`'s batch-keyframe path.
137
+
138
+ #### Latent v0.3 bug fixed in passing
139
+
140
+ The v0.3 `<Camera>` accepted a `defaultCaptureSource` prop but the
141
+ internal `buildInitialSettings` function never copied it into
142
+ `settings.captureSource` — only into `arPreference` state. The
143
+ discrepancy meant the wire dict sent to native always reported
144
+ `captureSource: 'ar'` even when the operator's effective source was
145
+ `'non-ar'`, which silently disabled Android's `disableAngularFallback`
146
+ opt-out (audit fix F1). v0.4's `extractPanoramaOverrides` +
147
+ `buildPanoramaInitialSettings` route the prop through correctly.
148
+ Hosts using `defaultCaptureSource="non-ar"` will see native receive
149
+ the matching value for the first time.
150
+
151
+ #### Known limitation — modal Capture-source field vs. AR toggle
152
+
153
+ The on-screen AR toggle button at the bottom of `<Camera>` updates
154
+ `arPreference` state (and through it `effectiveCaptureSource`),
155
+ which decides which preview component mounts. The Capture-source
156
+ segmented control inside the settings modal updates
157
+ `settings.captureSource`, which only affects what's reported to the
158
+ native engine via `panoramaSettingsToNativeConfig` (gates Android's
159
+ angular-fallback opt-out per audit fix F1). These two values can
160
+ drift if the operator toggles the AR button without re-opening
161
+ settings, OR flips the modal field without touching the AR button.
162
+ The on-screen toggle is the canonical UI affordance for the live
163
+ preview path; the modal field is best thought of as a tester escape
164
+ hatch for the wire-format consequence. A future cleanup is to make
165
+ both update the same source of truth — out of scope for v0.4.
166
+
167
+ #### Migration example
168
+
169
+ ```ts
170
+ // Before (v0.3)
171
+ const settings: PanoramaSettings = {
172
+ captureSource: 'ar',
173
+ stitchMode: 'auto',
174
+ blenderType: 'multiband',
175
+ flowMaxTranslationCm: 50,
176
+ flowNoveltyPercentile: 0.85,
177
+ keyframeMaxCount: 6,
178
+ frameSelectionMode: 'flow-based',
179
+ // … 40+ more fields
180
+ };
181
+
182
+ // After (v0.4)
183
+ const settings: PanoramaSettings = {
184
+ captureSource: 'ar',
185
+ debug: false,
186
+ stitcher: {
187
+ stitchMode: 'auto',
188
+ warperType: 'plane',
189
+ blenderType: 'multiband',
190
+ seamFinderType: 'graphcut',
191
+ enableMaxInscribedRectCrop: false,
192
+ },
193
+ frameSelection: {
194
+ mode: 'flow-based',
195
+ maxKeyframes: 6,
196
+ overlapThreshold: 0.20,
197
+ flow: {
198
+ noveltyPercentile: 0.85,
199
+ evalEveryNFrames: 5,
200
+ maxTranslationCm: 50,
201
+ maxCorners: 150,
202
+ qualityLevel: 0.01,
203
+ minDistance: 10,
204
+ },
205
+ },
206
+ };
207
+
208
+ // Or just use the default:
209
+ import { DEFAULT_PANORAMA_SETTINGS } from 'react-native-image-stitcher';
210
+ const settings = { ...DEFAULT_PANORAMA_SETTINGS, captureSource: 'non-ar' };
211
+ ```
212
+
213
+
214
+ ## [0.3.0] — 2026-05-23
215
+
216
+ > [!IMPORTANT]
217
+ > **v0.3.0 is the audit-follow-up release.** After v0.2.x we ran an
218
+ > exhaustive PanoramaSettings ground-truth audit and shipped the
219
+ > v0.3-pixel-data work alongside ~15 follow-up correctness fixes,
220
+ > two crash fixes, a stitcher mode-fallback retry, and the
221
+ > RetaiLens-parity debug UI port. Detailed entries below.
222
+ >
223
+ > **Behaviour changes**
224
+ > - Android AR mode + both platforms' non-AR mode now actually run
225
+ > the Flow strategy (sparse optical-flow novelty) end-to-end.
226
+ > Pre-0.3 they silently fell back to Pose strategy because no
227
+ > pixel data was supplied — hosts who tuned
228
+ > `keyframeOverlapThreshold` on those paths were tuning a
229
+ > different algorithm than is now active.
230
+ > - `stitchMode: 'auto'` now resolves correctly on iOS (was
231
+ > silently hardcoded to Panorama) and uses IMU-measured
232
+ > translation in non-AR mode.
233
+ > - `frameSelectionMode` is now honoured on both platforms;
234
+ > previously hardcoded to `'flow-based'`.
235
+ > - Mode-fallback retry: if the resolved cv::Stitcher mode fails
236
+ > with degenerate camera params, the stitcher automatically
237
+ > retries with the opposite mode before giving up.
238
+
239
+ ### Added
240
+
241
+ - **Pixel-aware Flow strategy across all four capture paths** —
242
+ iOS AR, iOS non-AR, Android AR, Android non-AR. The C++
243
+ KeyframeGate's `evaluateWithFrame` overload is now reached from
244
+ every entry point with real grayscale pixel data (Y plane bytes
245
+ on AR paths, decoded JPEG luma on non-AR paths).
246
+ - **Debug UI suite** (gated by `settings.debug`):
247
+ `CaptureMemoryPill` (top-right), `CaptureKeyframePill` (top-center),
248
+ `CaptureOrientationPill` (top-left), `CaptureStitchStatsToast` +
249
+ `useStitchStatsToast` hook, plus a detailed metrics block
250
+ (`CaptureDebugOverlay`). All exported individually for Layer 2
251
+ hosts to compose their own debug surface.
252
+ - **`stitchModeResolved`** in `IncrementalFinalizeResult` +
253
+ `CameraCaptureResult.panorama` — surfaces which cv::Stitcher
254
+ pipeline actually ran (`panorama` / `scans`), useful for
255
+ displaying on the output preview.
256
+
257
+ ### Fixed
258
+
259
+ - **F1 — Android `disableAngularFallback` was always false.**
260
+ The non-AR opt-out tested `captureSource ∈ {"wide", "ultrawide"}`
261
+ against a JS API that has been sending `"ar"` / `"non-ar"` since
262
+ v0.2. String mismatch silently nullified the opt-out → gyro
263
+ drift accepted near-identical frames → `STITCH_CAMERA_PARAMS_FAIL
264
+ — warpRoi too large (43039×55525)` on shelf-scan captures.
265
+ - **F1b — iOS `disableAngularFallback` wasn't wired at all.** The
266
+ C++ setter existed but the Swift facade had no property, the
267
+ Obj-C++ bridge had no method, and IncrementalStitcher never
268
+ called it. Same crash class as F1, just hidden until now.
269
+ - **F2 — iOS `stitchMode` was hardcoded to Panorama.** Now reads
270
+ the JS setting and resolves 'auto' via translation/rotation
271
+ magnitude-ratio (port of Android's resolveStitchModeAuto).
272
+ - **F2b — Auto-resolver uses IMU translation in non-AR mode.** The
273
+ JS-driver path doesn't carry pose tx/ty/tz, so the pose-only
274
+ resolver always picked 'panorama' even for shelf scans. Now
275
+ folds the IMU translation gate's measured displacement into the
276
+ resolver (`tMeters = max(tPose, tImu)`).
277
+ - **F2c — Cross-capture IMU drift bias.** Pre-fix the gravityX IIR
278
+ estimate was preserved across capture boundaries; if the phone
279
+ was at a different orientation between captures, the stale
280
+ estimate biased the linear-acceleration calculation for the
281
+ ~200 ms IIR convergence window, integrating into posX and
282
+ compounding per-capture. Now reseed gravityX on every
283
+ subscription start (= every capture).
284
+ - **F2d — IMU gate auto-rearms on every budget interval.** Pre-fix
285
+ the gate latched after the first `markNextFrameAsLastKeyframe`
286
+ fire and never re-triggered. Now resets posX + velX + fired
287
+ internally so it fires every `flowMaxTranslationCm` of measured
288
+ translation.
289
+ - **F2e — Android batch-keyframe now emits overlap %.** Pre-fix
290
+ `overlapPercent` was hardcoded to -1 in the accept emit, and
291
+ reject events emitted nothing at all — debug overlay was frozen
292
+ between accepts. Now reflects the gate's actual newContent
293
+ fraction on both accepts and rejects.
294
+ - **F2f — IMU delta resets on ANY frame accept.** Pre-fix the
295
+ `imuΔ` debug indicator only reset when the IMU gate itself
296
+ fired; a flow-novelty accept left posX ticking up indefinitely.
297
+ Now Camera.tsx watches `acceptedCount` and resets the gate on
298
+ every increment. A separate `totalAbsMetres` accumulator banks
299
+ the magnitude across resets so the finalize-time auto-resolver
300
+ still sees full translation history.
301
+ - **F4 — Camera.tsx now passes the four flow-tunable fields and
302
+ `captureSource`.** Pre-fix `flowMaxCorners`, `flowQualityLevel`,
303
+ `flowMinDistance`, `enableMaxInscribedRectCrop`, and
304
+ `captureSource` were silently dropped between the modal and the
305
+ native bridge. Now all five reach the engine.
306
+ - **F5 — Android KeyframeGate gained the missing Flow-tunable
307
+ surface.** Added Kotlin facade properties + JNI thunks for
308
+ `setFlowMaxCorners`, `setFlowQualityLevel`, `setFlowMinDistance`,
309
+ `setStrategy`. Android now mirrors iOS for the gate's full
310
+ knob set. Added the eval-throttle (`flowEvalEveryNFrames`)
311
+ to the AR ingest path.
312
+ - **F6 — `frameSelectionMode` is no longer hardcoded to
313
+ 'flow-based'.** Camera.tsx now passes the JS setting through;
314
+ both platforms honour `time-based` (gate disabled),
315
+ `pose-based` (Pose strategy), and `flow-based` (Flow strategy).
316
+ - **F7 — README documented `defaultFlowMaxTranslationCm` as 8
317
+ cm.** Actual default is 50 cm; 6× off.
318
+ - **ARCore Session.close() on AR-off** (Android-only crash fix).
319
+ Pre-fix `RNSARSession.stop()` and `stopForView()` called
320
+ `Session.pause()` then nulled the session reference. ARCore's
321
+ `pause()` only stops frame production — its native worker
322
+ threads stay alive. Orphaned, those threads kept running and
323
+ crashed under memory pressure with SIGSEGV in
324
+ `tango_pool_lp4`/`libarcore_c.so` (tombstone-confirmed). Now
325
+ calls `pause()` then `close()` (ARCore's documented full
326
+ teardown), and the camera-view drops its own stale reference.
327
+ - **Stitcher mode-fallback retry.** When the configured stitchMode
328
+ fails with degenerate camera params, the stitcher now
329
+ automatically retries with the opposite mode before giving up
330
+ (panorama → scans or scans → panorama). Result type carries
331
+ `stitchModeUsed` so callers can see which mode succeeded. The
332
+ warpRoi-too-large error message now includes the configured
333
+ mode + frame index for diagnostics.
334
+ - **Thumbnail strip first-frame race.** Pre-fix the `useEffect`
335
+ that cleared `batchKeyframeThumbnails` on statusPhase change
336
+ could race ahead of the JS subscriber: the AR camera's GL
337
+ thread could emit an ACCEPT during handleHoldStart's
338
+ `await incremental.start(...)` window, the subscriber would
339
+ add frame 0 to thumbnails, THEN React's queued statusPhase
340
+ effect would wipe the array — frame 0 was missing from the
341
+ strip. Fixed by moving the reset synchronously to the top of
342
+ handleHoldStart, before any await.
343
+
344
+ ### Audit ground-truth findings (no code change, doc-only)
345
+
346
+ - **F1 — Android `disableAngularFallback` was always false.** The
347
+ Android JNI's non-AR opt-out for the angular-delta gate fallback
348
+ tested `captureSource ∈ {"wide", "ultrawide"}` against a JS API
349
+ that has been sending `"ar"` / `"non-ar"` since 2026-05-14. The
350
+ string mismatch silently nullified the opt-out for the entire
351
+ Android non-AR path, letting gyro drift accumulate into the
352
+ integrated yaw/pitch and produce near-identical "accepted"
353
+ frames — which is what blew up cv::Stitcher with the "warpRoi too
354
+ large (43039×55525) — estimator produced degenerate camera params"
355
+ error on shelf-scan captures. Fix: read `"non-ar"`.
356
+ - **F2 — iOS `stitchMode` setting is now honoured end-to-end.** Pre-
357
+ audit, `OpenCVStitcher.mm:436` hardcoded `cv::Stitcher::PANORAMA`
358
+ regardless of the JS setting, so operators picking `'scans'` or
359
+ `'auto'` from the modal saw no effect on iOS. iOS now reads
360
+ `configOverrides["stitchMode"]`, tracks first + last accepted
361
+ keyframe poses, and implements `resolveStitchModeAuto` (port of
362
+ Android's translation/rotation magnitude-ratio heuristic) at
363
+ finalize time. Both platforms now resolve `'auto'` identically.
364
+ - **F4 — Camera.tsx now passes settings the modal exposed but Camera
365
+ silently dropped.** Pre-audit, the `config` block passed to
366
+ `incremental.start()` omitted four fields that iOS native already
367
+ read: `flowMaxCorners`, `flowQualityLevel`, `flowMinDistance`,
368
+ `enableMaxInscribedRectCrop`. Modal sliders for these were
369
+ silent no-ops on every platform. Now wired. Also added
370
+ `captureSource` to the config so F1's Android opt-out has
371
+ something to read.
372
+ - **F5 — Android KeyframeGate now exposes the full Flow tunable
373
+ surface.** Pre-audit, the Android KeyframeGate facade lacked
374
+ Kotlin properties + JNI thunks for `setFlowMaxCorners` /
375
+ `setFlowQualityLevel` / `setFlowMinDistance` / `setStrategy`,
376
+ even though the underlying C++ gate has had them since 0.2.0.
377
+ Added the missing JNI bindings + Kotlin facade fields. Android
378
+ IncrementalStitcher now reads `flowMaxCorners`, `flowQualityLevel`,
379
+ `flowMinDistance`, `flowEvalEveryNFrames`, and `frameSelectionMode`
380
+ from configOverrides with clamp ranges matching iOS.
381
+ - **F6 — Camera.tsx no longer hardcodes `frameSelectionMode`.** Pre-
382
+ audit, line 835 hardcoded `'flow-based'`, so the modal's
383
+ `time-based` / `pose-based` / `flow-based` toggle had no runtime
384
+ effect. Now passes `settings.frameSelectionMode` through. Both
385
+ platforms honour the setting: `time-based` disables the gate
386
+ (passthrough), `pose-based` enables Pose strategy, `flow-based`
387
+ enables Flow strategy. Android additionally now applies the
388
+ eval-throttle (`flowEvalEveryNFrames`) to the AR ingest path,
389
+ matching iOS' `IncrementalStitcher.swift:2459-2471` behaviour.
390
+ - **F7 — README documented `defaultFlowMaxTranslationCm` default as
391
+ `8`.** Actual `DEFAULT_PANORAMA_SETTINGS.flowMaxTranslationCm` is
392
+ `50`; 6× off. Corrected.
393
+
394
+ ### Audit ground-truth findings (doc-only)
395
+
396
+ The full audit traced every `PanoramaSettings` field through Camera.tsx,
397
+ the iOS bridge (`IncrementalStitcher.swift::applyConfigOverrides` and
398
+ the cv::Stitcher path), the Android bridge
399
+ (`IncrementalStitcher.kt::start`), the C++ gate (`cpp/keyframe_gate.cpp`),
400
+ and the live-engine config type (`RLISStitcherConfig`). Conclusions:
401
+
402
+ - Batch-keyframe and the live engines (hybrid + slit-scan) share
403
+ **zero settings**. All RLISStitcherConfig fields (NCC, plane
404
+ projection, paint mode, slit-scan painting) flow only through
405
+ Layer 2 entry points (`incremental.start({ engine: 'slitscan-…' })`),
406
+ never through `<Camera>` (which hardcodes `engine: 'batch-keyframe'`).
407
+ - ~10 fields in `PanoramaSettings` are confirmed dead (no native
408
+ consumer at all): `useARPreview`, `incrementalEngine`,
409
+ `slitWidthFraction`, `acceptGate`, `maxRecordingMs`,
410
+ `framesPerSecond`, `minFrames`, `maxFrames`, `quality`, and the
411
+ legacy `useDetectedPlane` alias. These are scheduled for removal
412
+ in v0.4.0 as part of the engine-discriminated typed-settings
413
+ rewrite.
414
+
415
+ ## [0.3.0-pre-audit] — 2026-05-21
416
+
417
+ > [!IMPORTANT]
418
+ > **Behaviour change on Android AR mode and on both platforms' non-AR
419
+ > mode.** Keyframe selection now actually runs the **Flow strategy**
420
+ > (sparse optical-flow novelty) on these paths, where pre-0.3 the
421
+ > C++ KeyframeGate silently fell back to the Pose strategy
422
+ > (angular-delta) because no pixel data was supplied. Hosts that
423
+ > tuned `keyframeOverlapThreshold` on these paths were tuning a
424
+ > different algorithm than is now active — see the migration note
425
+ > below before re-validating capture quality. iOS AR mode is
426
+ > unchanged (already ran Flow with pixel data via the AR delegate).
427
+
428
+ ### Fixed
429
+
430
+ - **[#9](https://github.com/bhargavkanda/react-native-image-stitcher/issues/9): Android AR mode — first keyframe thumbnail no longer delayed
431
+ several hundred milliseconds.** Pre-0.3 the AR ingest pipeline
432
+ encoded every ARCore frame to JPEG and wrote it to disk on the
433
+ GL render thread (~25 ms per frame at ~60 Hz) regardless of
434
+ whether the gate would accept it. Then the gate ran a pose-only
435
+ evaluation (no pixel data) which silently fell back to the
436
+ stricter Pose strategy, masking the result by force-accepting via
437
+ the IMU translation gate. Net effect: noticeable lag before
438
+ frame 1 thumbnail rendered, and frame 1 / frame 2 spacing
439
+ visually too large.
440
+ - v0.3 rewires the AR ingest path to extract just the **Y plane
441
+ bytes** from the ARCore camera image (zero-copy via
442
+ DirectByteBuffer → JVM byte[] + JNI `GetPrimitiveArrayCritical`)
443
+ and feeds them directly to the C++ gate's existing
444
+ `evaluateWithFrame` overload. Per-frame cost on the GL render
445
+ thread drops from ~25-40 ms to ~2-5 ms for rejected frames.
446
+ - JPEG encode + disk write is **deferred to only accepted frames**
447
+ (typically 3-6 per capture) via an `onAccept` lambda the gate
448
+ invokes if-and-only-if it keeps the frame. Single disk write
449
+ per accepted keyframe (pre-0.3 was: encode-then-copy = two
450
+ writes).
451
+ - Gate now runs Flow strategy with real pixel content — feature-
452
+ tracking-based novelty, not the strict angular-delta proxy.
453
+ - **iOS non-AR + Android non-AR Flow strategy regression** —
454
+ related to #9 but not user-reported. Both non-AR paths previously
455
+ called `evaluate(pose, plane: nil)` with no pixel data, which
456
+ silently fell back to Pose strategy on both platforms. v0.3
457
+ decodes the JPEG snapshot to grayscale before the gate call so
458
+ Flow strategy runs:
459
+ - iOS: `CGImageSource → CGContext` into a single-channel
460
+ `CVPixelBuffer` (`kCVPixelFormatType_OneComponent8`). The
461
+ `KeyframeGateBridge.mm` got OneComponent8 case-handling
462
+ (parallel to the existing NV12 / BGRA cases). ~10-20 ms per
463
+ snapshot on iPhone 13/16 Pro.
464
+ - Android: `Imgcodecs.imread(path, IMREAD_GRAYSCALE)` decodes
465
+ the JPEG straight to a CV_8UC1 Mat which we marshal into a
466
+ ByteArray for the new `nativeEvaluateWithFrame` JNI thunk.
467
+ ~10-20 ms per snapshot on Galaxy A35.
468
+
469
+ ### Added
470
+
471
+ - **`KeyframeGate.evaluateWithFrame(pose, plane, grayData, w, h, stride)`**
472
+ (Kotlin) — pixel-aware Flow-strategy gate-evaluate entry point,
473
+ parity with the existing iOS `KeyframeGateBridge.evaluatePixelBuffer:…`.
474
+ - **`nativeEvaluateWithFrame`** JNI thunk in `keyframe_gate_jni.cpp`.
475
+ Uses `GetPrimitiveArrayCritical` for zero-copy access to the
476
+ JVM-side byte[] during the gate evaluate.
477
+ - **`kCVPixelFormatType_OneComponent8` handling** in iOS
478
+ `KeyframeGateBridge.mm` — base address is read directly as the
479
+ Y plane with no conversion cost.
480
+
481
+ ### Changed
482
+
483
+ - **`IncrementalStitcher.ingestFromARCameraView` signature** (Android,
484
+ internal):
485
+ - **Removed**: `path: String` parameter. AR camera view no longer
486
+ encodes a JPEG to feed this method — it hands over Y-plane bytes
487
+ instead.
488
+ - **Added**: `grayData: ByteArray, grayWidth: Int, grayHeight: Int,
489
+ grayStride: Int, onAccept: (targetPath: String) -> Boolean`.
490
+ The lambda is invoked only on gate-accept and is expected to
491
+ write a JPEG of the current camera image to the supplied target
492
+ path. Returns true on success.
493
+ - `RNSARCameraView.forwardToIncremental` updated accordingly.
494
+ - **`RNSARCameraView.postFrameToEngine` removed.** The thin wrapper
495
+ was only used to wrap the old positional call to
496
+ `ingestFromARCameraView`; the new lambda-based call shape is
497
+ inline in `forwardToIncremental`.
498
+
499
+ ### Migration from 0.2.x
500
+
501
+ **Most consumers**: no code change required. The public JS API
502
+ (`<Camera>`, `useCapture`, `useIMUTranslationGate`,
503
+ `useDeviceOrientation`, everything) is byte-identical to 0.2.1.
504
+
505
+ **Hosts that tuned `keyframeOverlapThreshold` against Android AR or
506
+ either non-AR path**: the threshold now controls **Flow novelty
507
+ percentile** instead of **Pose angular delta**. Same setting, very
508
+ different metric — re-tune against your typical captures. The
509
+ default (`0.20`) was chosen to roughly match the pre-0.3 visible
510
+ behaviour; most hosts shouldn't need to change anything, but
511
+ quality-sensitive hosts should re-validate before shipping.
512
+
513
+ **Hosts that observed the Android-AR first-frame delay**: the bug
514
+ is fixed — first thumbnail should render within ~50 ms of shutter
515
+ hold (was ~200+ ms).
516
+
517
+ ### Deferred to v0.4 ([#11](https://github.com/bhargavkanda/react-native-image-stitcher/issues/11))
518
+
519
+ Non-AR capture currently still goes through vision-camera's
520
+ `takeSnapshot()` API at ~4 FPS with a per-snapshot JPEG-encode +
521
+ disk-write + decode-to-grayscale round-trip. v0.4 will migrate
522
+ non-AR to vision-camera's Frame Processor API: raw pixel data
523
+ direct from the camera, no JPEG, no disk, full camera frame rate.
524
+ At that point the JPEG-decode-to-grayscale workaround added in
525
+ v0.3's iOS/Android non-AR paths becomes redundant and will be
526
+ removed. See issue #11 for the full scope.
527
+
19
528
  ## [0.2.1] — 2026-05-21
20
529
 
21
530
  ### Changed
@@ -357,7 +866,8 @@ Native module names also changed:
357
866
  - iOS pod: `RetaiLensCaptureSDK` → `RNImageStitcher`
358
867
  - iOS xcframework: shipped as `opencv2.xcframework` (linked from `RNImageStitcher.podspec`)
359
868
 
360
- [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.2.1...HEAD
869
+ [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.3.0...HEAD
870
+ [0.3.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.2.1...v0.3.0
361
871
  [0.2.1]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.2.0...v0.2.1
362
872
  [0.2.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.1.3...v0.2.0
363
873
  [0.1.3]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.1.2...v0.1.3
package/README.md CHANGED
@@ -118,7 +118,7 @@ See `src/camera/Camera.tsx` for the full TSDoc. Highlights:
118
118
  | `defaultWarper` | `'plane'` | `'plane'`, `'cylindrical'`, `'spherical'` |
119
119
  | `defaultFlowNoveltyPercentile` | `0.85` | Range 0.50 – 0.99 |
120
120
  | `defaultFlowEvalEveryNFrames` | `5` | Range 1 – 10 |
121
- | `defaultFlowMaxTranslationCm` | `8` | 0 = disabled |
121
+ | `defaultFlowMaxTranslationCm` | `50` | 0 = disabled |
122
122
  | `defaultKeyframeMaxCount` | `6` | Range 3 – 10 |
123
123
  | `defaultKeyframeOverlapThreshold` | `0.20` | Range 0.20 – 0.60 |
124
124