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.
- package/CHANGELOG.md +363 -1
- package/README.md +1 -1
- package/android/src/main/cpp/keyframe_gate_jni.cpp +138 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +412 -40
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +128 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +87 -45
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +46 -4
- package/cpp/stitcher.cpp +101 -1
- package/cpp/stitcher.hpp +8 -0
- package/dist/camera/Camera.d.ts +9 -0
- package/dist/camera/Camera.js +118 -8
- package/dist/camera/CaptureDebugOverlay.d.ts +45 -0
- package/dist/camera/CaptureDebugOverlay.js +146 -0
- package/dist/camera/CaptureKeyframePill.d.ts +28 -0
- package/dist/camera/CaptureKeyframePill.js +60 -0
- package/dist/camera/CaptureMemoryPill.d.ts +28 -0
- package/dist/camera/CaptureMemoryPill.js +109 -0
- package/dist/camera/CaptureOrientationPill.d.ts +22 -0
- package/dist/camera/CaptureOrientationPill.js +44 -0
- package/dist/camera/CaptureStitchStatsToast.d.ts +45 -0
- package/dist/camera/CaptureStitchStatsToast.js +133 -0
- package/dist/camera/PanoramaSettingsModal.d.ts +6 -5
- package/dist/index.d.ts +10 -0
- package/dist/index.js +15 -1
- package/dist/sensors/useIMUTranslationGate.d.ts +26 -0
- package/dist/sensors/useIMUTranslationGate.js +83 -1
- package/dist/stitching/incremental.d.ts +25 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +12 -1
- package/dist/stitching/useIncrementalStitcher.js +7 -1
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +321 -7
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +8 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +12 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +13 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +15 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +1 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +17 -4
- package/ios/Sources/RNImageStitcher/Stitcher.swift +6 -1
- package/package.json +1 -1
- package/src/camera/Camera.tsx +165 -7
- package/src/camera/CaptureDebugOverlay.tsx +180 -0
- package/src/camera/CaptureKeyframePill.tsx +77 -0
- package/src/camera/CaptureMemoryPill.tsx +96 -0
- package/src/camera/CaptureOrientationPill.tsx +57 -0
- package/src/camera/CaptureStitchStatsToast.tsx +155 -0
- package/src/camera/PanoramaSettingsModal.tsx +6 -5
- package/src/index.ts +19 -0
- package/src/sensors/useIMUTranslationGate.ts +112 -1
- package/src/stitching/incremental.ts +25 -0
- 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.
|
|
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` | `
|
|
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"
|