react-native-image-stitcher 0.2.0 → 0.3.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 (49) hide show
  1. package/CHANGELOG.md +363 -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 +118 -8
  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/PanoramaSettingsModal.d.ts +6 -5
  23. package/dist/index.d.ts +10 -0
  24. package/dist/index.js +15 -1
  25. package/dist/sensors/useIMUTranslationGate.d.ts +26 -0
  26. package/dist/sensors/useIMUTranslationGate.js +83 -1
  27. package/dist/stitching/incremental.d.ts +25 -0
  28. package/dist/stitching/useIncrementalStitcher.d.ts +12 -1
  29. package/dist/stitching/useIncrementalStitcher.js +7 -1
  30. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +321 -7
  31. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +8 -0
  32. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +12 -0
  33. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +13 -0
  34. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +15 -0
  35. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +1 -0
  36. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +17 -4
  37. package/ios/Sources/RNImageStitcher/Stitcher.swift +6 -1
  38. package/package.json +1 -1
  39. package/src/camera/Camera.tsx +165 -7
  40. package/src/camera/CaptureDebugOverlay.tsx +180 -0
  41. package/src/camera/CaptureKeyframePill.tsx +77 -0
  42. package/src/camera/CaptureMemoryPill.tsx +96 -0
  43. package/src/camera/CaptureOrientationPill.tsx +57 -0
  44. package/src/camera/CaptureStitchStatsToast.tsx +155 -0
  45. package/src/camera/PanoramaSettingsModal.tsx +6 -5
  46. package/src/index.ts +19 -0
  47. package/src/sensors/useIMUTranslationGate.ts +112 -1
  48. package/src/stitching/incremental.ts +25 -0
  49. package/src/stitching/useIncrementalStitcher.ts +18 -0
package/CHANGELOG.md CHANGED
@@ -16,6 +16,366 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.3.0] — 2026-05-23
20
+
21
+ > [!IMPORTANT]
22
+ > **v0.3.0 is the audit-follow-up release.** After v0.2.x we ran an
23
+ > exhaustive PanoramaSettings ground-truth audit and shipped the
24
+ > v0.3-pixel-data work alongside ~15 follow-up correctness fixes,
25
+ > two crash fixes, a stitcher mode-fallback retry, and the
26
+ > RetaiLens-parity debug UI port. Detailed entries below.
27
+ >
28
+ > **Behaviour changes**
29
+ > - Android AR mode + both platforms' non-AR mode now actually run
30
+ > the Flow strategy (sparse optical-flow novelty) end-to-end.
31
+ > Pre-0.3 they silently fell back to Pose strategy because no
32
+ > pixel data was supplied — hosts who tuned
33
+ > `keyframeOverlapThreshold` on those paths were tuning a
34
+ > different algorithm than is now active.
35
+ > - `stitchMode: 'auto'` now resolves correctly on iOS (was
36
+ > silently hardcoded to Panorama) and uses IMU-measured
37
+ > translation in non-AR mode.
38
+ > - `frameSelectionMode` is now honoured on both platforms;
39
+ > previously hardcoded to `'flow-based'`.
40
+ > - Mode-fallback retry: if the resolved cv::Stitcher mode fails
41
+ > with degenerate camera params, the stitcher automatically
42
+ > retries with the opposite mode before giving up.
43
+
44
+ ### Added
45
+
46
+ - **Pixel-aware Flow strategy across all four capture paths** —
47
+ iOS AR, iOS non-AR, Android AR, Android non-AR. The C++
48
+ KeyframeGate's `evaluateWithFrame` overload is now reached from
49
+ every entry point with real grayscale pixel data (Y plane bytes
50
+ on AR paths, decoded JPEG luma on non-AR paths).
51
+ - **Debug UI suite** (gated by `settings.debug`):
52
+ `CaptureMemoryPill` (top-right), `CaptureKeyframePill` (top-center),
53
+ `CaptureOrientationPill` (top-left), `CaptureStitchStatsToast` +
54
+ `useStitchStatsToast` hook, plus a detailed metrics block
55
+ (`CaptureDebugOverlay`). All exported individually for Layer 2
56
+ hosts to compose their own debug surface.
57
+ - **`stitchModeResolved`** in `IncrementalFinalizeResult` +
58
+ `CameraCaptureResult.panorama` — surfaces which cv::Stitcher
59
+ pipeline actually ran (`panorama` / `scans`), useful for
60
+ displaying on the output preview.
61
+
62
+ ### Fixed
63
+
64
+ - **F1 — Android `disableAngularFallback` was always false.**
65
+ The non-AR opt-out tested `captureSource ∈ {"wide", "ultrawide"}`
66
+ against a JS API that has been sending `"ar"` / `"non-ar"` since
67
+ v0.2. String mismatch silently nullified the opt-out → gyro
68
+ drift accepted near-identical frames → `STITCH_CAMERA_PARAMS_FAIL
69
+ — warpRoi too large (43039×55525)` on shelf-scan captures.
70
+ - **F1b — iOS `disableAngularFallback` wasn't wired at all.** The
71
+ C++ setter existed but the Swift facade had no property, the
72
+ Obj-C++ bridge had no method, and IncrementalStitcher never
73
+ called it. Same crash class as F1, just hidden until now.
74
+ - **F2 — iOS `stitchMode` was hardcoded to Panorama.** Now reads
75
+ the JS setting and resolves 'auto' via translation/rotation
76
+ magnitude-ratio (port of Android's resolveStitchModeAuto).
77
+ - **F2b — Auto-resolver uses IMU translation in non-AR mode.** The
78
+ JS-driver path doesn't carry pose tx/ty/tz, so the pose-only
79
+ resolver always picked 'panorama' even for shelf scans. Now
80
+ folds the IMU translation gate's measured displacement into the
81
+ resolver (`tMeters = max(tPose, tImu)`).
82
+ - **F2c — Cross-capture IMU drift bias.** Pre-fix the gravityX IIR
83
+ estimate was preserved across capture boundaries; if the phone
84
+ was at a different orientation between captures, the stale
85
+ estimate biased the linear-acceleration calculation for the
86
+ ~200 ms IIR convergence window, integrating into posX and
87
+ compounding per-capture. Now reseed gravityX on every
88
+ subscription start (= every capture).
89
+ - **F2d — IMU gate auto-rearms on every budget interval.** Pre-fix
90
+ the gate latched after the first `markNextFrameAsLastKeyframe`
91
+ fire and never re-triggered. Now resets posX + velX + fired
92
+ internally so it fires every `flowMaxTranslationCm` of measured
93
+ translation.
94
+ - **F2e — Android batch-keyframe now emits overlap %.** Pre-fix
95
+ `overlapPercent` was hardcoded to -1 in the accept emit, and
96
+ reject events emitted nothing at all — debug overlay was frozen
97
+ between accepts. Now reflects the gate's actual newContent
98
+ fraction on both accepts and rejects.
99
+ - **F2f — IMU delta resets on ANY frame accept.** Pre-fix the
100
+ `imuΔ` debug indicator only reset when the IMU gate itself
101
+ fired; a flow-novelty accept left posX ticking up indefinitely.
102
+ Now Camera.tsx watches `acceptedCount` and resets the gate on
103
+ every increment. A separate `totalAbsMetres` accumulator banks
104
+ the magnitude across resets so the finalize-time auto-resolver
105
+ still sees full translation history.
106
+ - **F4 — Camera.tsx now passes the four flow-tunable fields and
107
+ `captureSource`.** Pre-fix `flowMaxCorners`, `flowQualityLevel`,
108
+ `flowMinDistance`, `enableMaxInscribedRectCrop`, and
109
+ `captureSource` were silently dropped between the modal and the
110
+ native bridge. Now all five reach the engine.
111
+ - **F5 — Android KeyframeGate gained the missing Flow-tunable
112
+ surface.** Added Kotlin facade properties + JNI thunks for
113
+ `setFlowMaxCorners`, `setFlowQualityLevel`, `setFlowMinDistance`,
114
+ `setStrategy`. Android now mirrors iOS for the gate's full
115
+ knob set. Added the eval-throttle (`flowEvalEveryNFrames`)
116
+ to the AR ingest path.
117
+ - **F6 — `frameSelectionMode` is no longer hardcoded to
118
+ 'flow-based'.** Camera.tsx now passes the JS setting through;
119
+ both platforms honour `time-based` (gate disabled),
120
+ `pose-based` (Pose strategy), and `flow-based` (Flow strategy).
121
+ - **F7 — README documented `defaultFlowMaxTranslationCm` as 8
122
+ cm.** Actual default is 50 cm; 6× off.
123
+ - **ARCore Session.close() on AR-off** (Android-only crash fix).
124
+ Pre-fix `RNSARSession.stop()` and `stopForView()` called
125
+ `Session.pause()` then nulled the session reference. ARCore's
126
+ `pause()` only stops frame production — its native worker
127
+ threads stay alive. Orphaned, those threads kept running and
128
+ crashed under memory pressure with SIGSEGV in
129
+ `tango_pool_lp4`/`libarcore_c.so` (tombstone-confirmed). Now
130
+ calls `pause()` then `close()` (ARCore's documented full
131
+ teardown), and the camera-view drops its own stale reference.
132
+ - **Stitcher mode-fallback retry.** When the configured stitchMode
133
+ fails with degenerate camera params, the stitcher now
134
+ automatically retries with the opposite mode before giving up
135
+ (panorama → scans or scans → panorama). Result type carries
136
+ `stitchModeUsed` so callers can see which mode succeeded. The
137
+ warpRoi-too-large error message now includes the configured
138
+ mode + frame index for diagnostics.
139
+ - **Thumbnail strip first-frame race.** Pre-fix the `useEffect`
140
+ that cleared `batchKeyframeThumbnails` on statusPhase change
141
+ could race ahead of the JS subscriber: the AR camera's GL
142
+ thread could emit an ACCEPT during handleHoldStart's
143
+ `await incremental.start(...)` window, the subscriber would
144
+ add frame 0 to thumbnails, THEN React's queued statusPhase
145
+ effect would wipe the array — frame 0 was missing from the
146
+ strip. Fixed by moving the reset synchronously to the top of
147
+ handleHoldStart, before any await.
148
+
149
+ ### Audit ground-truth findings (no code change, doc-only)
150
+
151
+ - **F1 — Android `disableAngularFallback` was always false.** The
152
+ Android JNI's non-AR opt-out for the angular-delta gate fallback
153
+ tested `captureSource ∈ {"wide", "ultrawide"}` against a JS API
154
+ that has been sending `"ar"` / `"non-ar"` since 2026-05-14. The
155
+ string mismatch silently nullified the opt-out for the entire
156
+ Android non-AR path, letting gyro drift accumulate into the
157
+ integrated yaw/pitch and produce near-identical "accepted"
158
+ frames — which is what blew up cv::Stitcher with the "warpRoi too
159
+ large (43039×55525) — estimator produced degenerate camera params"
160
+ error on shelf-scan captures. Fix: read `"non-ar"`.
161
+ - **F2 — iOS `stitchMode` setting is now honoured end-to-end.** Pre-
162
+ audit, `OpenCVStitcher.mm:436` hardcoded `cv::Stitcher::PANORAMA`
163
+ regardless of the JS setting, so operators picking `'scans'` or
164
+ `'auto'` from the modal saw no effect on iOS. iOS now reads
165
+ `configOverrides["stitchMode"]`, tracks first + last accepted
166
+ keyframe poses, and implements `resolveStitchModeAuto` (port of
167
+ Android's translation/rotation magnitude-ratio heuristic) at
168
+ finalize time. Both platforms now resolve `'auto'` identically.
169
+ - **F4 — Camera.tsx now passes settings the modal exposed but Camera
170
+ silently dropped.** Pre-audit, the `config` block passed to
171
+ `incremental.start()` omitted four fields that iOS native already
172
+ read: `flowMaxCorners`, `flowQualityLevel`, `flowMinDistance`,
173
+ `enableMaxInscribedRectCrop`. Modal sliders for these were
174
+ silent no-ops on every platform. Now wired. Also added
175
+ `captureSource` to the config so F1's Android opt-out has
176
+ something to read.
177
+ - **F5 — Android KeyframeGate now exposes the full Flow tunable
178
+ surface.** Pre-audit, the Android KeyframeGate facade lacked
179
+ Kotlin properties + JNI thunks for `setFlowMaxCorners` /
180
+ `setFlowQualityLevel` / `setFlowMinDistance` / `setStrategy`,
181
+ even though the underlying C++ gate has had them since 0.2.0.
182
+ Added the missing JNI bindings + Kotlin facade fields. Android
183
+ IncrementalStitcher now reads `flowMaxCorners`, `flowQualityLevel`,
184
+ `flowMinDistance`, `flowEvalEveryNFrames`, and `frameSelectionMode`
185
+ from configOverrides with clamp ranges matching iOS.
186
+ - **F6 — Camera.tsx no longer hardcodes `frameSelectionMode`.** Pre-
187
+ audit, line 835 hardcoded `'flow-based'`, so the modal's
188
+ `time-based` / `pose-based` / `flow-based` toggle had no runtime
189
+ effect. Now passes `settings.frameSelectionMode` through. Both
190
+ platforms honour the setting: `time-based` disables the gate
191
+ (passthrough), `pose-based` enables Pose strategy, `flow-based`
192
+ enables Flow strategy. Android additionally now applies the
193
+ eval-throttle (`flowEvalEveryNFrames`) to the AR ingest path,
194
+ matching iOS' `IncrementalStitcher.swift:2459-2471` behaviour.
195
+ - **F7 — README documented `defaultFlowMaxTranslationCm` default as
196
+ `8`.** Actual `DEFAULT_PANORAMA_SETTINGS.flowMaxTranslationCm` is
197
+ `50`; 6× off. Corrected.
198
+
199
+ ### Audit ground-truth findings (doc-only)
200
+
201
+ The full audit traced every `PanoramaSettings` field through Camera.tsx,
202
+ the iOS bridge (`IncrementalStitcher.swift::applyConfigOverrides` and
203
+ the cv::Stitcher path), the Android bridge
204
+ (`IncrementalStitcher.kt::start`), the C++ gate (`cpp/keyframe_gate.cpp`),
205
+ and the live-engine config type (`RLISStitcherConfig`). Conclusions:
206
+
207
+ - Batch-keyframe and the live engines (hybrid + slit-scan) share
208
+ **zero settings**. All RLISStitcherConfig fields (NCC, plane
209
+ projection, paint mode, slit-scan painting) flow only through
210
+ Layer 2 entry points (`incremental.start({ engine: 'slitscan-…' })`),
211
+ never through `<Camera>` (which hardcodes `engine: 'batch-keyframe'`).
212
+ - ~10 fields in `PanoramaSettings` are confirmed dead (no native
213
+ consumer at all): `useARPreview`, `incrementalEngine`,
214
+ `slitWidthFraction`, `acceptGate`, `maxRecordingMs`,
215
+ `framesPerSecond`, `minFrames`, `maxFrames`, `quality`, and the
216
+ legacy `useDetectedPlane` alias. These are scheduled for removal
217
+ in v0.4.0 as part of the engine-discriminated typed-settings
218
+ rewrite.
219
+
220
+ ## [0.3.0-pre-audit] — 2026-05-21
221
+
222
+ > [!IMPORTANT]
223
+ > **Behaviour change on Android AR mode and on both platforms' non-AR
224
+ > mode.** Keyframe selection now actually runs the **Flow strategy**
225
+ > (sparse optical-flow novelty) on these paths, where pre-0.3 the
226
+ > C++ KeyframeGate silently fell back to the Pose strategy
227
+ > (angular-delta) because no pixel data was supplied. Hosts that
228
+ > tuned `keyframeOverlapThreshold` on these paths were tuning a
229
+ > different algorithm than is now active — see the migration note
230
+ > below before re-validating capture quality. iOS AR mode is
231
+ > unchanged (already ran Flow with pixel data via the AR delegate).
232
+
233
+ ### Fixed
234
+
235
+ - **[#9](https://github.com/bhargavkanda/react-native-image-stitcher/issues/9): Android AR mode — first keyframe thumbnail no longer delayed
236
+ several hundred milliseconds.** Pre-0.3 the AR ingest pipeline
237
+ encoded every ARCore frame to JPEG and wrote it to disk on the
238
+ GL render thread (~25 ms per frame at ~60 Hz) regardless of
239
+ whether the gate would accept it. Then the gate ran a pose-only
240
+ evaluation (no pixel data) which silently fell back to the
241
+ stricter Pose strategy, masking the result by force-accepting via
242
+ the IMU translation gate. Net effect: noticeable lag before
243
+ frame 1 thumbnail rendered, and frame 1 / frame 2 spacing
244
+ visually too large.
245
+ - v0.3 rewires the AR ingest path to extract just the **Y plane
246
+ bytes** from the ARCore camera image (zero-copy via
247
+ DirectByteBuffer → JVM byte[] + JNI `GetPrimitiveArrayCritical`)
248
+ and feeds them directly to the C++ gate's existing
249
+ `evaluateWithFrame` overload. Per-frame cost on the GL render
250
+ thread drops from ~25-40 ms to ~2-5 ms for rejected frames.
251
+ - JPEG encode + disk write is **deferred to only accepted frames**
252
+ (typically 3-6 per capture) via an `onAccept` lambda the gate
253
+ invokes if-and-only-if it keeps the frame. Single disk write
254
+ per accepted keyframe (pre-0.3 was: encode-then-copy = two
255
+ writes).
256
+ - Gate now runs Flow strategy with real pixel content — feature-
257
+ tracking-based novelty, not the strict angular-delta proxy.
258
+ - **iOS non-AR + Android non-AR Flow strategy regression** —
259
+ related to #9 but not user-reported. Both non-AR paths previously
260
+ called `evaluate(pose, plane: nil)` with no pixel data, which
261
+ silently fell back to Pose strategy on both platforms. v0.3
262
+ decodes the JPEG snapshot to grayscale before the gate call so
263
+ Flow strategy runs:
264
+ - iOS: `CGImageSource → CGContext` into a single-channel
265
+ `CVPixelBuffer` (`kCVPixelFormatType_OneComponent8`). The
266
+ `KeyframeGateBridge.mm` got OneComponent8 case-handling
267
+ (parallel to the existing NV12 / BGRA cases). ~10-20 ms per
268
+ snapshot on iPhone 13/16 Pro.
269
+ - Android: `Imgcodecs.imread(path, IMREAD_GRAYSCALE)` decodes
270
+ the JPEG straight to a CV_8UC1 Mat which we marshal into a
271
+ ByteArray for the new `nativeEvaluateWithFrame` JNI thunk.
272
+ ~10-20 ms per snapshot on Galaxy A35.
273
+
274
+ ### Added
275
+
276
+ - **`KeyframeGate.evaluateWithFrame(pose, plane, grayData, w, h, stride)`**
277
+ (Kotlin) — pixel-aware Flow-strategy gate-evaluate entry point,
278
+ parity with the existing iOS `KeyframeGateBridge.evaluatePixelBuffer:…`.
279
+ - **`nativeEvaluateWithFrame`** JNI thunk in `keyframe_gate_jni.cpp`.
280
+ Uses `GetPrimitiveArrayCritical` for zero-copy access to the
281
+ JVM-side byte[] during the gate evaluate.
282
+ - **`kCVPixelFormatType_OneComponent8` handling** in iOS
283
+ `KeyframeGateBridge.mm` — base address is read directly as the
284
+ Y plane with no conversion cost.
285
+
286
+ ### Changed
287
+
288
+ - **`IncrementalStitcher.ingestFromARCameraView` signature** (Android,
289
+ internal):
290
+ - **Removed**: `path: String` parameter. AR camera view no longer
291
+ encodes a JPEG to feed this method — it hands over Y-plane bytes
292
+ instead.
293
+ - **Added**: `grayData: ByteArray, grayWidth: Int, grayHeight: Int,
294
+ grayStride: Int, onAccept: (targetPath: String) -> Boolean`.
295
+ The lambda is invoked only on gate-accept and is expected to
296
+ write a JPEG of the current camera image to the supplied target
297
+ path. Returns true on success.
298
+ - `RNSARCameraView.forwardToIncremental` updated accordingly.
299
+ - **`RNSARCameraView.postFrameToEngine` removed.** The thin wrapper
300
+ was only used to wrap the old positional call to
301
+ `ingestFromARCameraView`; the new lambda-based call shape is
302
+ inline in `forwardToIncremental`.
303
+
304
+ ### Migration from 0.2.x
305
+
306
+ **Most consumers**: no code change required. The public JS API
307
+ (`<Camera>`, `useCapture`, `useIMUTranslationGate`,
308
+ `useDeviceOrientation`, everything) is byte-identical to 0.2.1.
309
+
310
+ **Hosts that tuned `keyframeOverlapThreshold` against Android AR or
311
+ either non-AR path**: the threshold now controls **Flow novelty
312
+ percentile** instead of **Pose angular delta**. Same setting, very
313
+ different metric — re-tune against your typical captures. The
314
+ default (`0.20`) was chosen to roughly match the pre-0.3 visible
315
+ behaviour; most hosts shouldn't need to change anything, but
316
+ quality-sensitive hosts should re-validate before shipping.
317
+
318
+ **Hosts that observed the Android-AR first-frame delay**: the bug
319
+ is fixed — first thumbnail should render within ~50 ms of shutter
320
+ hold (was ~200+ ms).
321
+
322
+ ### Deferred to v0.4 ([#11](https://github.com/bhargavkanda/react-native-image-stitcher/issues/11))
323
+
324
+ Non-AR capture currently still goes through vision-camera's
325
+ `takeSnapshot()` API at ~4 FPS with a per-snapshot JPEG-encode +
326
+ disk-write + decode-to-grayscale round-trip. v0.4 will migrate
327
+ non-AR to vision-camera's Frame Processor API: raw pixel data
328
+ direct from the camera, no JPEG, no disk, full camera frame rate.
329
+ At that point the JPEG-decode-to-grayscale workaround added in
330
+ v0.3's iOS/Android non-AR paths becomes redundant and will be
331
+ removed. See issue #11 for the full scope.
332
+
333
+ ## [0.2.1] — 2026-05-21
334
+
335
+ ### Changed
336
+
337
+ - **Example app no longer wires Expo modules.** The deferred v0.2
338
+ follow-up landed: the example app now uses the standard React
339
+ Native 0.84 host wiring throughout — `RCTReactNativeFactory` in
340
+ `AppDelegate.swift`, `DefaultReactHost.getDefaultReactHost` in
341
+ `MainApplication.kt`, no `use_expo_modules!` macro in `Podfile`,
342
+ no `expo-root-project` plugin or `expoAutolinking.useExpoModules()`
343
+ call in the gradle files, and no `expo`/`expo-modules-core`/
344
+ `expo-modules-autolinking` packages in `example/package.json`.
345
+ The two inline `patch-package`-style Podfile patches for Expo
346
+ SDK 55 on RN 0.84 are also gone — they were only needed because
347
+ we were dragging Expo in. Verified by clean build + install on
348
+ iPhone 16 Pro and Galaxy A35 (with `LANG=en_US.UTF-8 pod install`
349
+ + `JAVA_HOME` set to OpenJDK 17, both required workarounds for
350
+ unrelated tooling bugs we now document in the troubleshooting
351
+ table).
352
+ - **`docs/host-app-integration.md` rewritten** for the post-Expo
353
+ posture. Dropped ~340 lines describing Podfile macros,
354
+ AppDelegate Expo factory wiring, MainApplication Expo factory
355
+ wiring, gradle `expo-root-project` plugin, and the
356
+ `expo-modules-core+55.0.14.patch` patch-package patch. The
357
+ remaining content (vision-camera permission strings, ARCore
358
+ manifest entries, the one `react-native-sensors+7.3.6.patch`
359
+ patch for the jcenter→mavenCentral swap, network access from
360
+ devices to Metro, troubleshooting) is preserved. The README's
361
+ IMPORTANT block at [README.md:53-66](README.md:53) and the
362
+ pre-existing setup walkthrough still apply.
363
+
364
+ ### Migration from 0.2.0
365
+
366
+ Hosts upgrading from 0.2.0 with their existing Expo modules host
367
+ wiring **don't have to change anything** — Expo modules are
368
+ additive, so the wiring keeps working even though the SDK no
369
+ longer requires it. But the wiring is now strictly optional, and
370
+ [`docs/host-app-integration.md`](docs/host-app-integration.md)
371
+ describes the simpler post-Expo path. If you want to follow the
372
+ simpler path: drop the four Expo packages from your
373
+ `package.json`, revert your `AppDelegate.swift` /
374
+ `MainApplication.kt` / Podfile / gradle / patches to the standard
375
+ RN 0.84 templates documented in that file, run
376
+ `pod deintegrate && pod install` (the CocoaPods 1.16 bug needs
377
+ `LANG=en_US.UTF-8`), and rebuild.
378
+
19
379
  ## [0.2.0] — 2026-05-21
20
380
 
21
381
  > [!IMPORTANT]
@@ -311,7 +671,9 @@ Native module names also changed:
311
671
  - iOS pod: `RetaiLensCaptureSDK` → `RNImageStitcher`
312
672
  - iOS xcframework: shipped as `opencv2.xcframework` (linked from `RNImageStitcher.podspec`)
313
673
 
314
- [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.2.0...HEAD
674
+ [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.3.0...HEAD
675
+ [0.3.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.2.1...v0.3.0
676
+ [0.2.1]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.2.0...v0.2.1
315
677
  [0.2.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.1.3...v0.2.0
316
678
  [0.1.3]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.1.2...v0.1.3
317
679
  [0.1.2]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.1.1...v0.1.2
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
 
@@ -120,6 +120,46 @@ Java_io_imagestitcher_rn_KeyframeGate_nativeSetFlowNoveltyPercentile(
120
120
  gate(handle)->setFlowNoveltyPercentile(static_cast<double>(percentile));
121
121
  }
122
122
 
123
+ // 2026-05-22 (audit F5) — Android JNI parity for the Shi-Tomasi
124
+ // corner tunables. Pre-audit, iOS bridges these via KeyframeGateBridge
125
+ // but Android had no equivalent — JS Settings sliders for
126
+ // flowMaxCorners / flowQualityLevel / flowMinDistance were no-ops on
127
+ // Android. See setFlowMaxCorners / setFlowQualityLevel /
128
+ // setFlowMinDistance docs in keyframe_gate.hpp.
129
+ JNIEXPORT void JNICALL
130
+ Java_io_imagestitcher_rn_KeyframeGate_nativeSetFlowMaxCorners(
131
+ JNIEnv*, jclass, jlong handle, jint maxCorners)
132
+ {
133
+ gate(handle)->setFlowMaxCorners(static_cast<int32_t>(maxCorners));
134
+ }
135
+
136
+ JNIEXPORT void JNICALL
137
+ Java_io_imagestitcher_rn_KeyframeGate_nativeSetFlowQualityLevel(
138
+ JNIEnv*, jclass, jlong handle, jdouble quality)
139
+ {
140
+ gate(handle)->setFlowQualityLevel(static_cast<double>(quality));
141
+ }
142
+
143
+ JNIEXPORT void JNICALL
144
+ Java_io_imagestitcher_rn_KeyframeGate_nativeSetFlowMinDistance(
145
+ JNIEnv*, jclass, jlong handle, jdouble minDistance)
146
+ {
147
+ gate(handle)->setFlowMinDistance(static_cast<double>(minDistance));
148
+ }
149
+
150
+ // 2026-05-22 (audit F6) — gate strategy selector. Maps the Kotlin
151
+ // enum's int value back to the C++ GateStrategy enum. Pre-audit
152
+ // Android had no way to flip strategy → was stuck on the C++ default
153
+ // (Pose), making `frameSelectionMode = 'flow-based'` a silent no-op
154
+ // on Android.
155
+ JNIEXPORT void JNICALL
156
+ Java_io_imagestitcher_rn_KeyframeGate_nativeSetStrategy(
157
+ JNIEnv*, jclass, jlong handle, jint strategyInt)
158
+ {
159
+ auto strategy = static_cast<retailens::GateStrategy>(strategyInt);
160
+ gate(handle)->setStrategy(strategy);
161
+ }
162
+
123
163
  JNIEXPORT void JNICALL
124
164
  Java_io_imagestitcher_rn_KeyframeGate_nativeReset(
125
165
  JNIEnv*, jclass, jlong handle)
@@ -201,4 +241,102 @@ Java_io_imagestitcher_rn_KeyframeGate_nativeEvaluate(
201
241
  return out;
202
242
  }
203
243
 
244
+ // ── Per-frame evaluate WITH PIXEL DATA ──────────────────────────
245
+ //
246
+ // 2026-05-21 (v0.3) — pixel-aware Flow-strategy entry point. The
247
+ // `nativeEvaluate` above hands the gate pose + plane only, which
248
+ // forces the C++ side to silently fall back from Flow strategy to
249
+ // Pose strategy in cpp/keyframe_gate.cpp's evaluateWithFrame()
250
+ // (defensive fallback at the grayData==nullptr branch). This thunk
251
+ // is the proper Flow-strategy entry point: the caller supplies the
252
+ // frame's grayscale plane (Y plane for YUV camera images, or a
253
+ // JPEG-decode result for the JS-driver path), and the C++ Flow
254
+ // path actually runs feature tracking on it.
255
+ //
256
+ // grayBytes: Java byte[] holding the grayscale plane. Accessed via
257
+ // GetPrimitiveArrayCritical (no copy, pins GC briefly for
258
+ // the duration of the gate.evaluateWithFrame call —
259
+ // evaluation is ~1-5 ms so the pin window is tight).
260
+ // width: grayscale image width in pixels.
261
+ // height: grayscale image height in pixels.
262
+ // stride: bytes per row. May exceed width when the plane has
263
+ // padding (ARCore's Image.Plane.getRowStride() can pad).
264
+ //
265
+ // plane16OrNull: same as nativeEvaluate — column-major 4×4 plane
266
+ // transform, or null for angular-delta fallback.
267
+ //
268
+ // Returns DoubleArray[5] identical to nativeEvaluate.
269
+ JNIEXPORT jdoubleArray JNICALL
270
+ Java_io_imagestitcher_rn_KeyframeGate_nativeEvaluateWithFrame(
271
+ JNIEnv* env, jclass, jlong handle,
272
+ jfloat tx, jfloat ty, jfloat tz,
273
+ jfloat qx, jfloat qy, jfloat qz, jfloat qw,
274
+ jfloat fx, jfloat fy, jfloat cx, jfloat cy,
275
+ jint imageWidth, jint imageHeight,
276
+ jfloatArray plane16OrNull,
277
+ jbyteArray grayBytes,
278
+ jint grayWidth, jint grayHeight, jint grayStride)
279
+ {
280
+ retailens::Pose pose;
281
+ pose.tx = tx; pose.ty = ty; pose.tz = tz;
282
+ pose.qx = qx; pose.qy = qy; pose.qz = qz; pose.qw = qw;
283
+ pose.fx = fx; pose.fy = fy; pose.cx = cx; pose.cy = cy;
284
+ pose.imageWidth = static_cast<int32_t>(imageWidth);
285
+ pose.imageHeight = static_cast<int32_t>(imageHeight);
286
+
287
+ retailens::PlaneTransform planeStorage;
288
+ const retailens::PlaneTransform* planePtr = nullptr;
289
+ if (plane16OrNull) {
290
+ jsize len = env->GetArrayLength(plane16OrNull);
291
+ if (len == 16) {
292
+ jfloat* src = env->GetFloatArrayElements(plane16OrNull, nullptr);
293
+ if (src) {
294
+ std::memcpy(planeStorage.m, src, sizeof(float) * 16);
295
+ env->ReleaseFloatArrayElements(plane16OrNull, src, JNI_ABORT);
296
+ planePtr = &planeStorage;
297
+ }
298
+ }
299
+ }
300
+
301
+ // Pin the byte[] for the duration of the gate evaluate. Use
302
+ // GetPrimitiveArrayCritical (zero-copy, JVM pins the GC) over
303
+ // GetByteArrayElements (may copy on some VMs) because at 30-60
304
+ // Hz of 2 MB Y-planes, the copy cost adds up. Evaluate is
305
+ // ~1-5 ms so the pin window is short. Always paired with
306
+ // ReleasePrimitiveArrayCritical even on the error paths below.
307
+ retailens::KeyframeGateDecision d;
308
+ if (grayBytes && grayWidth > 0 && grayHeight > 0 && grayStride >= grayWidth) {
309
+ void* raw = env->GetPrimitiveArrayCritical(grayBytes, nullptr);
310
+ if (raw) {
311
+ d = gate(handle)->evaluateWithFrame(
312
+ pose, planePtr,
313
+ static_cast<const uint8_t*>(raw),
314
+ static_cast<int32_t>(grayWidth),
315
+ static_cast<int32_t>(grayHeight),
316
+ static_cast<int32_t>(grayStride));
317
+ env->ReleasePrimitiveArrayCritical(grayBytes, raw, JNI_ABORT);
318
+ } else {
319
+ // GetPrimitiveArrayCritical failed (rare, but defensive).
320
+ // Fall back to pose-only path so we degrade gracefully
321
+ // rather than crashing the whole capture pipeline.
322
+ d = gate(handle)->evaluate(pose, planePtr);
323
+ }
324
+ } else {
325
+ // Caller passed null / invalid dims — defensive fall-through
326
+ // to pose-only path (matches the C++ side's own defensive
327
+ // fallback in evaluateWithFrame when grayData == nullptr).
328
+ d = gate(handle)->evaluate(pose, planePtr);
329
+ }
330
+
331
+ jdoubleArray out = env->NewDoubleArray(5);
332
+ jdouble values[5];
333
+ values[0] = d.accept ? 1.0 : 0.0;
334
+ values[1] = static_cast<jdouble>(static_cast<int32_t>(d.reason));
335
+ values[2] = d.newContentFraction;
336
+ values[3] = static_cast<jdouble>(d.acceptedCount);
337
+ values[4] = static_cast<jdouble>(d.maxCount);
338
+ env->SetDoubleArrayRegion(out, 0, 5, values);
339
+ return out;
340
+ }
341
+
204
342
  } // extern "C"