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 +165 -0
- package/README.md +1 -0
- package/android/build.gradle +33 -0
- package/android/src/main/java/io/imagestitcher/rn/CvFlowGateFrameProcessor.kt +163 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +148 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +431 -23
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +65 -7
- package/dist/camera/Camera.d.ts +68 -1
- package/dist/camera/Camera.js +102 -16
- package/dist/camera/CameraView.d.ts +17 -5
- package/dist/camera/CameraView.js +28 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -1
- package/dist/stitching/incremental.d.ts +13 -4
- package/dist/stitching/useFrameProcessorDriver.d.ts +148 -0
- package/dist/stitching/useFrameProcessorDriver.js +321 -0
- package/dist/stitching/useIncrementalJSDriver.js +21 -0
- package/ios/Package.swift +35 -21
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +188 -8
- package/ios/Sources/RNImageStitcher/KeyframeGateFrameProcessor.mm +196 -0
- package/package.json +3 -1
- package/src/camera/Camera.tsx +190 -15
- package/src/camera/CameraView.tsx +50 -0
- package/src/index.ts +12 -0
- package/src/stitching/incremental.ts +12 -3
- package/src/stitching/useFrameProcessorDriver.ts +407 -0
- package/src/stitching/useIncrementalJSDriver.ts +24 -0
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
|
package/android/build.gradle
CHANGED
|
@@ -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) {
|