react-native-image-stitcher 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -7
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +148 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +258 -64
- package/dist/camera/Camera.d.ts +18 -0
- package/dist/camera/Camera.js +3 -2
- package/ios/Package.swift +35 -21
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +60 -0
- package/package.json +1 -1
- package/src/camera/Camera.tsx +26 -1
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
16
|
|
|
17
17
|
## [Unreleased]
|
|
18
18
|
|
|
19
|
+
## [0.5.1] — 2026-05-25
|
|
20
|
+
|
|
21
|
+
### Added — F8.6 Android pixel-buffer engine parity
|
|
22
|
+
|
|
23
|
+
Closes the v0.5.0 follow-up tracked in the [0.5.0] section.
|
|
24
|
+
|
|
25
|
+
**Live engine ingest no longer requires a JPEG round-trip.**
|
|
26
|
+
The `IncrementalFirstwinsEngine` (slit-scan / first-wins) and the
|
|
27
|
+
hybrid `IncrementalEngine` both gained a new
|
|
28
|
+
`addFramePixelData(nv21, w, h, ...)` method. It builds the BGR
|
|
29
|
+
`cv::Mat` in-process via
|
|
30
|
+
`Imgproc.cvtColor(yuv, COLOR_YUV2BGR_NV21)`, then delegates to a
|
|
31
|
+
newly-extracted shared `addFrameMat` helper that runs the original
|
|
32
|
+
engine pipeline verbatim. The legacy `addFrameAtPath(path, ...)`
|
|
33
|
+
is now a thin wrapper: `imread → downsample → addFrameMat`.
|
|
34
|
+
|
|
35
|
+
**Routing.** `IncrementalStitcher.ingestFromARCameraView` got
|
|
36
|
+
three optional parameters — `nv21PixelData: ByteArray?`,
|
|
37
|
+
`nv21PixelWidth: Int`, `nv21PixelHeight: Int`. When supplied (and
|
|
38
|
+
`batchKeyframeMode == false`), the live engine ingests via
|
|
39
|
+
`addFramePixelData`; otherwise falls back to `addFrameAtPath` with
|
|
40
|
+
`legacyJpegPath`. Backwards-compatible — all-null defaults
|
|
41
|
+
preserve every existing caller.
|
|
42
|
+
|
|
43
|
+
**Frame Processor wiring.** `consumeFrameFromPlugin` now packs the
|
|
44
|
+
incoming `Image` NV21 once at the top (was twice — gate consumed
|
|
45
|
+
Y only, then the `onAccept` lambda re-packed for JPEG encode) and
|
|
46
|
+
threads the bytes through to both the gate (which reads only the
|
|
47
|
+
Y subset) AND the new `nv21PixelData` parameter. Net: single
|
|
48
|
+
`packNV21` per producer-thread frame.
|
|
49
|
+
|
|
50
|
+
**Measured on Galaxy A35, `engine: 'firstwins-rectilinear'`,
|
|
51
|
+
non-AR Frame Processor capture:**
|
|
52
|
+
|
|
53
|
+
| Outcome | F8.6 pixel-data | Legacy JPEG path (estimated) |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `AcceptedHigh` (first-frame init) | 7–11 ms | 50–70 ms |
|
|
56
|
+
| `SkippedTooClose` (gate bail) | 0.5–2 ms | 50–60 ms (imread is unconditional) |
|
|
57
|
+
|
|
58
|
+
`SkippedTooClose` dominates the producer-thread frame budget
|
|
59
|
+
(~95% of frames at 30 fps with a slow pan). Eliminating the
|
|
60
|
+
imread on those frames is the bulk of the F8.6 win.
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
|
|
64
|
+
* New `<Camera engine={...}>` prop exposes the live engine
|
|
65
|
+
selection (`'batch-keyframe'` (default) / `'firstwins-rectilinear'`
|
|
66
|
+
/ `'hybrid'` / `'slitscan-*'`). Lets hosts opt into in-flight
|
|
67
|
+
stitching for low-latency previews; previously the choice was
|
|
68
|
+
hardcoded.
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
|
|
72
|
+
* `New: F8.6 perf-diagnostic logs` (`F8.6-route`, `F8.6-perf`) fire
|
|
73
|
+
in live-engine mode only — inert under the default
|
|
74
|
+
`batch-keyframe`. Will be removed in v0.6 once F8.6 is baked in
|
|
75
|
+
production.
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
* In `IncrementalStitcher.consumeFrameFromPlugin`, the `onAccept`
|
|
80
|
+
lambda was re-packing the live `Image` instead of reusing the
|
|
81
|
+
already-packed NV21 from the outer scope. Now it reuses the
|
|
82
|
+
outer `packed` — saves a redundant `packNV21` call on every
|
|
83
|
+
accepted frame.
|
|
84
|
+
|
|
19
85
|
## [0.5.0] — 2026-05-25
|
|
20
86
|
|
|
21
87
|
### Added — F8 Frame Processor port
|
|
@@ -91,13 +157,29 @@ Frame Processor** on the camera producer thread instead of the
|
|
|
91
157
|
|
|
92
158
|
### Tracking — known follow-ups (don't gate this release)
|
|
93
159
|
|
|
94
|
-
- **F8.6** — Android engine refactor for pixel-buffer-
|
|
95
|
-
ingest (true zero-copy parity with iOS).
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
`
|
|
99
|
-
|
|
100
|
-
|
|
160
|
+
- **F8.6 (v0.5.1)** — Android engine refactor for pixel-buffer-
|
|
161
|
+
direct ingest (true zero-copy parity with iOS). Would extract
|
|
162
|
+
an `addFrameMat` helper from `IncrementalFirstwinsEngine` and
|
|
163
|
+
`IncrementalEngine`'s `addFrameAtPath`, add a parallel
|
|
164
|
+
`addFramePixelData` that constructs the BGR `cv::Mat` from NV21
|
|
165
|
+
bytes via `cvtColor`, and rewire `RNSARCameraView` to skip the
|
|
166
|
+
per-frame JPEG encode. Expected gain: ~30–50 ms per accepted
|
|
167
|
+
frame. Deferred because the engine bodies are 400+ lines of
|
|
168
|
+
complex AR-mode code; needs A35 device verification before
|
|
169
|
+
merge, which the v0.5.0 prep session didn't have.
|
|
170
|
+
|
|
171
|
+
- **F8.3-followup-roll** — resolved in v0.5.0.
|
|
172
|
+
|
|
173
|
+
- **F8.3.H2-target** — RESOLVED in v0.5.0 via a different
|
|
174
|
+
mechanism than originally planned. The selector pin is now a
|
|
175
|
+
compile-time `#selector(...)` reference inside
|
|
176
|
+
`IncrementalStitcher.swift` plus a dev-build runtime assert in
|
|
177
|
+
`IncrementalStitcher.init()` — both fire if the Swift method
|
|
178
|
+
signature drifts from what `KeyframeGateFrameProcessor.mm`
|
|
179
|
+
expects. The obsolete test file was deleted. `swift test` now
|
|
180
|
+
runs the (8-test) `QualityCheckerTests` suite cleanly because
|
|
181
|
+
`Package.swift` switched from an exclude list (broke every time
|
|
182
|
+
a new `.mm` landed) to an explicit `sources` allowlist.
|
|
101
183
|
|
|
102
184
|
## [0.4.1] — 2026-05-23
|
|
103
185
|
|
|
@@ -197,7 +197,155 @@ internal class IncrementalFirstwinsEngine(
|
|
|
197
197
|
}
|
|
198
198
|
val frameBGR = downsampleToCompose(srcRaw)
|
|
199
199
|
if (frameBGR !== srcRaw) srcRaw.release()
|
|
200
|
+
val tele = addFrameMat(
|
|
201
|
+
frameBGR,
|
|
202
|
+
qx, qy, qz, qw,
|
|
203
|
+
fx, fy, cx, cy,
|
|
204
|
+
imageWidth, imageHeight,
|
|
205
|
+
yaw, pitch,
|
|
206
|
+
fovHorizDegrees, fovVertDegrees,
|
|
207
|
+
t0,
|
|
208
|
+
)
|
|
209
|
+
f8_6_logPerf("firstwins/jpeg", t0, tele.outcome)
|
|
210
|
+
return tele
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* F8.6 — pixel-data twin of [addFrameAtPath]. Accepts the
|
|
215
|
+
* camera frame as an NV21 byte buffer instead of a JPEG file
|
|
216
|
+
* path; skips the JPEG decode round-trip (~30–50 ms per
|
|
217
|
+
* accepted frame on a mid-tier device).
|
|
218
|
+
*
|
|
219
|
+
* Use this from the vision-camera Frame Processor path (where
|
|
220
|
+
* we already have direct producer-thread access to YUV bytes)
|
|
221
|
+
* and from the ARCore path (where the previous
|
|
222
|
+
* JPEG-encode-on-every-frame in `RNSARCameraView` was a
|
|
223
|
+
* measurable hot-spot).
|
|
224
|
+
*
|
|
225
|
+
* The body matches [addFrameAtPath] one-for-one except for the
|
|
226
|
+
* Mat construction: an `Mat(h*3/2, w, CV_8UC1)` wraps the
|
|
227
|
+
* NV21 bytes, then `Imgproc.cvtColor` produces the BGR Mat the
|
|
228
|
+
* engine pipeline already expects. Everything downstream is
|
|
229
|
+
* the shared [addFrameMat] helper.
|
|
230
|
+
*
|
|
231
|
+
* `nv21Width`/`nv21Height` describe the buffer's actual
|
|
232
|
+
* dimensions. `imageWidth`/`imageHeight` describe the
|
|
233
|
+
* camera's reported sensor dims used for intrinsics scaling —
|
|
234
|
+
* these can differ when the camera is downsampling for Frame
|
|
235
|
+
* Processor output.
|
|
236
|
+
*/
|
|
237
|
+
fun addFramePixelData(
|
|
238
|
+
nv21: ByteArray,
|
|
239
|
+
nv21Width: Int,
|
|
240
|
+
nv21Height: Int,
|
|
241
|
+
qx: Double, qy: Double, qz: Double, qw: Double,
|
|
242
|
+
fx: Double, fy: Double, cx: Double, cy: Double,
|
|
243
|
+
imageWidth: Int, imageHeight: Int,
|
|
244
|
+
yaw: Double, pitch: Double,
|
|
245
|
+
fovHorizDegrees: Double, fovVertDegrees: Double,
|
|
246
|
+
trackingPoor: Boolean,
|
|
247
|
+
): FrameTelemetry {
|
|
248
|
+
val t0 = System.nanoTime()
|
|
249
|
+
if (trackingPoor) {
|
|
250
|
+
return FrameTelemetry(
|
|
251
|
+
FrameOutcome.SkippedTrackingPoor, -1.0, 0, yaw, pitch,
|
|
252
|
+
msSince(t0),
|
|
253
|
+
isLandscape = isLandscape,
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
// NV21 layout: Y plane (w*h bytes) + interleaved VU
|
|
257
|
+
// (w*h/2 bytes). A single CV_8UC1 Mat of height h*3/2
|
|
258
|
+
// packs the whole thing; cvtColor with COLOR_YUV2BGR_NV21
|
|
259
|
+
// does the planar-aware decode in one call.
|
|
260
|
+
//
|
|
261
|
+
// F8.6 IS-1 — length guard. If the caller supplied a
|
|
262
|
+
// short buffer, `yuv.put(0,0,nv21)` would copy only
|
|
263
|
+
// `nv21.size` bytes and leave the rest zero-init; cvtColor
|
|
264
|
+
// would then read stale/zero UV and produce silently
|
|
265
|
+
// corrupt colour. Fail fast instead.
|
|
266
|
+
val expectedBytes = nv21Width * nv21Height * 3 / 2
|
|
267
|
+
require(nv21.size >= expectedBytes) {
|
|
268
|
+
"addFramePixelData: nv21 buffer too small " +
|
|
269
|
+
"(${nv21.size} bytes < $expectedBytes for " +
|
|
270
|
+
"${nv21Width}x${nv21Height})"
|
|
271
|
+
}
|
|
272
|
+
val yuv = Mat(nv21Height + nv21Height / 2, nv21Width, CvType.CV_8UC1)
|
|
273
|
+
yuv.put(0, 0, nv21)
|
|
274
|
+
val srcRaw = Mat()
|
|
275
|
+
Imgproc.cvtColor(yuv, srcRaw, Imgproc.COLOR_YUV2BGR_NV21)
|
|
276
|
+
yuv.release()
|
|
277
|
+
if (srcRaw.empty()) {
|
|
278
|
+
return FrameTelemetry(
|
|
279
|
+
FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch,
|
|
280
|
+
msSince(t0),
|
|
281
|
+
isLandscape = isLandscape,
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
val frameBGR = downsampleToCompose(srcRaw)
|
|
285
|
+
if (frameBGR !== srcRaw) srcRaw.release()
|
|
286
|
+
val tele = addFrameMat(
|
|
287
|
+
frameBGR,
|
|
288
|
+
qx, qy, qz, qw,
|
|
289
|
+
fx, fy, cx, cy,
|
|
290
|
+
imageWidth, imageHeight,
|
|
291
|
+
yaw, pitch,
|
|
292
|
+
fovHorizDegrees, fovVertDegrees,
|
|
293
|
+
t0,
|
|
294
|
+
)
|
|
295
|
+
f8_6_logPerf("firstwins/pixel", t0, tele.outcome)
|
|
296
|
+
return tele
|
|
297
|
+
}
|
|
200
298
|
|
|
299
|
+
/**
|
|
300
|
+
* F8.6 perf-diagnostic counter. Logs ingest timing every Nth
|
|
301
|
+
* call so a single capture session yields enough samples to
|
|
302
|
+
* eyeball the JPEG-path vs pixel-data-path delta. Remove this
|
|
303
|
+
* once F8.6 is verified in production.
|
|
304
|
+
*/
|
|
305
|
+
@Volatile private var f8_6_perfCallCounter: Long = 0L
|
|
306
|
+
private fun f8_6_logPerf(
|
|
307
|
+
path: String,
|
|
308
|
+
t0Nanos: Long,
|
|
309
|
+
outcome: FrameOutcome,
|
|
310
|
+
) {
|
|
311
|
+
val n = ++f8_6_perfCallCounter
|
|
312
|
+
// Every 5th call ≈ ~1 line/sec at 5 Hz live-engine rate;
|
|
313
|
+
// first call always logs so we see something on capture
|
|
314
|
+
// start without waiting.
|
|
315
|
+
if (n == 1L || n % 5L == 0L) {
|
|
316
|
+
Log.i(
|
|
317
|
+
"F8.6-perf",
|
|
318
|
+
"$path took ${msSince(t0Nanos)}ms outcome=$outcome (call #$n)",
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* F8.6 — the body extracted from [addFrameAtPath]. Takes a
|
|
325
|
+
* BGR `Mat` (downsampled to compose dims) and runs the full
|
|
326
|
+
* engine pipeline: first-frame init or subsequent-frame paste
|
|
327
|
+
* via either rectilinear or cylindrical warp.
|
|
328
|
+
*
|
|
329
|
+
* Behaviour is identical to the pre-F8.6 `addFrameAtPath`
|
|
330
|
+
* (the body is a verbatim move). Both `addFrameAtPath` and
|
|
331
|
+
* `addFramePixelData` delegate here after their respective
|
|
332
|
+
* Mat constructions.
|
|
333
|
+
*/
|
|
334
|
+
private fun addFrameMat(
|
|
335
|
+
frameBGR: Mat,
|
|
336
|
+
qx: Double, qy: Double, qz: Double, qw: Double,
|
|
337
|
+
fx: Double, fy: Double, cx: Double, cy: Double,
|
|
338
|
+
imageWidth: Int, imageHeight: Int,
|
|
339
|
+
yaw: Double, pitch: Double,
|
|
340
|
+
// FOV params kept for symmetry with `IncrementalEngine.addFrameMat`
|
|
341
|
+
// (the hybrid engine, which uses them in `computeOverlapPct`).
|
|
342
|
+
// The firstwins/slit-scan engine here doesn't consume them —
|
|
343
|
+
// its paste decision is driven by pose-projected pixel
|
|
344
|
+
// displacement, not FoV-overlap percent.
|
|
345
|
+
@Suppress("UNUSED_PARAMETER") fovHorizDegrees: Double,
|
|
346
|
+
@Suppress("UNUSED_PARAMETER") fovVertDegrees: Double,
|
|
347
|
+
t0: Long,
|
|
348
|
+
): FrameTelemetry {
|
|
201
349
|
val rNew = quaternionToRotationMat(qx, qy, qz, qw)
|
|
202
350
|
|
|
203
351
|
if (!hasFirstFrame) {
|
|
@@ -240,6 +240,11 @@ class IncrementalStitcher(
|
|
|
240
240
|
/// (start/cancel/finalize) writes via `set()`/`compareAndSet()`.
|
|
241
241
|
/// Mirror of iOS' `frameProcessorIngestEnabled` ivar.
|
|
242
242
|
private val frameProcessorIngestEnabled = AtomicBoolean(false)
|
|
243
|
+
|
|
244
|
+
/// F8.6 perf-diagnostic — one-shot guard so the "live-engine
|
|
245
|
+
/// route" log fires exactly once per capture session. Reset
|
|
246
|
+
/// in start().
|
|
247
|
+
private val f8_6_routeLoggedThisCapture = AtomicBoolean(false)
|
|
243
248
|
/// Critic #5 fix: serial dispatcher so concurrent
|
|
244
249
|
/// processFrameAtPath() calls can't race on the engine's canvas.
|
|
245
250
|
/// `limitedParallelism(1)` guarantees one-at-a-time execution
|
|
@@ -333,6 +338,9 @@ class IncrementalStitcher(
|
|
|
333
338
|
// / ARCore paths run unmodified.
|
|
334
339
|
val frameSourceMode = options.getString("frameSourceMode") ?: "jsDriver"
|
|
335
340
|
frameProcessorIngestEnabled.set(frameSourceMode == "frameProcessor")
|
|
341
|
+
// F8.6 perf-diagnostic — re-arm the route log for the
|
|
342
|
+
// new capture.
|
|
343
|
+
f8_6_routeLoggedThisCapture.set(false)
|
|
336
344
|
val rotation = options.getIntOrDefault("frameRotationDegrees", 90)
|
|
337
345
|
val composeW = options.getIntOrDefault("composeWidth", 960)
|
|
338
346
|
val composeH = options.getIntOrDefault("composeHeight", 720)
|
|
@@ -1262,6 +1270,24 @@ class IncrementalStitcher(
|
|
|
1262
1270
|
// can check `isBatchKeyframeMode` to elide the per-frame
|
|
1263
1271
|
// JPEG encode for the batch path.
|
|
1264
1272
|
legacyJpegPath: String? = null,
|
|
1273
|
+
// F8.6 — pixel-data path for live engines. When supplied
|
|
1274
|
+
// (and `batchKeyframeMode == false`), takes precedence over
|
|
1275
|
+
// `legacyJpegPath`: the live engine ingests via
|
|
1276
|
+
// `addFramePixelData` (NV21 → BGR Mat in-process) instead of
|
|
1277
|
+
// `addFrameAtPath` (JPEG decode round-trip). Saves ~30-50 ms
|
|
1278
|
+
// per accepted frame on a mid-tier device. Pass null to use
|
|
1279
|
+
// the legacy JPEG path.
|
|
1280
|
+
//
|
|
1281
|
+
// OWNERSHIP: the engine retains a reference to `nv21PixelData`
|
|
1282
|
+
// until `workScope`'s coroutine consumes it (~50 ms later).
|
|
1283
|
+
// Callers MUST treat the array as transferred — do not
|
|
1284
|
+
// mutate it or return it to a buffer pool after calling
|
|
1285
|
+
// this method. If a caller needs to recycle the buffer,
|
|
1286
|
+
// pass `.copyOf()` (currently no caller does — the F8.4
|
|
1287
|
+
// Frame Processor plugin allocates a fresh array per frame).
|
|
1288
|
+
nv21PixelData: ByteArray? = null,
|
|
1289
|
+
nv21PixelWidth: Int = 0,
|
|
1290
|
+
nv21PixelHeight: Int = 0,
|
|
1265
1291
|
) {
|
|
1266
1292
|
// ── V16 batch-keyframe: AR-driven path ─────────────────────
|
|
1267
1293
|
//
|
|
@@ -1439,40 +1465,91 @@ class IncrementalStitcher(
|
|
|
1439
1465
|
// we only get here when batchKeyframeMode == false. Caller
|
|
1440
1466
|
// (RNSARCameraView) was expected to supply legacyJpegPath in
|
|
1441
1467
|
// that case — defensively drop the frame if it didn't.
|
|
1442
|
-
|
|
1468
|
+
// F8.6 — prefer the pixel-data path when the caller supplied
|
|
1469
|
+
// NV21 bytes (Frame Processor / refactored ARCore path),
|
|
1470
|
+
// otherwise fall back to legacyJpegPath (un-migrated ARCore
|
|
1471
|
+
// path). At least one of them must be present; drop the
|
|
1472
|
+
// frame defensively otherwise.
|
|
1473
|
+
val hasPixelData = nv21PixelData != null
|
|
1474
|
+
&& nv21PixelWidth > 0
|
|
1475
|
+
&& nv21PixelHeight > 0
|
|
1476
|
+
// F8.6 perf-diagnostic — log route choice once per capture
|
|
1477
|
+
// so logcat shows whether the new pixel-data path is
|
|
1478
|
+
// actually getting exercised. Throttled to first-hit per
|
|
1479
|
+
// capture (counter resets in start()/cancel()).
|
|
1480
|
+
if (!f8_6_routeLoggedThisCapture.getAndSet(true)) {
|
|
1481
|
+
android.util.Log.i(
|
|
1482
|
+
"F8.6-route",
|
|
1483
|
+
"ingestFromARCameraView live-engine route: "
|
|
1484
|
+
+ if (hasPixelData) "pixel-data (F8.6, no JPEG round-trip)"
|
|
1485
|
+
else "jpeg-path (legacy, JPEG decode round-trip)",
|
|
1486
|
+
)
|
|
1487
|
+
}
|
|
1488
|
+
val path = if (hasPixelData) null else legacyJpegPath ?: run {
|
|
1443
1489
|
android.util.Log.w(
|
|
1444
1490
|
"IncrementalStitcher",
|
|
1445
1491
|
"ingestFromARCameraView legacy: batchKeyframeMode=false " +
|
|
1446
|
-
"but legacyJpegPath
|
|
1447
|
-
"Caller should have encoded a JPEG
|
|
1492
|
+
"but both legacyJpegPath and nv21PixelData are null — " +
|
|
1493
|
+
"dropping frame. Caller should have encoded a JPEG " +
|
|
1494
|
+
"OR supplied NV21 pixel data when " +
|
|
1448
1495
|
"isBatchKeyframeMode == false.",
|
|
1449
1496
|
)
|
|
1450
1497
|
return
|
|
1451
1498
|
}
|
|
1452
1499
|
workScope.launch {
|
|
1453
1500
|
val state: WritableMap? = if (firstwins != null) {
|
|
1454
|
-
val tele =
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1501
|
+
val tele = if (hasPixelData) {
|
|
1502
|
+
firstwins.addFramePixelData(
|
|
1503
|
+
nv21 = nv21PixelData!!,
|
|
1504
|
+
nv21Width = nv21PixelWidth,
|
|
1505
|
+
nv21Height = nv21PixelHeight,
|
|
1506
|
+
qx = qx, qy = qy, qz = qz, qw = qw,
|
|
1507
|
+
fx = fx, fy = fy, cx = cx, cy = cy,
|
|
1508
|
+
imageWidth = imageWidth, imageHeight = imageHeight,
|
|
1509
|
+
yaw = yaw, pitch = pitch,
|
|
1510
|
+
fovHorizDegrees = fovHorizDegrees,
|
|
1511
|
+
fovVertDegrees = fovVertDegrees,
|
|
1512
|
+
trackingPoor = trackingPoor,
|
|
1513
|
+
)
|
|
1514
|
+
} else {
|
|
1515
|
+
firstwins.addFrameAtPath(
|
|
1516
|
+
path = path!!,
|
|
1517
|
+
qx = qx, qy = qy, qz = qz, qw = qw,
|
|
1518
|
+
fx = fx, fy = fy, cx = cx, cy = cy,
|
|
1519
|
+
imageWidth = imageWidth, imageHeight = imageHeight,
|
|
1520
|
+
yaw = yaw, pitch = pitch,
|
|
1521
|
+
fovHorizDegrees = fovHorizDegrees,
|
|
1522
|
+
fovVertDegrees = fovVertDegrees,
|
|
1523
|
+
trackingPoor = trackingPoor,
|
|
1524
|
+
)
|
|
1525
|
+
}
|
|
1464
1526
|
firstwins.snapshotIfDue(tele)
|
|
1465
1527
|
} else {
|
|
1466
|
-
val tele =
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1528
|
+
val tele = if (hasPixelData) {
|
|
1529
|
+
hybrid!!.addFramePixelData(
|
|
1530
|
+
nv21 = nv21PixelData!!,
|
|
1531
|
+
nv21Width = nv21PixelWidth,
|
|
1532
|
+
nv21Height = nv21PixelHeight,
|
|
1533
|
+
qx = qx, qy = qy, qz = qz, qw = qw,
|
|
1534
|
+
fx = fx, fy = fy, cx = cx, cy = cy,
|
|
1535
|
+
imageWidth = imageWidth, imageHeight = imageHeight,
|
|
1536
|
+
yaw = yaw, pitch = pitch,
|
|
1537
|
+
fovHorizDegrees = fovHorizDegrees,
|
|
1538
|
+
fovVertDegrees = fovVertDegrees,
|
|
1539
|
+
trackingPoor = trackingPoor,
|
|
1540
|
+
)
|
|
1541
|
+
} else {
|
|
1542
|
+
hybrid!!.addFrameAtPath(
|
|
1543
|
+
path = path!!,
|
|
1544
|
+
qx = qx, qy = qy, qz = qz, qw = qw,
|
|
1545
|
+
fx = fx, fy = fy, cx = cx, cy = cy,
|
|
1546
|
+
imageWidth = imageWidth, imageHeight = imageHeight,
|
|
1547
|
+
yaw = yaw, pitch = pitch,
|
|
1548
|
+
fovHorizDegrees = fovHorizDegrees,
|
|
1549
|
+
fovVertDegrees = fovVertDegrees,
|
|
1550
|
+
trackingPoor = trackingPoor,
|
|
1551
|
+
)
|
|
1552
|
+
}
|
|
1476
1553
|
hybrid.snapshotIfDue(tele)
|
|
1477
1554
|
}
|
|
1478
1555
|
emitState(state)
|
|
@@ -1539,18 +1616,31 @@ class IncrementalStitcher(
|
|
|
1539
1616
|
// for the full reasoning. Mirrors iOS H1.
|
|
1540
1617
|
if (!frameProcessorIngestEnabled.get()) return
|
|
1541
1618
|
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
//
|
|
1550
|
-
//
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1619
|
+
// F8.6 — pack the full NV21 (Y + interleaved VU) once,
|
|
1620
|
+
// then reuse it for BOTH the gate's Y-plane read AND the
|
|
1621
|
+
// live engine's pixel-data ingest. Previously the plugin
|
|
1622
|
+
// only extracted Y; the live engine then had to JPEG-decode
|
|
1623
|
+
// a separately-encoded path to recover BGR colour. Now we
|
|
1624
|
+
// skip both round-trips: the packed NV21 → BGR cvtColor
|
|
1625
|
+
// inside `addFramePixelData` produces the BGR Mat directly.
|
|
1626
|
+
//
|
|
1627
|
+
// YuvImageConverter.packNV21 is stride-aware and densely
|
|
1628
|
+
// repacks Y (so the gate's `grayStride = grayWidth = width`
|
|
1629
|
+
// works), then interleaves VU per the standard NV21 layout
|
|
1630
|
+
// [Y...][VU...]. Returns null only on degenerate Images
|
|
1631
|
+
// (closed mid-callback or non-YUV format).
|
|
1632
|
+
val packed = io.imagestitcher.rn.ar.YuvImageConverter.packNV21(image)
|
|
1633
|
+
?: return
|
|
1634
|
+
val width = packed.width
|
|
1635
|
+
val height = packed.height
|
|
1636
|
+
val nv21Bytes = packed.nv21
|
|
1637
|
+
// The gate reads `grayHeight` rows of `grayWidth` pixels
|
|
1638
|
+
// at stride=width starting from offset 0. That's exactly
|
|
1639
|
+
// the Y plane region of nv21Bytes — the gate naturally
|
|
1640
|
+
// stops before the UV bytes start. No need to slice into
|
|
1641
|
+
// a separate ByteArray.
|
|
1642
|
+
val yBytes = nv21Bytes
|
|
1643
|
+
val yRowStride = width
|
|
1554
1644
|
|
|
1555
1645
|
// Compute derived params expected by the existing ingest
|
|
1556
1646
|
// API. Quaternion-to-yaw/pitch follows the same convention
|
|
@@ -1596,13 +1686,22 @@ class IncrementalStitcher(
|
|
|
1596
1686
|
grayWidth = width,
|
|
1597
1687
|
grayHeight = height,
|
|
1598
1688
|
grayStride = yRowStride,
|
|
1689
|
+
// F8.6 — pass the already-packed NV21 so the live
|
|
1690
|
+
// engine branch (hybrid / firstwins) can ingest via
|
|
1691
|
+
// `addFramePixelData` instead of JPEG-decoding a
|
|
1692
|
+
// separately-written path. Batch-keyframe mode
|
|
1693
|
+
// ignores these (it uses `grayData` + `onAccept`).
|
|
1694
|
+
nv21PixelData = nv21Bytes,
|
|
1695
|
+
nv21PixelWidth = width,
|
|
1696
|
+
nv21PixelHeight = height,
|
|
1599
1697
|
onAccept = { targetPath ->
|
|
1600
1698
|
// Synchronous JPEG encode via the existing
|
|
1601
1699
|
// YuvImageConverter (also used by RNSARCameraView's
|
|
1602
|
-
// ARCore path).
|
|
1603
|
-
//
|
|
1604
|
-
//
|
|
1605
|
-
//
|
|
1700
|
+
// ARCore path). Reuses the NV21 already packed at
|
|
1701
|
+
// the top of `consumeFrameFromPlugin` — F8.6 saves
|
|
1702
|
+
// a duplicate packNV21 call here (the previous
|
|
1703
|
+
// version repacked the live `image` inside the
|
|
1704
|
+
// lambda).
|
|
1606
1705
|
//
|
|
1607
1706
|
// EXIF rotation is BAKED-AS-METADATA, not pixel-
|
|
1608
1707
|
// rotated. cv::imread in the stitcher ignores EXIF
|
|
@@ -1614,32 +1713,20 @@ class IncrementalStitcher(
|
|
|
1614
1713
|
// Returning `true` tells the engine the keyframe was
|
|
1615
1714
|
// persisted; `false` tells it to drop the accept.
|
|
1616
1715
|
try {
|
|
1617
|
-
val
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
return@run null
|
|
1624
|
-
}
|
|
1625
|
-
if (packed == null) {
|
|
1626
|
-
false
|
|
1627
|
-
} else {
|
|
1628
|
-
val displayRotation = when (sensorRotationDegrees) {
|
|
1629
|
-
0 -> android.view.Surface.ROTATION_90
|
|
1630
|
-
90 -> android.view.Surface.ROTATION_0
|
|
1631
|
-
180 -> android.view.Surface.ROTATION_270
|
|
1632
|
-
270 -> android.view.Surface.ROTATION_180
|
|
1633
|
-
else -> android.view.Surface.ROTATION_0
|
|
1634
|
-
}
|
|
1635
|
-
val outPath = YuvImageConverter.encodeJpegFromNV21(
|
|
1636
|
-
packed,
|
|
1637
|
-
targetPath,
|
|
1638
|
-
jpegQuality = 80,
|
|
1639
|
-
displayRotation = displayRotation,
|
|
1640
|
-
)
|
|
1641
|
-
outPath != null
|
|
1716
|
+
val displayRotation = when (sensorRotationDegrees) {
|
|
1717
|
+
0 -> android.view.Surface.ROTATION_90
|
|
1718
|
+
90 -> android.view.Surface.ROTATION_0
|
|
1719
|
+
180 -> android.view.Surface.ROTATION_270
|
|
1720
|
+
270 -> android.view.Surface.ROTATION_180
|
|
1721
|
+
else -> android.view.Surface.ROTATION_0
|
|
1642
1722
|
}
|
|
1723
|
+
val outPath = YuvImageConverter.encodeJpegFromNV21(
|
|
1724
|
+
packed,
|
|
1725
|
+
targetPath,
|
|
1726
|
+
jpegQuality = 80,
|
|
1727
|
+
displayRotation = displayRotation,
|
|
1728
|
+
)
|
|
1729
|
+
outPath != null
|
|
1643
1730
|
} catch (e: Throwable) {
|
|
1644
1731
|
android.util.Log.w(
|
|
1645
1732
|
"IncrementalStitcher",
|
|
@@ -2548,7 +2635,114 @@ internal class IncrementalEngine(
|
|
|
2548
2635
|
// See iOS' equivalent fix for the architectural rationale.
|
|
2549
2636
|
val frame = downsampleToCompose(srcRaw)
|
|
2550
2637
|
if (frame !== srcRaw) srcRaw.release()
|
|
2638
|
+
val tele = addFrameMat(
|
|
2639
|
+
frame,
|
|
2640
|
+
qx, qy, qz, qw,
|
|
2641
|
+
fx, fy, cx, cy,
|
|
2642
|
+
imageWidth, imageHeight,
|
|
2643
|
+
yaw, pitch,
|
|
2644
|
+
fovHorizDegrees, fovVertDegrees,
|
|
2645
|
+
t0,
|
|
2646
|
+
)
|
|
2647
|
+
f8_6_logPerf("hybrid/jpeg", t0, tele.outcome)
|
|
2648
|
+
return tele
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
/**
|
|
2652
|
+
* F8.6 — pixel-data twin of [addFrameAtPath]. Accepts the
|
|
2653
|
+
* camera frame as an NV21 byte buffer instead of a JPEG file
|
|
2654
|
+
* path; skips the JPEG decode round-trip. See
|
|
2655
|
+
* `IncrementalFirstwinsEngine.addFramePixelData` for the
|
|
2656
|
+
* sibling implementation rationale.
|
|
2657
|
+
*/
|
|
2658
|
+
fun addFramePixelData(
|
|
2659
|
+
nv21: ByteArray,
|
|
2660
|
+
nv21Width: Int,
|
|
2661
|
+
nv21Height: Int,
|
|
2662
|
+
qx: Double, qy: Double, qz: Double, qw: Double,
|
|
2663
|
+
fx: Double, fy: Double, cx: Double, cy: Double,
|
|
2664
|
+
imageWidth: Int, imageHeight: Int,
|
|
2665
|
+
yaw: Double, pitch: Double,
|
|
2666
|
+
fovHorizDegrees: Double, fovVertDegrees: Double,
|
|
2667
|
+
trackingPoor: Boolean,
|
|
2668
|
+
): FrameTelemetry {
|
|
2669
|
+
val t0 = System.nanoTime()
|
|
2670
|
+
if (trackingPoor) {
|
|
2671
|
+
return FrameTelemetry(
|
|
2672
|
+
FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
|
|
2673
|
+
msSince(t0),
|
|
2674
|
+
)
|
|
2675
|
+
}
|
|
2676
|
+
// F8.6 IS-1 — length guard; see
|
|
2677
|
+
// `IncrementalFirstwinsEngine.addFramePixelData` for the
|
|
2678
|
+
// failure-mode rationale.
|
|
2679
|
+
val expectedBytes = nv21Width * nv21Height * 3 / 2
|
|
2680
|
+
require(nv21.size >= expectedBytes) {
|
|
2681
|
+
"addFramePixelData: nv21 buffer too small " +
|
|
2682
|
+
"(${nv21.size} bytes < $expectedBytes for " +
|
|
2683
|
+
"${nv21Width}x${nv21Height})"
|
|
2684
|
+
}
|
|
2685
|
+
val yuv = Mat(nv21Height + nv21Height / 2, nv21Width, CvType.CV_8UC1)
|
|
2686
|
+
yuv.put(0, 0, nv21)
|
|
2687
|
+
val srcRaw = Mat()
|
|
2688
|
+
Imgproc.cvtColor(yuv, srcRaw, Imgproc.COLOR_YUV2BGR_NV21)
|
|
2689
|
+
yuv.release()
|
|
2690
|
+
if (srcRaw.empty()) {
|
|
2691
|
+
return FrameTelemetry(
|
|
2692
|
+
FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
|
|
2693
|
+
msSince(t0),
|
|
2694
|
+
)
|
|
2695
|
+
}
|
|
2696
|
+
val frame = downsampleToCompose(srcRaw)
|
|
2697
|
+
if (frame !== srcRaw) srcRaw.release()
|
|
2698
|
+
val tele = addFrameMat(
|
|
2699
|
+
frame,
|
|
2700
|
+
qx, qy, qz, qw,
|
|
2701
|
+
fx, fy, cx, cy,
|
|
2702
|
+
imageWidth, imageHeight,
|
|
2703
|
+
yaw, pitch,
|
|
2704
|
+
fovHorizDegrees, fovVertDegrees,
|
|
2705
|
+
t0,
|
|
2706
|
+
)
|
|
2707
|
+
f8_6_logPerf("hybrid/pixel", t0, tele.outcome)
|
|
2708
|
+
return tele
|
|
2709
|
+
}
|
|
2551
2710
|
|
|
2711
|
+
/**
|
|
2712
|
+
* F8.6 perf-diagnostic counter (mirror of the firstwins one).
|
|
2713
|
+
* Remove after v0.5.1 ships and the numbers are baked in.
|
|
2714
|
+
*/
|
|
2715
|
+
@Volatile private var f8_6_perfCallCounter: Long = 0L
|
|
2716
|
+
private fun f8_6_logPerf(
|
|
2717
|
+
path: String,
|
|
2718
|
+
t0Nanos: Long,
|
|
2719
|
+
outcome: FrameOutcome,
|
|
2720
|
+
) {
|
|
2721
|
+
val n = ++f8_6_perfCallCounter
|
|
2722
|
+
if (n == 1L || n % 5L == 0L) {
|
|
2723
|
+
android.util.Log.i(
|
|
2724
|
+
"F8.6-perf",
|
|
2725
|
+
"$path took ${msSince(t0Nanos)}ms outcome=$outcome (call #$n)",
|
|
2726
|
+
)
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
/**
|
|
2731
|
+
* F8.6 — the body extracted from [addFrameAtPath]. Takes a
|
|
2732
|
+
* BGR `Mat` (already downsampled to compose dims) and runs the
|
|
2733
|
+
* pose-driven homography paste pipeline. Behaviour is
|
|
2734
|
+
* identical to the pre-F8.6 `addFrameAtPath` — the body is a
|
|
2735
|
+
* verbatim move.
|
|
2736
|
+
*/
|
|
2737
|
+
private fun addFrameMat(
|
|
2738
|
+
frame: Mat,
|
|
2739
|
+
qx: Double, qy: Double, qz: Double, qw: Double,
|
|
2740
|
+
fx: Double, fy: Double, cx: Double, cy: Double,
|
|
2741
|
+
imageWidth: Int, imageHeight: Int,
|
|
2742
|
+
yaw: Double, pitch: Double,
|
|
2743
|
+
fovHorizDegrees: Double, fovVertDegrees: Double,
|
|
2744
|
+
t0: Long,
|
|
2745
|
+
): FrameTelemetry {
|
|
2552
2746
|
// Build R_new from quaternion.
|
|
2553
2747
|
val rNew = quaternionToRotationMat(qx, qy, qz, qw)
|
|
2554
2748
|
|
package/dist/camera/Camera.d.ts
CHANGED
|
@@ -140,6 +140,24 @@ export interface CameraProps {
|
|
|
140
140
|
enablePanoramaMode?: boolean;
|
|
141
141
|
showSettingsButton?: boolean;
|
|
142
142
|
style?: StyleProp<ViewStyle>;
|
|
143
|
+
/**
|
|
144
|
+
* Which incremental stitcher engine to drive. Default
|
|
145
|
+
* `'batch-keyframe'` — collects accepted JPEGs and runs
|
|
146
|
+
* `cv::Stitcher` once at finalize time. This is the v0.4+
|
|
147
|
+
* production default and what the v0.5 Frame Processor migration
|
|
148
|
+
* exercises.
|
|
149
|
+
*
|
|
150
|
+
* Switch to a live engine (`'firstwins-rectilinear'` or
|
|
151
|
+
* `'hybrid'`) for low-latency in-flight stitching. Live engines
|
|
152
|
+
* exercise the F8.6 pixel-buffer ingest path (skipping the JPEG
|
|
153
|
+
* encode/decode round-trip; ~30–50 ms saved per accept) when the
|
|
154
|
+
* Frame Processor driver is active.
|
|
155
|
+
*
|
|
156
|
+
* See `docs/f8-frame-processor-plan.md` and the v0.5.0
|
|
157
|
+
* CHANGELOG for the trade-offs between batch-keyframe and live
|
|
158
|
+
* engines.
|
|
159
|
+
*/
|
|
160
|
+
engine?: 'batch-keyframe' | 'hybrid' | 'slitscan-rotate' | 'slitscan-both' | 'firstwins' | 'firstwins-zoomed' | 'firstwins-rectilinear' | 'slitscan';
|
|
143
161
|
/**
|
|
144
162
|
* Optional destination directory for captures. When set, the lib
|
|
145
163
|
* lands tap-photos at `${outputDir}/photo-${ts}.jpg` and panoramas
|
package/dist/camera/Camera.js
CHANGED
|
@@ -271,7 +271,7 @@ function extractPanoramaOverrides(props) {
|
|
|
271
271
|
* The public `<Camera>` component.
|
|
272
272
|
*/
|
|
273
273
|
function Camera(props) {
|
|
274
|
-
const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, frameProcessor: hostFrameProcessor, legacyDriver = false, } = props;
|
|
274
|
+
const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, frameProcessor: hostFrameProcessor, legacyDriver = false, engine = 'batch-keyframe', } = props;
|
|
275
275
|
const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
|
|
276
276
|
// ── State ───────────────────────────────────────────────────────
|
|
277
277
|
const [arPreference, setArPreference] = (0, react_1.useState)(defaultCaptureSource === 'ar');
|
|
@@ -662,7 +662,7 @@ function Camera(props) {
|
|
|
662
662
|
composeHeight: 1080,
|
|
663
663
|
canvasWidth: 5000,
|
|
664
664
|
canvasHeight: 5000,
|
|
665
|
-
engine
|
|
665
|
+
engine,
|
|
666
666
|
config: (0, PanoramaSettingsBridge_1.panoramaSettingsToNativeConfig)({
|
|
667
667
|
...settings,
|
|
668
668
|
captureSource: effectiveCaptureSource,
|
|
@@ -706,6 +706,7 @@ function Camera(props) {
|
|
|
706
706
|
jsDriver,
|
|
707
707
|
fpDriver,
|
|
708
708
|
legacyDriver,
|
|
709
|
+
engine,
|
|
709
710
|
onError,
|
|
710
711
|
]);
|
|
711
712
|
const handleHoldEnd = (0, react_1.useCallback)(async () => {
|
package/ios/Package.swift
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// swift-tools-version:5.
|
|
1
|
+
// swift-tools-version:5.10
|
|
2
2
|
//
|
|
3
3
|
// Package.swift — SwiftPM manifest used **only for command-line testing**
|
|
4
4
|
// of the algorithm layer (QualityChecker.swift). Production builds
|
|
@@ -38,26 +38,40 @@ let package = Package(
|
|
|
38
38
|
.target(
|
|
39
39
|
name: "RNImageStitcher",
|
|
40
40
|
path: "Sources/RNImageStitcher",
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
41
|
+
// F8.3.H2-target — instead of an `exclude` list (which broke
|
|
42
|
+
// every time a new .mm landed, e.g.
|
|
43
|
+
// `KeyframeGateFrameProcessor.mm` in F8.1, because SwiftPM
|
|
44
|
+
// still scans the directory and rejects "mixed language
|
|
45
|
+
// source files" if it sees both .swift and .mm), we use an
|
|
46
|
+
// explicit `sources` allowlist of files that compile cleanly
|
|
47
|
+
// on macOS (where `swift test` runs).
|
|
48
|
+
//
|
|
49
|
+
// What's in the allowlist:
|
|
50
|
+
// * QualityChecker.swift — Accelerate / CoreImage; macOS-OK.
|
|
51
|
+
// * KeyframeGate.swift — Foundation + simd; macOS-OK.
|
|
52
|
+
//
|
|
53
|
+
// What's NOT (intentionally):
|
|
54
|
+
// * Anything with `import UIKit` / `import ARKit` — iOS only.
|
|
55
|
+
// CocoaPods compiles them for the host app via the podspec
|
|
56
|
+
// source_files glob; SwiftPM macOS doesn't need them.
|
|
57
|
+
// * .mm / .m / .h files — same. Picked up by CocoaPods.
|
|
58
|
+
// * RN-bridge Swift files (`*Bridge.swift`) — `import React`,
|
|
59
|
+
// not a SwiftPM dep.
|
|
60
|
+
//
|
|
61
|
+
// The Frame Processor plugin's Swift⇄ObjC selector pin
|
|
62
|
+
// (formerly relied on by `FrameProcessorPluginSelectorTests`)
|
|
63
|
+
// is enforced as a compile-time `#selector(...)` reference
|
|
64
|
+
// inside `IncrementalStitcher.swift` itself — see the
|
|
65
|
+
// `_consumeFrameFromPluginSelectorPin` static. Drift breaks
|
|
66
|
+
// the SDK build, which is a stronger guarantee than a test
|
|
67
|
+
// that needs iOS-Simulator infrastructure to run.
|
|
68
|
+
sources: [
|
|
69
|
+
"QualityChecker.swift",
|
|
70
|
+
// KeyframeGate.swift depends on `KeyframeGateBridge` (ObjC
|
|
71
|
+
// class in .mm) and `RNSARFramePose` (from a UIKit-using
|
|
72
|
+
// Swift file), so it doesn't compile standalone under
|
|
73
|
+
// SwiftPM on macOS — only the CocoaPods build sees the
|
|
74
|
+
// full type graph.
|
|
61
75
|
]
|
|
62
76
|
),
|
|
63
77
|
.testTarget(
|
|
@@ -476,6 +476,13 @@ public final class IncrementalStitcher: NSObject {
|
|
|
476
476
|
|
|
477
477
|
private override init() {
|
|
478
478
|
super.init()
|
|
479
|
+
// F8.3.H2 — runtime check that Swift's auto-bridged ObjC
|
|
480
|
+
// selector for `consumeFrameFromPlugin(...)` matches the
|
|
481
|
+
// selector string the plugin's .mm dispatches. Asserts in
|
|
482
|
+
// dev builds; no-ops in release. See the
|
|
483
|
+
// `_consumeFrameFromPluginSelectorPin` declaration below for
|
|
484
|
+
// the full rationale.
|
|
485
|
+
IncrementalStitcher._verifyConsumeFrameFromPluginSelector()
|
|
479
486
|
}
|
|
480
487
|
|
|
481
488
|
/// 2026-05-18 (iOS cross-orientation fix) — bridge entry-point
|
|
@@ -3158,4 +3165,57 @@ extension IncrementalStitcher {
|
|
|
3158
3165
|
)
|
|
3159
3166
|
consumeFrame(pixelBuffer: pixelBuffer, pose: pose)
|
|
3160
3167
|
}
|
|
3168
|
+
|
|
3169
|
+
// F8.3.H2 — compile-time + runtime guard for the Swift⇄ObjC
|
|
3170
|
+
// selector contract that `KeyframeGateFrameProcessor.mm`
|
|
3171
|
+
// depends on.
|
|
3172
|
+
//
|
|
3173
|
+
// The .mm file forward-declares `IncrementalStitcher` and
|
|
3174
|
+
// dispatches `[shared consumeFrameFromPluginWithPixelBuffer:tx:
|
|
3175
|
+
// …:trackingStateRaw:]` by NAME — ObjC's late-binding means
|
|
3176
|
+
// signature drift would silently link but crash at runtime
|
|
3177
|
+
// with `NSInvalidArgumentException: unrecognized selector`
|
|
3178
|
+
// on the first non-AR frame.
|
|
3179
|
+
//
|
|
3180
|
+
// This `#selector(...)` reference forces the Swift compiler
|
|
3181
|
+
// to resolve the exact method signature. If anyone renames a
|
|
3182
|
+
// parameter label or adds/removes an argument, the
|
|
3183
|
+
// `_consumeFrameFromPluginSelectorPin` expression fails to
|
|
3184
|
+
// compile — the SDK won't build until the .mm's forward
|
|
3185
|
+
// declaration is updated to match. Stronger guarantee than a
|
|
3186
|
+
// test that needs iOS-Simulator infrastructure to run.
|
|
3187
|
+
//
|
|
3188
|
+
// The runtime check below additionally pins the exact
|
|
3189
|
+
// SELECTOR STRING the .mm dispatches. In dev/debug builds it
|
|
3190
|
+
// asserts; in release builds it's a no-op (the static let is
|
|
3191
|
+
// initialised lazily and never read otherwise, so the runtime
|
|
3192
|
+
// cost is one-time + tiny). Drift between Swift's auto-
|
|
3193
|
+
// generated selector name and the .mm's expected string
|
|
3194
|
+
// (e.g., if Swift's bridging rules change) trips the assert.
|
|
3195
|
+
private static let _consumeFrameFromPluginSelectorPin: Selector =
|
|
3196
|
+
#selector(IncrementalStitcher.consumeFrameFromPlugin(
|
|
3197
|
+
pixelBuffer:
|
|
3198
|
+
tx: ty: tz:
|
|
3199
|
+
qx: qy: qz: qw:
|
|
3200
|
+
fx: fy: cx: cy:
|
|
3201
|
+
imageWidth: imageHeight:
|
|
3202
|
+
timestampMs:
|
|
3203
|
+
trackingStateRaw:))
|
|
3204
|
+
|
|
3205
|
+
@inline(never)
|
|
3206
|
+
private static func _verifyConsumeFrameFromPluginSelector() {
|
|
3207
|
+
let expected =
|
|
3208
|
+
"consumeFrameFromPluginWithPixelBuffer:tx:ty:tz:"
|
|
3209
|
+
+ "qx:qy:qz:qw:fx:fy:cx:cy:"
|
|
3210
|
+
+ "imageWidth:imageHeight:timestampMs:trackingStateRaw:"
|
|
3211
|
+
let actual = NSStringFromSelector(_consumeFrameFromPluginSelectorPin)
|
|
3212
|
+
assert(
|
|
3213
|
+
actual == expected,
|
|
3214
|
+
"Frame Processor selector drift — Swift's auto-bridged "
|
|
3215
|
+
+ "ObjC selector for consumeFrameFromPlugin is "
|
|
3216
|
+
+ "\(actual) but KeyframeGateFrameProcessor.mm's "
|
|
3217
|
+
+ "forward declaration expects \(expected). Update the "
|
|
3218
|
+
+ ".mm to match (or fix the assumption here).",
|
|
3219
|
+
)
|
|
3220
|
+
}
|
|
3161
3221
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-image-stitcher",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
package/src/camera/Camera.tsx
CHANGED
|
@@ -235,6 +235,29 @@ export interface CameraProps {
|
|
|
235
235
|
showSettingsButton?: boolean;
|
|
236
236
|
style?: StyleProp<ViewStyle>;
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Which incremental stitcher engine to drive. Default
|
|
240
|
+
* `'batch-keyframe'` — collects accepted JPEGs and runs
|
|
241
|
+
* `cv::Stitcher` once at finalize time. This is the v0.4+
|
|
242
|
+
* production default and what the v0.5 Frame Processor migration
|
|
243
|
+
* exercises.
|
|
244
|
+
*
|
|
245
|
+
* Switch to a live engine (`'firstwins-rectilinear'` or
|
|
246
|
+
* `'hybrid'`) for low-latency in-flight stitching. Live engines
|
|
247
|
+
* exercise the F8.6 pixel-buffer ingest path (skipping the JPEG
|
|
248
|
+
* encode/decode round-trip; ~30–50 ms saved per accept) when the
|
|
249
|
+
* Frame Processor driver is active.
|
|
250
|
+
*
|
|
251
|
+
* See `docs/f8-frame-processor-plan.md` and the v0.5.0
|
|
252
|
+
* CHANGELOG for the trade-offs between batch-keyframe and live
|
|
253
|
+
* engines.
|
|
254
|
+
*/
|
|
255
|
+
engine?: 'batch-keyframe'
|
|
256
|
+
| 'hybrid'
|
|
257
|
+
| 'slitscan-rotate' | 'slitscan-both'
|
|
258
|
+
| 'firstwins' | 'firstwins-zoomed' | 'firstwins-rectilinear'
|
|
259
|
+
| 'slitscan';
|
|
260
|
+
|
|
238
261
|
/**
|
|
239
262
|
* Optional destination directory for captures. When set, the lib
|
|
240
263
|
* lands tap-photos at `${outputDir}/photo-${ts}.jpg` and panoramas
|
|
@@ -587,6 +610,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
587
610
|
onError,
|
|
588
611
|
frameProcessor: hostFrameProcessor,
|
|
589
612
|
legacyDriver = false,
|
|
613
|
+
engine = 'batch-keyframe',
|
|
590
614
|
} = props;
|
|
591
615
|
|
|
592
616
|
const insets = useSafeAreaInsets();
|
|
@@ -1031,7 +1055,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1031
1055
|
composeHeight: 1080,
|
|
1032
1056
|
canvasWidth: 5000,
|
|
1033
1057
|
canvasHeight: 5000,
|
|
1034
|
-
engine
|
|
1058
|
+
engine,
|
|
1035
1059
|
config: panoramaSettingsToNativeConfig({
|
|
1036
1060
|
...settings,
|
|
1037
1061
|
captureSource: effectiveCaptureSource,
|
|
@@ -1079,6 +1103,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1079
1103
|
jsDriver,
|
|
1080
1104
|
fpDriver,
|
|
1081
1105
|
legacyDriver,
|
|
1106
|
+
engine,
|
|
1082
1107
|
onError,
|
|
1083
1108
|
]);
|
|
1084
1109
|
|