react-native-image-stitcher 0.4.1 → 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 CHANGED
@@ -16,6 +16,171 @@ 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
+
85
+ ## [0.5.0] — 2026-05-25
86
+
87
+ ### Added — F8 Frame Processor port
88
+
89
+ `<Camera>` now drives **non-AR captures through a vision-camera
90
+ Frame Processor** on the camera producer thread instead of the
91
+ 4 Hz `takeSnapshot` → JPEG → cache-file path the v0.4 series used.
92
+
93
+ - **`useFrameProcessorDriver`** (`src/stitching/useFrameProcessorDriver.ts`)
94
+ — new hook with the same `{ start, stop, frameProcessor,
95
+ isRunning }` shape as the legacy `useIncrementalJSDriver`. Gyro
96
+ yaw / pitch / **roll** are integrated on the JS thread and
97
+ published via `useSharedValue` so the worklet reads pose
98
+ zero-hop. Plugin acquisition uses a mount-once + 16 ms
99
+ setTimeout retry pattern to side-step the vision-camera
100
+ registry init race.
101
+ - **`cv_flow_gate_process_frame` JSI plugin** — registered on both
102
+ platforms:
103
+ - iOS: `ios/Sources/RNImageStitcher/KeyframeGateFrameProcessor.mm`
104
+ + `@objc IncrementalStitcher.consumeFrameFromPlugin(...)`
105
+ wrapper. `CVPixelBuffer` flows end-to-end into
106
+ `IncrementalStitcher.consumeFrame` — the SAME entry point AR
107
+ mode already uses. Zero JPEG round-trip on accept.
108
+ - Android: `android/src/main/java/io/imagestitcher/rn/CvFlowGateFrameProcessor.kt`
109
+ + Kotlin `consumeFrameFromPlugin(...)` wrapper. Extracts the
110
+ Y plane on the producer thread, encodes inline JPEG on accept
111
+ via the existing `YuvImageConverter`, hands the path to
112
+ `ingestFromARCameraView`. Pixel-buffer parity tracked as F8.6.
113
+ - **`frameSourceMode: 'frameProcessor'`** in
114
+ `IncrementalStitcher.start()` options — flips
115
+ `frameProcessorIngestEnabled` ON so the plugin's producer-thread
116
+ feed reaches the engine. Default for non-AR captures from v0.5.
117
+ - **`legacyDriver?: boolean`** prop on `<Camera>` — opt-in escape
118
+ hatch back to `useIncrementalJSDriver` for hosts that hit a
119
+ vision-camera incompatibility. Will be removed in v0.6.
120
+ - **`VISION_CAMERA_RUNTIME` error code** for vision-camera
121
+ runtime errors that aren't transient lifecycle events.
122
+ - **Roll axis** (gyro-Z) in the synthesised pose quaternion —
123
+ `q = q_yaw * q_pitch * q_roll`. Field captures with wrist-twist
124
+ no longer lie to the cv::Stitcher's intrinsic estimator.
125
+
126
+ ### Changed
127
+
128
+ - Default non-AR driver is now `useFrameProcessorDriver`. Hosts
129
+ using `<Camera>` opt in transparently — no code change needed
130
+ unless you want the legacy path (`legacyDriver={true}`).
131
+ - `host-supplied frameProcessor` prop on `<Camera>` is now treated
132
+ as a legacy escape hatch: silently overridden by the SDK driver
133
+ in default mode with a one-shot `console.warn`.
134
+
135
+ ### Deprecated
136
+
137
+ - **`useIncrementalJSDriver`** — works through v0.5, removed in
138
+ v0.6. Hosts that drove non-AR captures with this hook should
139
+ migrate to letting `<Camera>` do it by default
140
+ (`legacyDriver` unset). The hook now emits a one-shot
141
+ `console.warn` from its `start()` call.
142
+
143
+ ### Fixed
144
+
145
+ - **Vision-camera transient lifecycle errors** (screen-lock,
146
+ app-switch, DoNotDisturb, MDM camera restriction) are now
147
+ filtered inside `<CameraView>` instead of propagating to the
148
+ host's `onError`. Auto-recovery happens on resume; hosts no
149
+ longer get spurious crash reports on every phone-lock.
150
+
151
+ ### Added — peer dependency
152
+
153
+ - **`react-native-worklets-core`** is now a declared peer
154
+ dependency (`>=1.3.0`). It was already required transitively
155
+ via `react-native-vision-camera@^4`; the explicit declaration
156
+ documents the contract.
157
+
158
+ ### Tracking — known follow-ups (don't gate this release)
159
+
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.
183
+
19
184
  ## [0.4.1] — 2026-05-23
20
185
 
21
186
  ### Fixed
package/README.md CHANGED
@@ -160,6 +160,7 @@ The component owns the runtime state; the parent persists across launches via th
160
160
  | **Android namespace** | `io.imagestitcher.rn`. |
161
161
  | **Stitching pipeline** | Shared C++ under `cpp/stitcher.cpp` invoked from both iOS Obj-C++ and Android JNI. PANORAMA + SCANS modes; C+D progressive-confidence retry over keyframes. |
162
162
  | **Two capture-source paths** | AR uses ARKit (iOS) / ARCore (Android) pose stream. Non-AR uses vision-camera + IMU integration via `useIMUTranslationGate`. |
163
+ | **Frame Processor driver (v0.5+)** | Non-AR captures evaluate the keyframe gate on the camera producer thread at native frame rate via a vision-camera Frame Processor (`cv_flow_gate_process_frame`). iOS passes `CVPixelBuffer` end-to-end; Android writes a Y-plane-derived JPEG on accept. Opt-out via `<Camera legacyDriver />` for one minor cycle. See `docs/f8-frame-processor-plan.md` for the design. |
163
164
  | **Two supported pan modes** | Landscape phone + vertical pan; portrait phone + horizontal pan. Any other combination is a user deviation, not a supported mode. |
164
165
 
165
166
  ## License
@@ -215,6 +215,39 @@ dependencies {
215
215
  // on demand (~30 MB) the first time the user opens an AR
216
216
  // capture screen on a supported device.
217
217
  implementation "com.google.ar:core:1.45.0"
218
+
219
+ // F8.4 — vision-camera as a compile-time peer dep. Same
220
+ // `compileOnly` pattern as React Native above: host apps that
221
+ // wire `<Camera>` already include the autolinked
222
+ // `:react-native-vision-camera` Gradle project; we just need
223
+ // `com.mrousavy.camera.frameprocessors.*` types on the compile
224
+ // classpath to build `CvFlowGateFrameProcessor.kt` + the
225
+ // registration in `RNImageStitcherPackage`. Runtime
226
+ // resolution is the host's responsibility —
227
+ // `RNImageStitcherPackage`'s static initialiser catches
228
+ // `NoClassDefFoundError` so the SDK still loads if a non-
229
+ // camera consumer omits the dep.
230
+ //
231
+ // `findProject(...)` guard: lets the SDK still compile in
232
+ // hosts that haven't installed react-native-vision-camera.
233
+ // The plugin file's import resolution will fail in that case,
234
+ // which is fine — we conditionally exclude the plugin sources
235
+ // below.
236
+ if (findProject(':react-native-vision-camera') != null) {
237
+ compileOnly project(':react-native-vision-camera')
238
+ // CameraX `ImageProxy` / `ImageInfo` types — vision-camera
239
+ // exposes them through `Frame.getImageProxy()` and we need
240
+ // the compile-time class for `imageInfo.rotationDegrees`.
241
+ // `compileOnly` because the host app already ships these via
242
+ // vision-camera's transitive runtime dep.
243
+ compileOnly "androidx.camera:camera-core:1.5.0-alpha03"
244
+ } else {
245
+ // Without vision-camera on the classpath the Frame
246
+ // Processor plugin source can't compile (imports unresolved).
247
+ // Exclude it from the source set so the rest of the SDK
248
+ // still builds for non-camera consumers.
249
+ android.sourceSets.main.java.exclude '**/CvFlowGateFrameProcessor.kt'
250
+ }
218
251
  }
219
252
 
220
253
  // Helper from the React Native gradle convention to read host-app
@@ -0,0 +1,163 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn
3
+
4
+ import android.media.Image
5
+ import androidx.annotation.Keep
6
+ import com.facebook.proguard.annotations.DoNotStrip
7
+ import com.mrousavy.camera.frameprocessors.Frame
8
+ import com.mrousavy.camera.frameprocessors.FrameProcessorPlugin
9
+ import com.mrousavy.camera.frameprocessors.VisionCameraProxy
10
+
11
+ /**
12
+ * F8.4 — Android vision-camera Frame Processor plugin that mirrors
13
+ * iOS' `KeyframeGateFrameProcessor.mm`.
14
+ *
15
+ * Plugin name (must match the iOS plugin):
16
+ * `cv_flow_gate_process_frame`
17
+ *
18
+ * JS-side usage is identical to iOS — the same `useFrameProcessorDriver`
19
+ * hook + the same `plugin.call(frame, args)` contract. The JS layer
20
+ * is 100% platform-agnostic.
21
+ *
22
+ * ## What this plugin does
23
+ *
24
+ * Per producer-thread frame:
25
+ * 1. Pull the `android.media.Image` out of vision-camera's `Frame`.
26
+ * 2. Extract pose primitives from the worklet's `params` dict
27
+ * (defaults safe for non-AR: tx/ty/tz=0, qw=1 identity, fx/fy=0
28
+ * → engine uses 65°×50° FoV fallback).
29
+ * 3. Call `IncrementalStitcher.consumeFrameFromPlugin(image, …)`
30
+ * which:
31
+ * - Drops the call if `frameSourceMode != "frameProcessor"`
32
+ * (prevents double-feeding the engine alongside the legacy
33
+ * `processFrameAtPath` path).
34
+ * - Otherwise: extracts the Y plane, evaluates the keyframe
35
+ * gate via `KeyframeGate.evaluateWithFrame`, encodes the
36
+ * accepted frame to JPEG synchronously, and hands the path
37
+ * to the existing `ingestFromARCameraView` engine entry.
38
+ *
39
+ * ## Lifetime / threading
40
+ *
41
+ * The `Frame` (and the underlying `Image` / `ImageProxy`) is valid
42
+ * only for the duration of this callback — vision-camera closes it
43
+ * on return. All Image access (including the JPEG encode on
44
+ * accept) MUST happen synchronously inside `callback()`.
45
+ *
46
+ * ## Divergence vs iOS
47
+ *
48
+ * iOS keeps the `CVPixelBuffer` reachable end-to-end into the
49
+ * stitcher engine (zero-copy). Android's engine entry point
50
+ * (`ingestFromARCameraView`) takes a Y `ByteArray` + a JPEG file
51
+ * path, so we copy Y bytes here and encode JPEG inline on accept.
52
+ * Cross-platform parity at the engine level is tracked as F8.6.
53
+ *
54
+ * ## Registration
55
+ *
56
+ * Registered in `RNImageStitcherPackage.kt`'s companion-object
57
+ * static initialiser via `FrameProcessorPluginRegistry`. Vision-
58
+ * camera docs say "should be called as soon as possible — ideally
59
+ * on app start or in a static initialiser"; the package class is
60
+ * loaded by RN autolinking at app startup, so the registration
61
+ * fires before any JS Frame Processor can `initFrameProcessorPlugin`
62
+ * the plugin.
63
+ */
64
+ @DoNotStrip
65
+ @Keep
66
+ class CvFlowGateFrameProcessor(
67
+ proxy: VisionCameraProxy,
68
+ options: Map<String, Any>?,
69
+ ) : FrameProcessorPlugin() {
70
+
71
+ // The `proxy` and `options` are accepted by the
72
+ // `PluginInitializer` contract but the plugin is stateless —
73
+ // all gate tunables live on `IncrementalStitcher` and are
74
+ // configured at its `start()` time from the host-app settings.
75
+ // The plugin is a thin pose-injector.
76
+ //
77
+ // Lint suppressors: we intentionally don't read these.
78
+ @Suppress("unused", "UNUSED_PARAMETER")
79
+ private val unused = proxy to options
80
+
81
+ @Suppress("UNCHECKED_CAST")
82
+ override fun callback(frame: Frame, params: Map<String, Any>?): Any? {
83
+ // Frame may throw `FrameInvalidError` if vision-camera has
84
+ // already released it. Defensive: swallow and return.
85
+ val image: Image = try {
86
+ frame.image
87
+ } catch (e: Throwable) {
88
+ return mapOf("submitted" to false, "error" to "frame invalid")
89
+ }
90
+
91
+ val stitcher = IncrementalStitcher.bridgeInstance
92
+ if (stitcher == null) {
93
+ // Module never registered (host hasn't initialised the
94
+ // React bridge yet, or autolinking skipped us). Drop
95
+ // the call; JS sees `submitted: false` and can detect.
96
+ return mapOf("submitted" to false, "error" to "stitcher not registered")
97
+ }
98
+
99
+ // F8.4-Android-c rotation fix: read CameraX's authoritative
100
+ // "rotation needed to display upright" value via
101
+ // `imageProxy.imageInfo.rotationDegrees`.
102
+ //
103
+ // The earlier attempt used `Frame.orientation` (the enum),
104
+ // but vision-camera's `getOrientation()` returns the REVERSE
105
+ // of the rotation-needed value (see Frame.java:88, the
106
+ // "Reverse it" comment). Trying to invert the enum
107
+ // ourselves was off by 90° on the A35. The raw
108
+ // `imageInfo.rotationDegrees` is unambiguous.
109
+ //
110
+ // Used by the engine's JPEG encoder to write the correct
111
+ // EXIF Orientation tag so thumbnails (and any other
112
+ // EXIF-honoring viewer) display upright. The raw cv::Mat
113
+ // the stitcher sees is unaffected — see consumeFrameFromPlugin
114
+ // docstring for the no-double-rotation rationale.
115
+ val sensorRotationDegrees = try {
116
+ frame.imageProxy.imageInfo.rotationDegrees
117
+ } catch (_: Throwable) {
118
+ // FrameInvalidError or null mid-callback — treat as
119
+ // portrait back-camera default (sensor mounted 90° CW).
120
+ 90
121
+ }
122
+
123
+ stitcher.consumeFrameFromPlugin(
124
+ image = image,
125
+ tx = argDouble(params, "tx", 0.0),
126
+ ty = argDouble(params, "ty", 0.0),
127
+ tz = argDouble(params, "tz", 0.0),
128
+ qx = argDouble(params, "qx", 0.0),
129
+ qy = argDouble(params, "qy", 0.0),
130
+ qz = argDouble(params, "qz", 0.0),
131
+ qw = argDouble(params, "qw", 1.0),
132
+ fx = argDouble(params, "fx", 0.0),
133
+ fy = argDouble(params, "fy", 0.0),
134
+ cx = argDouble(params, "cx", image.width / 2.0),
135
+ cy = argDouble(params, "cy", image.height / 2.0),
136
+ timestampMs = argDouble(params, "timestampMs", 0.0),
137
+ // Default 2 == `.tracking` so the worklet doesn't need
138
+ // to send a tracking-state field on every frame.
139
+ trackingStateRaw = argInt(params, "trackingStateRaw", 2),
140
+ sensorRotationDegrees = sensorRotationDegrees,
141
+ )
142
+
143
+ return mapOf("submitted" to true)
144
+ }
145
+
146
+ private fun argDouble(args: Map<String, Any>?, key: String, default: Double): Double {
147
+ if (args == null) return default
148
+ val v = args[key] ?: return default
149
+ return when (v) {
150
+ is Number -> v.toDouble()
151
+ else -> default
152
+ }
153
+ }
154
+
155
+ private fun argInt(args: Map<String, Any>?, key: String, default: Int): Int {
156
+ if (args == null) return default
157
+ val v = args[key] ?: return default
158
+ return when (v) {
159
+ is Number -> v.toInt()
160
+ else -> default
161
+ }
162
+ }
163
+ }
@@ -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) {