react-native-image-stitcher 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +200 -8
- package/android/src/main/java/io/imagestitcher/rn/CvFlowGateFrameProcessor.kt +2 -2
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +120 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +266 -385
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +6 -3
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +17 -30
- package/dist/camera/Camera.d.ts +29 -27
- package/dist/camera/Camera.js +48 -79
- package/dist/index.d.ts +0 -2
- package/dist/index.js +4 -6
- package/dist/stitching/incremental.d.ts +10 -11
- package/dist/stitching/useFrameProcessorDriver.d.ts +7 -6
- package/dist/stitching/useFrameProcessorDriver.js +12 -11
- package/ios/Package.swift +35 -21
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +85 -206
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +0 -8
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +6 -126
- package/ios/Sources/RNImageStitcher/KeyframeGateFrameProcessor.mm +6 -6
- package/package.json +1 -1
- package/src/camera/Camera.tsx +83 -107
- package/src/index.ts +3 -8
- package/src/stitching/incremental.ts +10 -11
- package/src/stitching/useFrameProcessorDriver.ts +12 -11
- package/dist/stitching/useIncrementalJSDriver.d.ts +0 -74
- package/dist/stitching/useIncrementalJSDriver.js +0 -220
- package/src/stitching/useIncrementalJSDriver.ts +0 -297
|
@@ -58,20 +58,20 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|
|
58
58
|
*
|
|
59
59
|
* What the bridge exposes to JS:
|
|
60
60
|
* - start(options) — spin up the engine
|
|
61
|
-
* - processFrameAtPath() — feed a JPEG path + pose; engine returns
|
|
62
|
-
* the same outcome enum iOS emits as events
|
|
63
61
|
* - finalize(options) — write the final panorama and reset
|
|
64
62
|
* - cancel() — abort without producing output
|
|
65
63
|
* - getState() — pull the latest state on demand
|
|
66
|
-
* -
|
|
67
|
-
*
|
|
64
|
+
* - refinePanorama() — re-run the C++ stitcher over saved keyframes
|
|
65
|
+
* - cleanupKeyframes() — GC stale per-capture keyframe directories
|
|
66
|
+
* - Event "IncrementalStateUpdate" emitted on every accepted frame
|
|
68
67
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
68
|
+
* How frames reach the engine (no JS-driven path post-v0.6):
|
|
69
|
+
* - AR mode: `RNSARCameraView` calls `ingestFromARCameraView(...)`
|
|
70
|
+
* once per ARCore Frame from its scene-update listener.
|
|
71
|
+
* - Non-AR mode: the vision-camera Frame Processor plugin
|
|
72
|
+
* (`CvFlowGateFrameProcessor`) calls `consumeFrameFromPlugin(...)`
|
|
73
|
+
* on the producer thread, gated by `frameProcessorIngestEnabled`.
|
|
74
|
+
* The pre-v0.6 `processFrameAtPath` JS-driver entry point is gone.
|
|
75
75
|
*/
|
|
76
76
|
class IncrementalStitcher(
|
|
77
77
|
private val reactContext: ReactApplicationContext,
|
|
@@ -115,9 +115,9 @@ class IncrementalStitcher(
|
|
|
115
115
|
// The MVP gate is frame-count-based ("accept every Nth frame
|
|
116
116
|
// until cap"). iOS uses a pose-based gate (overlap < threshold)
|
|
117
117
|
// — adding that here is a follow-up that needs ARCore-pose
|
|
118
|
-
// accumulation across
|
|
119
|
-
// N-th frame is good enough to validate end-to-end
|
|
120
|
-
// parity.
|
|
118
|
+
// accumulation across `ingestFromARCameraView` calls. For now,
|
|
119
|
+
// every N-th frame is good enough to validate end-to-end
|
|
120
|
+
// stitching parity.
|
|
121
121
|
private var batchKeyframeMode: Boolean = false
|
|
122
122
|
private val batchKeyframePaths: MutableList<String> = mutableListOf()
|
|
123
123
|
private var batchKeyframeFrameCounter: Int = 0
|
|
@@ -217,9 +217,10 @@ class IncrementalStitcher(
|
|
|
217
217
|
|
|
218
218
|
/// 2026-05-22 (audit F5) — Frame counter for the flowEvalEveryNFrames
|
|
219
219
|
/// throttle. Incremented on every per-frame entry point
|
|
220
|
-
/// (ingestFromARCameraView for AR,
|
|
221
|
-
/// gate evaluation only runs when
|
|
222
|
-
/// so frame #1 always evaluates
|
|
220
|
+
/// (`ingestFromARCameraView` for AR mode, `consumeFrameFromPlugin`
|
|
221
|
+
/// for non-AR Frame Processor mode); gate evaluation only runs when
|
|
222
|
+
/// (counter - 1) % evalCadence == 0 so frame #1 always evaluates
|
|
223
|
+
/// regardless of cadence. iOS parity:
|
|
223
224
|
/// IncrementalStitcher.swift:2459-2471 (`consumeFrameCounter`).
|
|
224
225
|
/// Reset to 0 on each `start()` call.
|
|
225
226
|
private var consumeFrameCounter: Long = 0L
|
|
@@ -229,22 +230,23 @@ class IncrementalStitcher(
|
|
|
229
230
|
/// F8.4 — gate for `consumeFrameFromPlugin` (the vision-camera
|
|
230
231
|
/// Frame Processor producer-thread entry point on Android).
|
|
231
232
|
/// TRUE only when the current capture was started with
|
|
232
|
-
/// `frameSourceMode == "frameProcessor"`. In
|
|
233
|
-
/// (
|
|
234
|
-
/// `
|
|
235
|
-
///
|
|
236
|
-
/// the
|
|
237
|
-
///
|
|
233
|
+
/// `frameSourceMode == "frameProcessor"`. In AR mode
|
|
234
|
+
/// (`frameSourceMode == "arSession"`) the plugin would double-feed
|
|
235
|
+
/// the engine alongside `ingestFromARCameraView` — bytes from the
|
|
236
|
+
/// producer thread + bytes from the ARCore Frame listener, racing
|
|
237
|
+
/// on the same workScope serial dispatcher — so we drop the
|
|
238
|
+
/// producer-thread call.
|
|
238
239
|
///
|
|
239
240
|
/// AtomicBoolean: producer thread reads lock-free, JS thread
|
|
240
241
|
/// (start/cancel/finalize) writes via `set()`/`compareAndSet()`.
|
|
241
242
|
/// Mirror of iOS' `frameProcessorIngestEnabled` ivar.
|
|
242
243
|
private val frameProcessorIngestEnabled = AtomicBoolean(false)
|
|
243
|
-
/// Critic #5 fix: serial dispatcher so concurrent
|
|
244
|
-
///
|
|
245
|
-
/// `
|
|
246
|
-
///
|
|
247
|
-
///
|
|
244
|
+
/// Critic #5 fix: serial dispatcher so concurrent per-frame
|
|
245
|
+
/// ingest calls (today: `ingestFromARCameraView` in AR mode,
|
|
246
|
+
/// `consumeFrameFromPlugin` in frame-processor mode) can't race
|
|
247
|
+
/// on the engine's canvas. `limitedParallelism(1)` guarantees
|
|
248
|
+
/// one-at-a-time execution while still backing onto the Default
|
|
249
|
+
/// pool — matches iOS' `workQueue` (DispatchQueue.serial).
|
|
248
250
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
|
249
251
|
private val workScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1))
|
|
250
252
|
|
|
@@ -275,17 +277,6 @@ class IncrementalStitcher(
|
|
|
275
277
|
/// on start/stop so the view feeds frames only during a capture.
|
|
276
278
|
@Volatile private var arCameraViewRef: RNSARCameraView? = null
|
|
277
279
|
|
|
278
|
-
/// 2026-05-21 (v0.3) — public-to-the-view-file getter so
|
|
279
|
-
/// `RNSARCameraView.forwardToIncremental` can ask whether the
|
|
280
|
-
/// currently-running engine is the batch-keyframe path (v0.3
|
|
281
|
-
/// Y-plane pixel-data flow) or the legacy hybrid/firstwins live
|
|
282
|
-
/// engine (which still needs a per-frame JPEG path). Used to
|
|
283
|
-
/// elide the eager JPEG encode for batchKeyframe mode (the
|
|
284
|
-
/// production Camera component's path); the legacy live engine
|
|
285
|
-
/// still pays the per-frame JPEG cost.
|
|
286
|
-
internal val isBatchKeyframeMode: Boolean
|
|
287
|
-
get() = batchKeyframeMode
|
|
288
|
-
|
|
289
280
|
init {
|
|
290
281
|
// Static back-pointer so `RNSARCameraView` can call into
|
|
291
282
|
// the singleton-style bridge module without a DI dance. RN
|
|
@@ -327,11 +318,16 @@ class IncrementalStitcher(
|
|
|
327
318
|
// F8.4 — frameSourceMode honoured on Android. Pre-F8.4,
|
|
328
319
|
// Android ignored this option (only iOS interpreted it).
|
|
329
320
|
// Now `"frameProcessor"` unlocks `consumeFrameFromPlugin`'s
|
|
330
|
-
// producer-thread ingest path;
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
321
|
+
// producer-thread ingest path; `"arSession"` (the default)
|
|
322
|
+
// keeps it dormant so the ARCore-driven
|
|
323
|
+
// `ingestFromARCameraView` path runs unmodified.
|
|
324
|
+
// Default to "arSession" for parity with iOS — pre-v0.6 this
|
|
325
|
+
// defaulted to "jsDriver", but that mode (the JS-driver
|
|
326
|
+
// processFrameAtPath path) was removed in v0.6. Raw
|
|
327
|
+
// NativeModules callers that omit `frameSourceMode` get the
|
|
328
|
+
// AR-mode behaviour (the production <Camera> always sets
|
|
329
|
+
// 'arSession' explicitly for AR captures anyway).
|
|
330
|
+
val frameSourceMode = options.getString("frameSourceMode") ?: "arSession"
|
|
335
331
|
frameProcessorIngestEnabled.set(frameSourceMode == "frameProcessor")
|
|
336
332
|
val rotation = options.getIntOrDefault("frameRotationDegrees", 90)
|
|
337
333
|
val composeW = options.getIntOrDefault("composeWidth", 960)
|
|
@@ -585,14 +581,6 @@ class IncrementalStitcher(
|
|
|
585
581
|
}
|
|
586
582
|
}
|
|
587
583
|
|
|
588
|
-
/**
|
|
589
|
-
* Feed one frame at a JPEG path into the engine. Pose inputs
|
|
590
|
-
* drive the same FoV-overlap gate as iOS. When a pose source
|
|
591
|
-
* isn't available pass yaw=0, pitch=0, fovHorizDegrees=0 — the
|
|
592
|
-
* engine treats fov<=0 as a sentinel for "no intrinsics" and
|
|
593
|
-
* substitutes a 65° default, so frames will still be processed,
|
|
594
|
-
* just less gated.
|
|
595
|
-
*/
|
|
596
584
|
/**
|
|
597
585
|
* Copy a (non-persistent) source JPEG to a persistent per-keyframe
|
|
598
586
|
* path under the React context's cache dir. The ARCameraView's
|
|
@@ -614,67 +602,6 @@ class IncrementalStitcher(
|
|
|
614
602
|
* for a Phase 3 follow-up; this MVP is just enough to make
|
|
615
603
|
* batch-keyframe work end-to-end on Android.
|
|
616
604
|
*/
|
|
617
|
-
/**
|
|
618
|
-
* 2026-05-21 (v0.3) — JPEG-to-grayscale decode for the JS-driver
|
|
619
|
-
* (non-AR) keyframe-gate path. Used by the batch-keyframe branch
|
|
620
|
-
* of processFrameAtPath to feed the C++ KeyframeGate with real
|
|
621
|
-
* pixel data so its Flow strategy actually runs (pre-0.3 the
|
|
622
|
-
* non-AR call was `evaluate(pose, null)` which silently fell back
|
|
623
|
-
* to the Pose strategy because no pixel data was supplied).
|
|
624
|
-
*
|
|
625
|
-
* Uses OpenCV's Imgcodecs.imread with IMREAD_GRAYSCALE, which
|
|
626
|
-
* decodes the JPEG straight into a single-channel CV_8UC1 Mat —
|
|
627
|
-
* faster than BitmapFactory + manual luma loop (~10-20 ms here
|
|
628
|
-
* vs ~100+ ms in interpreted Kotlin for a 1920×1080 image).
|
|
629
|
-
*
|
|
630
|
-
* The non-AR snapshot cadence is ~4 FPS so the per-call cost is
|
|
631
|
-
* well under the inter-snapshot interval. v0.4 will replace
|
|
632
|
-
* this code path entirely by moving non-AR capture to
|
|
633
|
-
* vision-camera's Frame Processor API (which delivers raw
|
|
634
|
-
* pixels directly with no JPEG roundtrip). Tracked at issue #11.
|
|
635
|
-
*
|
|
636
|
-
* Returns null when the decode fails (corrupt JPEG, OOM, etc.);
|
|
637
|
-
* caller is expected to fall back to the pose-only evaluate
|
|
638
|
-
* path so the capture doesn't lock up.
|
|
639
|
-
*/
|
|
640
|
-
private data class GrayscaleFrame(
|
|
641
|
-
val bytes: ByteArray,
|
|
642
|
-
val width: Int,
|
|
643
|
-
val height: Int,
|
|
644
|
-
val stride: Int,
|
|
645
|
-
)
|
|
646
|
-
|
|
647
|
-
private fun decodeJpegToGrayscale(path: String): GrayscaleFrame? {
|
|
648
|
-
val mat = Imgcodecs.imread(path, Imgcodecs.IMREAD_GRAYSCALE)
|
|
649
|
-
if (mat.empty()) {
|
|
650
|
-
android.util.Log.w(
|
|
651
|
-
"IncrementalStitcher",
|
|
652
|
-
"decodeJpegToGrayscale: imread returned empty Mat for $path"
|
|
653
|
-
)
|
|
654
|
-
return null
|
|
655
|
-
}
|
|
656
|
-
return try {
|
|
657
|
-
val width = mat.cols()
|
|
658
|
-
val height = mat.rows()
|
|
659
|
-
// step1() returns bytes-per-row for a CV_8UC1 Mat. For a
|
|
660
|
-
// continuous Mat from imread (no ROI) stride == width.
|
|
661
|
-
val stride = mat.step1().toInt()
|
|
662
|
-
val size = stride * height
|
|
663
|
-
val bytes = ByteArray(size)
|
|
664
|
-
mat.get(0, 0, bytes)
|
|
665
|
-
GrayscaleFrame(bytes, width, height, stride)
|
|
666
|
-
} catch (e: Exception) {
|
|
667
|
-
android.util.Log.w(
|
|
668
|
-
"IncrementalStitcher",
|
|
669
|
-
"decodeJpegToGrayscale: failed to copy Mat bytes for $path: ${e.message}",
|
|
670
|
-
e,
|
|
671
|
-
)
|
|
672
|
-
null
|
|
673
|
-
} finally {
|
|
674
|
-
mat.release()
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
605
|
private fun copyKeyframeToStore(srcPath: String): String? {
|
|
679
606
|
// V16 Phase 2 (Android Fix-1) — write into the per-session
|
|
680
607
|
// subdir created by start(). If start() didn't run (defensive
|
|
@@ -708,8 +635,8 @@ class IncrementalStitcher(
|
|
|
708
635
|
// ── V16 Phase 1 → P3-F migration note ────────────────────────
|
|
709
636
|
// The frame-counter placeholder gate `handleBatchKeyframeFrame`
|
|
710
637
|
// that lived here has been REMOVED. Both the AR-driven path
|
|
711
|
-
// (ingestFromARCameraView) and the
|
|
712
|
-
// (
|
|
638
|
+
// (`ingestFromARCameraView`) and the Frame Processor path
|
|
639
|
+
// (`consumeFrameFromPlugin`) now route through the shared-C++
|
|
713
640
|
// `KeyframeGate` (cpp/keyframe_gate.{hpp,cpp}) — same algorithm
|
|
714
641
|
// iOS has used since the V16 ship. See
|
|
715
642
|
// `private val keyframeGate = KeyframeGate()` above for the
|
|
@@ -722,204 +649,6 @@ class IncrementalStitcher(
|
|
|
722
649
|
// we ever add a "force every Nth frame regardless of overlap"
|
|
723
650
|
// override.
|
|
724
651
|
|
|
725
|
-
@ReactMethod
|
|
726
|
-
fun processFrameAtPath(options: ReadableMap, promise: Promise) {
|
|
727
|
-
val hybrid = this.engine
|
|
728
|
-
val firstwins = this.firstwinsEngine
|
|
729
|
-
// batch-keyframe mode runs without a live engine — handle it
|
|
730
|
-
// up-front before the null-check rejects.
|
|
731
|
-
//
|
|
732
|
-
// P3-F: this path uses the same shared-C++ KeyframeGate as
|
|
733
|
-
// the AR view path, but with translation = 0 (gyro-derived
|
|
734
|
-
// poses have only rotation, not position). The gate's
|
|
735
|
-
// internal logic detects the missing plane and uses the
|
|
736
|
-
// camera-forward angular-delta fallback automatically.
|
|
737
|
-
if (batchKeyframeMode) {
|
|
738
|
-
val path = options.getString("path")
|
|
739
|
-
?: return promise.reject("invalid-options", "path required")
|
|
740
|
-
val pose = RNSARFramePose(
|
|
741
|
-
tx = options.getDoubleOrDefault("tx", 0.0) ?: 0.0,
|
|
742
|
-
ty = options.getDoubleOrDefault("ty", 0.0) ?: 0.0,
|
|
743
|
-
tz = options.getDoubleOrDefault("tz", 0.0) ?: 0.0,
|
|
744
|
-
qx = options.getDoubleOrDefault("qx", 0.0) ?: 0.0,
|
|
745
|
-
qy = options.getDoubleOrDefault("qy", 0.0) ?: 0.0,
|
|
746
|
-
qz = options.getDoubleOrDefault("qz", 0.0) ?: 0.0,
|
|
747
|
-
qw = options.getDoubleOrDefault("qw", 1.0) ?: 1.0,
|
|
748
|
-
fx = options.getDoubleOrDefault("fx", 1000.0) ?: 1000.0,
|
|
749
|
-
fy = options.getDoubleOrDefault("fy", 1000.0) ?: 1000.0,
|
|
750
|
-
cx = options.getDoubleOrDefault("cx", 540.0) ?: 540.0,
|
|
751
|
-
cy = options.getDoubleOrDefault("cy", 960.0) ?: 960.0,
|
|
752
|
-
imageWidth = options.getIntOrDefault("imageWidth", 1080),
|
|
753
|
-
imageHeight = options.getIntOrDefault("imageHeight", 1920),
|
|
754
|
-
timestampMs = 0.0,
|
|
755
|
-
trackingState = RNSARSession.TRACKING_TRACKING,
|
|
756
|
-
)
|
|
757
|
-
// Vision-camera path: no plane available (gyro can't fit
|
|
758
|
-
// planes). Pass null → C++ skips the plane-overlap math.
|
|
759
|
-
//
|
|
760
|
-
// 2026-05-21 (v0.3) — pixel-aware Flow-strategy evaluation.
|
|
761
|
-
// The JS driver hands us a JPEG file path; we decode it to
|
|
762
|
-
// grayscale here so the C++ gate actually runs sparse-flow
|
|
763
|
-
// novelty (pre-0.3 the call was `evaluate(pose, null)` and
|
|
764
|
-
// the C++ side silently fell back to Pose strategy because
|
|
765
|
-
// no pixel data was supplied — same bug as Android AR
|
|
766
|
-
// mode, both fixed in v0.3). BitmapFactory + manual luma
|
|
767
|
-
// is fine here — non-AR snapshot cadence is ~4 FPS, the
|
|
768
|
-
// ~15-30 ms JPEG-decode-to-grayscale per call is well
|
|
769
|
-
// under the inter-snapshot interval. (v0.4 will move
|
|
770
|
-
// non-AR to vision-camera's Frame Processor API, at which
|
|
771
|
-
// point the JPEG step goes away entirely. Tracked at
|
|
772
|
-
// https://github.com/bhargavkanda/react-native-image-stitcher/issues/11)
|
|
773
|
-
val gray = decodeJpegToGrayscale(path)
|
|
774
|
-
val decision = if (gray != null) {
|
|
775
|
-
keyframeGate.evaluateWithFrame(
|
|
776
|
-
pose, null,
|
|
777
|
-
gray.bytes, gray.width, gray.height, gray.stride,
|
|
778
|
-
)
|
|
779
|
-
} else {
|
|
780
|
-
// JPEG decode failed (corrupt file? OOM?). Fall back
|
|
781
|
-
// to pose-only path so the capture doesn't lock up;
|
|
782
|
-
// this matches the C++ side's own defensive fallback
|
|
783
|
-
// for null grayData.
|
|
784
|
-
keyframeGate.evaluate(pose, null)
|
|
785
|
-
}
|
|
786
|
-
val result = Arguments.createMap()
|
|
787
|
-
// Outcome mapping for iOS-parity JS contract:
|
|
788
|
-
// 1 = accepted, 2 = rejected (gate), 3 = rejected (cap).
|
|
789
|
-
// C++ enum → outcome int:
|
|
790
|
-
val outcome = when {
|
|
791
|
-
decision.accept -> 1
|
|
792
|
-
decision.reason == "max-reached" -> 3
|
|
793
|
-
else -> 2
|
|
794
|
-
}
|
|
795
|
-
result.putInt("outcome", outcome)
|
|
796
|
-
if (!decision.accept) {
|
|
797
|
-
// 2026-05-22 (audit follow-up) — emit reject-state on
|
|
798
|
-
// the non-AR JS-driver path too so the debug overlay
|
|
799
|
-
// sees overlap % update on every snapshot (~4 Hz).
|
|
800
|
-
// Same rationale as the AR path's reject emission
|
|
801
|
-
// above; iOS parity.
|
|
802
|
-
emitBatchKeyframeRejectState(
|
|
803
|
-
decision = decision,
|
|
804
|
-
keyframeCount = batchKeyframePaths.size,
|
|
805
|
-
keyframeMax = keyframeGate.maxCount,
|
|
806
|
-
isLandscape = pose.imageWidth >= pose.imageHeight,
|
|
807
|
-
)
|
|
808
|
-
}
|
|
809
|
-
if (decision.accept) {
|
|
810
|
-
batchKeyframePaths.add(path) // vision-camera path
|
|
811
|
-
// gives us a unique
|
|
812
|
-
// per-snapshot file
|
|
813
|
-
// already (no copy
|
|
814
|
-
// needed).
|
|
815
|
-
// Emit the same state event the AR path emits so
|
|
816
|
-
// the JS LiveFrameStrip + "Keyframes n/max" pill
|
|
817
|
-
// work identically on the vision-camera fallback
|
|
818
|
-
// path.
|
|
819
|
-
emitBatchKeyframeAcceptedState(
|
|
820
|
-
thumbnailPath = path,
|
|
821
|
-
keyframeIndex = batchKeyframePaths.size - 1,
|
|
822
|
-
keyframeCount = batchKeyframePaths.size,
|
|
823
|
-
keyframeMax = keyframeGate.maxCount,
|
|
824
|
-
isLandscape = pose.imageWidth >= pose.imageHeight,
|
|
825
|
-
newContentFraction = decision.newContentFraction,
|
|
826
|
-
)
|
|
827
|
-
}
|
|
828
|
-
result.putInt("acceptedCount", batchKeyframePaths.size)
|
|
829
|
-
promise.resolve(result)
|
|
830
|
-
return
|
|
831
|
-
}
|
|
832
|
-
if (hybrid == null && firstwins == null) {
|
|
833
|
-
return promise.reject(
|
|
834
|
-
"incremental-not-running",
|
|
835
|
-
"Call start() before processFrameAtPath().",
|
|
836
|
-
)
|
|
837
|
-
}
|
|
838
|
-
val path = options.getString("path")
|
|
839
|
-
?: return promise.reject("invalid-options", "path required")
|
|
840
|
-
val yaw = options.getDoubleOrDefault("yaw", 0.0)
|
|
841
|
-
val pitch = options.getDoubleOrDefault("pitch", 0.0)
|
|
842
|
-
val fovH = options.getDoubleOrDefault("fovHorizDegrees", 65.0)
|
|
843
|
-
val fovV = options.getDoubleOrDefault("fovVertDegrees", 50.0)
|
|
844
|
-
// V6 pose-driven params. Defaults removed per critic finding
|
|
845
|
-
// #3: previously qw=1.0 default meant frames without explicit
|
|
846
|
-
// quaternion produced an identity rotation, and EVERY
|
|
847
|
-
// subsequent frame had R_rel = R_first^T (constant), so
|
|
848
|
-
// strip placement never advanced and `acceptedCount` froze
|
|
849
|
-
// at 1 after the first frame. Now every quaternion field is
|
|
850
|
-
// required; missing → reject as RejectedAlignmentLost so the
|
|
851
|
-
// gyro driver upstream notices instantly.
|
|
852
|
-
if (!options.hasKey("qx") || !options.hasKey("qy")
|
|
853
|
-
|| !options.hasKey("qz") || !options.hasKey("qw")) {
|
|
854
|
-
return promise.reject(
|
|
855
|
-
"invalid-options",
|
|
856
|
-
"qx/qy/qz/qw all required (no identity-quaternion fallback)",
|
|
857
|
-
)
|
|
858
|
-
}
|
|
859
|
-
val qx = options.getDouble("qx")
|
|
860
|
-
val qy = options.getDouble("qy")
|
|
861
|
-
val qz = options.getDouble("qz")
|
|
862
|
-
val qw = options.getDouble("qw")
|
|
863
|
-
val fx = options.getDoubleOrDefault("fx", 0.0)
|
|
864
|
-
val fy = options.getDoubleOrDefault("fy", 0.0)
|
|
865
|
-
val cx = options.getDoubleOrDefault("cx", 0.0)
|
|
866
|
-
val cy = options.getDoubleOrDefault("cy", 0.0)
|
|
867
|
-
val imageWidth = options.getIntOrDefault("imageWidth", 0)
|
|
868
|
-
val imageHeight = options.getIntOrDefault("imageHeight", 0)
|
|
869
|
-
val trackingPoor = options.getBooleanOrDefault("trackingPoor", false)
|
|
870
|
-
|
|
871
|
-
workScope.launch {
|
|
872
|
-
// Critic #4 fix: re-check isRunning synchronously here in
|
|
873
|
-
// case finalize/cancel ran on the JS thread between the
|
|
874
|
-
// null-check above and this dispatch landing. Skip the
|
|
875
|
-
// ingest if we're no longer running — matches iOS' V12.1
|
|
876
|
-
// pattern (synchronous-stop + worker re-check).
|
|
877
|
-
if (!isRunning.get()) {
|
|
878
|
-
promise.resolve(Arguments.createMap().apply { putInt("outcome", -1) })
|
|
879
|
-
return@launch
|
|
880
|
-
}
|
|
881
|
-
try {
|
|
882
|
-
val telemetry: FrameTelemetry
|
|
883
|
-
val state: WritableMap?
|
|
884
|
-
val accepted: Int
|
|
885
|
-
if (firstwins != null) {
|
|
886
|
-
telemetry = firstwins.addFrameAtPath(
|
|
887
|
-
path = path,
|
|
888
|
-
qx = qx, qy = qy, qz = qz, qw = qw,
|
|
889
|
-
fx = fx, fy = fy, cx = cx, cy = cy,
|
|
890
|
-
imageWidth = imageWidth, imageHeight = imageHeight,
|
|
891
|
-
yaw = yaw, pitch = pitch,
|
|
892
|
-
fovHorizDegrees = fovH, fovVertDegrees = fovV,
|
|
893
|
-
trackingPoor = trackingPoor,
|
|
894
|
-
)
|
|
895
|
-
state = firstwins.snapshotIfDue(telemetry)
|
|
896
|
-
accepted = firstwins.acceptedCount
|
|
897
|
-
} else {
|
|
898
|
-
telemetry = hybrid!!.addFrameAtPath(
|
|
899
|
-
path = path,
|
|
900
|
-
qx = qx, qy = qy, qz = qz, qw = qw,
|
|
901
|
-
fx = fx, fy = fy, cx = cx, cy = cy,
|
|
902
|
-
imageWidth = imageWidth, imageHeight = imageHeight,
|
|
903
|
-
yaw = yaw, pitch = pitch,
|
|
904
|
-
fovHorizDegrees = fovH, fovVertDegrees = fovV,
|
|
905
|
-
trackingPoor = trackingPoor,
|
|
906
|
-
)
|
|
907
|
-
state = hybrid.snapshotIfDue(telemetry)
|
|
908
|
-
accepted = hybrid.acceptedCount
|
|
909
|
-
}
|
|
910
|
-
emitState(state)
|
|
911
|
-
val result = Arguments.createMap()
|
|
912
|
-
result.putInt("outcome", telemetry.outcome.ordinal)
|
|
913
|
-
result.putDouble("confidence", telemetry.confidence)
|
|
914
|
-
result.putDouble("overlapPercent", telemetry.overlapPercent)
|
|
915
|
-
result.putDouble("processingMs", telemetry.processingMs)
|
|
916
|
-
result.putInt("acceptedCount", accepted)
|
|
917
|
-
promise.resolve(result)
|
|
918
|
-
} catch (t: Throwable) {
|
|
919
|
-
promise.reject("incremental-process-failed", t.message, t)
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
652
|
|
|
924
653
|
@ReactMethod
|
|
925
654
|
fun finalize(options: ReadableMap, promise: Promise) {
|
|
@@ -963,9 +692,10 @@ class IncrementalStitcher(
|
|
|
963
692
|
// frames slip into the engine while we serialize the canvas.
|
|
964
693
|
arCameraViewRef?.setIncrementalIngestionActive(false)
|
|
965
694
|
// Critic #4 fix: synchronously flip isRunning=false BEFORE
|
|
966
|
-
// dispatching the finalize body, so any in-flight
|
|
967
|
-
//
|
|
968
|
-
//
|
|
695
|
+
// dispatching the finalize body, so any in-flight per-frame
|
|
696
|
+
// ingest workers about to launch on workScope (today:
|
|
697
|
+
// `ingestFromARCameraView` or `consumeFrameFromPlugin`) bail
|
|
698
|
+
// at the re-check inside their workScope.launch blocks.
|
|
969
699
|
// Matches iOS V12.1 fix.
|
|
970
700
|
isRunning.set(false)
|
|
971
701
|
frameProcessorIngestEnabled.set(false) // F8.4 — cut producer-thread ingest at finalize
|
|
@@ -1258,10 +988,29 @@ class IncrementalStitcher(
|
|
|
1258
988
|
// is false (the legacy hybrid/firstwins live-engine path,
|
|
1259
989
|
// which feeds JPEG paths into addFrameAtPath for each ARCore
|
|
1260
990
|
// frame). Pass null when batchKeyframeMode is true; the
|
|
1261
|
-
// batch path uses `grayData` + `onAccept` instead.
|
|
1262
|
-
//
|
|
1263
|
-
//
|
|
991
|
+
// batch path uses `grayData` + `onAccept` instead. Modern
|
|
992
|
+
// callers prefer `nv21PixelData` below — `legacyJpegPath` is
|
|
993
|
+
// kept only as a defensive fallback for older call sites
|
|
994
|
+
// that have not yet been migrated.
|
|
1264
995
|
legacyJpegPath: String? = null,
|
|
996
|
+
// F8.6 — pixel-data path for live engines. When supplied
|
|
997
|
+
// (and `batchKeyframeMode == false`), takes precedence over
|
|
998
|
+
// `legacyJpegPath`: the live engine ingests via
|
|
999
|
+
// `addFramePixelData` (NV21 → BGR Mat in-process) instead of
|
|
1000
|
+
// `addFrameAtPath` (JPEG decode round-trip). Saves ~30-50 ms
|
|
1001
|
+
// per accepted frame on a mid-tier device. Pass null to use
|
|
1002
|
+
// the legacy JPEG path.
|
|
1003
|
+
//
|
|
1004
|
+
// OWNERSHIP: the engine retains a reference to `nv21PixelData`
|
|
1005
|
+
// until `workScope`'s coroutine consumes it (~50 ms later).
|
|
1006
|
+
// Callers MUST treat the array as transferred — do not
|
|
1007
|
+
// mutate it or return it to a buffer pool after calling
|
|
1008
|
+
// this method. If a caller needs to recycle the buffer,
|
|
1009
|
+
// pass `.copyOf()` (currently no caller does — the F8.4
|
|
1010
|
+
// Frame Processor plugin allocates a fresh array per frame).
|
|
1011
|
+
nv21PixelData: ByteArray? = null,
|
|
1012
|
+
nv21PixelWidth: Int = 0,
|
|
1013
|
+
nv21PixelHeight: Int = 0,
|
|
1265
1014
|
) {
|
|
1266
1015
|
// ── V16 batch-keyframe: AR-driven path ─────────────────────
|
|
1267
1016
|
//
|
|
@@ -1439,40 +1188,78 @@ class IncrementalStitcher(
|
|
|
1439
1188
|
// we only get here when batchKeyframeMode == false. Caller
|
|
1440
1189
|
// (RNSARCameraView) was expected to supply legacyJpegPath in
|
|
1441
1190
|
// that case — defensively drop the frame if it didn't.
|
|
1442
|
-
|
|
1191
|
+
// F8.6 — prefer the pixel-data path when the caller supplied
|
|
1192
|
+
// NV21 bytes (Frame Processor / refactored ARCore path),
|
|
1193
|
+
// otherwise fall back to legacyJpegPath (un-migrated ARCore
|
|
1194
|
+
// path). At least one of them must be present; drop the
|
|
1195
|
+
// frame defensively otherwise.
|
|
1196
|
+
val hasPixelData = nv21PixelData != null
|
|
1197
|
+
&& nv21PixelWidth > 0
|
|
1198
|
+
&& nv21PixelHeight > 0
|
|
1199
|
+
val path = if (hasPixelData) null else legacyJpegPath ?: run {
|
|
1443
1200
|
android.util.Log.w(
|
|
1444
1201
|
"IncrementalStitcher",
|
|
1445
1202
|
"ingestFromARCameraView legacy: batchKeyframeMode=false " +
|
|
1446
|
-
"but legacyJpegPath
|
|
1447
|
-
"Caller
|
|
1448
|
-
"
|
|
1203
|
+
"but both legacyJpegPath and nv21PixelData are null — " +
|
|
1204
|
+
"dropping frame. Caller must supply NV21 pixel data " +
|
|
1205
|
+
"(preferred) or a JPEG path for the live engine path.",
|
|
1449
1206
|
)
|
|
1450
1207
|
return
|
|
1451
1208
|
}
|
|
1452
1209
|
workScope.launch {
|
|
1453
1210
|
val state: WritableMap? = if (firstwins != null) {
|
|
1454
|
-
val tele =
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1211
|
+
val tele = if (hasPixelData) {
|
|
1212
|
+
firstwins.addFramePixelData(
|
|
1213
|
+
nv21 = nv21PixelData!!,
|
|
1214
|
+
nv21Width = nv21PixelWidth,
|
|
1215
|
+
nv21Height = nv21PixelHeight,
|
|
1216
|
+
qx = qx, qy = qy, qz = qz, qw = qw,
|
|
1217
|
+
fx = fx, fy = fy, cx = cx, cy = cy,
|
|
1218
|
+
imageWidth = imageWidth, imageHeight = imageHeight,
|
|
1219
|
+
yaw = yaw, pitch = pitch,
|
|
1220
|
+
fovHorizDegrees = fovHorizDegrees,
|
|
1221
|
+
fovVertDegrees = fovVertDegrees,
|
|
1222
|
+
trackingPoor = trackingPoor,
|
|
1223
|
+
)
|
|
1224
|
+
} else {
|
|
1225
|
+
firstwins.addFrameAtPath(
|
|
1226
|
+
path = path!!,
|
|
1227
|
+
qx = qx, qy = qy, qz = qz, qw = qw,
|
|
1228
|
+
fx = fx, fy = fy, cx = cx, cy = cy,
|
|
1229
|
+
imageWidth = imageWidth, imageHeight = imageHeight,
|
|
1230
|
+
yaw = yaw, pitch = pitch,
|
|
1231
|
+
fovHorizDegrees = fovHorizDegrees,
|
|
1232
|
+
fovVertDegrees = fovVertDegrees,
|
|
1233
|
+
trackingPoor = trackingPoor,
|
|
1234
|
+
)
|
|
1235
|
+
}
|
|
1464
1236
|
firstwins.snapshotIfDue(tele)
|
|
1465
1237
|
} else {
|
|
1466
|
-
val tele =
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1238
|
+
val tele = if (hasPixelData) {
|
|
1239
|
+
hybrid!!.addFramePixelData(
|
|
1240
|
+
nv21 = nv21PixelData!!,
|
|
1241
|
+
nv21Width = nv21PixelWidth,
|
|
1242
|
+
nv21Height = nv21PixelHeight,
|
|
1243
|
+
qx = qx, qy = qy, qz = qz, qw = qw,
|
|
1244
|
+
fx = fx, fy = fy, cx = cx, cy = cy,
|
|
1245
|
+
imageWidth = imageWidth, imageHeight = imageHeight,
|
|
1246
|
+
yaw = yaw, pitch = pitch,
|
|
1247
|
+
fovHorizDegrees = fovHorizDegrees,
|
|
1248
|
+
fovVertDegrees = fovVertDegrees,
|
|
1249
|
+
trackingPoor = trackingPoor,
|
|
1250
|
+
)
|
|
1251
|
+
} else {
|
|
1252
|
+
hybrid!!.addFrameAtPath(
|
|
1253
|
+
path = path!!,
|
|
1254
|
+
qx = qx, qy = qy, qz = qz, qw = qw,
|
|
1255
|
+
fx = fx, fy = fy, cx = cx, cy = cy,
|
|
1256
|
+
imageWidth = imageWidth, imageHeight = imageHeight,
|
|
1257
|
+
yaw = yaw, pitch = pitch,
|
|
1258
|
+
fovHorizDegrees = fovHorizDegrees,
|
|
1259
|
+
fovVertDegrees = fovVertDegrees,
|
|
1260
|
+
trackingPoor = trackingPoor,
|
|
1261
|
+
)
|
|
1262
|
+
}
|
|
1476
1263
|
hybrid.snapshotIfDue(tele)
|
|
1477
1264
|
}
|
|
1478
1265
|
emitState(state)
|
|
@@ -1534,23 +1321,36 @@ class IncrementalStitcher(
|
|
|
1534
1321
|
) {
|
|
1535
1322
|
// F8.4 — drop the call unless this capture was started in
|
|
1536
1323
|
// frameProcessor mode. Otherwise the plugin would double-
|
|
1537
|
-
// feed the engine alongside the
|
|
1538
|
-
//
|
|
1324
|
+
// feed the engine alongside the AR-mode
|
|
1325
|
+
// `ingestFromARCameraView` path. See the flag's declaration
|
|
1539
1326
|
// for the full reasoning. Mirrors iOS H1.
|
|
1540
1327
|
if (!frameProcessorIngestEnabled.get()) return
|
|
1541
1328
|
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
//
|
|
1550
|
-
//
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1329
|
+
// F8.6 — pack the full NV21 (Y + interleaved VU) once,
|
|
1330
|
+
// then reuse it for BOTH the gate's Y-plane read AND the
|
|
1331
|
+
// live engine's pixel-data ingest. Previously the plugin
|
|
1332
|
+
// only extracted Y; the live engine then had to JPEG-decode
|
|
1333
|
+
// a separately-encoded path to recover BGR colour. Now we
|
|
1334
|
+
// skip both round-trips: the packed NV21 → BGR cvtColor
|
|
1335
|
+
// inside `addFramePixelData` produces the BGR Mat directly.
|
|
1336
|
+
//
|
|
1337
|
+
// YuvImageConverter.packNV21 is stride-aware and densely
|
|
1338
|
+
// repacks Y (so the gate's `grayStride = grayWidth = width`
|
|
1339
|
+
// works), then interleaves VU per the standard NV21 layout
|
|
1340
|
+
// [Y...][VU...]. Returns null only on degenerate Images
|
|
1341
|
+
// (closed mid-callback or non-YUV format).
|
|
1342
|
+
val packed = io.imagestitcher.rn.ar.YuvImageConverter.packNV21(image)
|
|
1343
|
+
?: return
|
|
1344
|
+
val width = packed.width
|
|
1345
|
+
val height = packed.height
|
|
1346
|
+
val nv21Bytes = packed.nv21
|
|
1347
|
+
// The gate reads `grayHeight` rows of `grayWidth` pixels
|
|
1348
|
+
// at stride=width starting from offset 0. That's exactly
|
|
1349
|
+
// the Y plane region of nv21Bytes — the gate naturally
|
|
1350
|
+
// stops before the UV bytes start. No need to slice into
|
|
1351
|
+
// a separate ByteArray.
|
|
1352
|
+
val yBytes = nv21Bytes
|
|
1353
|
+
val yRowStride = width
|
|
1554
1354
|
|
|
1555
1355
|
// Compute derived params expected by the existing ingest
|
|
1556
1356
|
// API. Quaternion-to-yaw/pitch follows the same convention
|
|
@@ -1596,13 +1396,22 @@ class IncrementalStitcher(
|
|
|
1596
1396
|
grayWidth = width,
|
|
1597
1397
|
grayHeight = height,
|
|
1598
1398
|
grayStride = yRowStride,
|
|
1399
|
+
// F8.6 — pass the already-packed NV21 so the live
|
|
1400
|
+
// engine branch (hybrid / firstwins) can ingest via
|
|
1401
|
+
// `addFramePixelData` instead of JPEG-decoding a
|
|
1402
|
+
// separately-written path. Batch-keyframe mode
|
|
1403
|
+
// ignores these (it uses `grayData` + `onAccept`).
|
|
1404
|
+
nv21PixelData = nv21Bytes,
|
|
1405
|
+
nv21PixelWidth = width,
|
|
1406
|
+
nv21PixelHeight = height,
|
|
1599
1407
|
onAccept = { targetPath ->
|
|
1600
1408
|
// Synchronous JPEG encode via the existing
|
|
1601
1409
|
// YuvImageConverter (also used by RNSARCameraView's
|
|
1602
|
-
// ARCore path).
|
|
1603
|
-
//
|
|
1604
|
-
//
|
|
1605
|
-
//
|
|
1410
|
+
// ARCore path). Reuses the NV21 already packed at
|
|
1411
|
+
// the top of `consumeFrameFromPlugin` — F8.6 saves
|
|
1412
|
+
// a duplicate packNV21 call here (the previous
|
|
1413
|
+
// version repacked the live `image` inside the
|
|
1414
|
+
// lambda).
|
|
1606
1415
|
//
|
|
1607
1416
|
// EXIF rotation is BAKED-AS-METADATA, not pixel-
|
|
1608
1417
|
// rotated. cv::imread in the stitcher ignores EXIF
|
|
@@ -1614,32 +1423,20 @@ class IncrementalStitcher(
|
|
|
1614
1423
|
// Returning `true` tells the engine the keyframe was
|
|
1615
1424
|
// persisted; `false` tells it to drop the accept.
|
|
1616
1425
|
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
|
|
1426
|
+
val displayRotation = when (sensorRotationDegrees) {
|
|
1427
|
+
0 -> android.view.Surface.ROTATION_90
|
|
1428
|
+
90 -> android.view.Surface.ROTATION_0
|
|
1429
|
+
180 -> android.view.Surface.ROTATION_270
|
|
1430
|
+
270 -> android.view.Surface.ROTATION_180
|
|
1431
|
+
else -> android.view.Surface.ROTATION_0
|
|
1642
1432
|
}
|
|
1433
|
+
val outPath = YuvImageConverter.encodeJpegFromNV21(
|
|
1434
|
+
packed,
|
|
1435
|
+
targetPath,
|
|
1436
|
+
jpegQuality = 80,
|
|
1437
|
+
displayRotation = displayRotation,
|
|
1438
|
+
)
|
|
1439
|
+
outPath != null
|
|
1643
1440
|
} catch (e: Throwable) {
|
|
1644
1441
|
android.util.Log.w(
|
|
1645
1442
|
"IncrementalStitcher",
|
|
@@ -2548,7 +2345,91 @@ internal class IncrementalEngine(
|
|
|
2548
2345
|
// See iOS' equivalent fix for the architectural rationale.
|
|
2549
2346
|
val frame = downsampleToCompose(srcRaw)
|
|
2550
2347
|
if (frame !== srcRaw) srcRaw.release()
|
|
2348
|
+
return addFrameMat(
|
|
2349
|
+
frame,
|
|
2350
|
+
qx, qy, qz, qw,
|
|
2351
|
+
fx, fy, cx, cy,
|
|
2352
|
+
imageWidth, imageHeight,
|
|
2353
|
+
yaw, pitch,
|
|
2354
|
+
fovHorizDegrees, fovVertDegrees,
|
|
2355
|
+
t0,
|
|
2356
|
+
)
|
|
2357
|
+
}
|
|
2551
2358
|
|
|
2359
|
+
/**
|
|
2360
|
+
* F8.6 — pixel-data twin of [addFrameAtPath]. Accepts the
|
|
2361
|
+
* camera frame as an NV21 byte buffer instead of a JPEG file
|
|
2362
|
+
* path; skips the JPEG decode round-trip. See
|
|
2363
|
+
* `IncrementalFirstwinsEngine.addFramePixelData` for the
|
|
2364
|
+
* sibling implementation rationale.
|
|
2365
|
+
*/
|
|
2366
|
+
fun addFramePixelData(
|
|
2367
|
+
nv21: ByteArray,
|
|
2368
|
+
nv21Width: Int,
|
|
2369
|
+
nv21Height: Int,
|
|
2370
|
+
qx: Double, qy: Double, qz: Double, qw: Double,
|
|
2371
|
+
fx: Double, fy: Double, cx: Double, cy: Double,
|
|
2372
|
+
imageWidth: Int, imageHeight: Int,
|
|
2373
|
+
yaw: Double, pitch: Double,
|
|
2374
|
+
fovHorizDegrees: Double, fovVertDegrees: Double,
|
|
2375
|
+
trackingPoor: Boolean,
|
|
2376
|
+
): FrameTelemetry {
|
|
2377
|
+
val t0 = System.nanoTime()
|
|
2378
|
+
if (trackingPoor) {
|
|
2379
|
+
return FrameTelemetry(
|
|
2380
|
+
FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
|
|
2381
|
+
msSince(t0),
|
|
2382
|
+
)
|
|
2383
|
+
}
|
|
2384
|
+
// F8.6 IS-1 — length guard; see
|
|
2385
|
+
// `IncrementalFirstwinsEngine.addFramePixelData` for the
|
|
2386
|
+
// failure-mode rationale.
|
|
2387
|
+
val expectedBytes = nv21Width * nv21Height * 3 / 2
|
|
2388
|
+
require(nv21.size >= expectedBytes) {
|
|
2389
|
+
"addFramePixelData: nv21 buffer too small " +
|
|
2390
|
+
"(${nv21.size} bytes < $expectedBytes for " +
|
|
2391
|
+
"${nv21Width}x${nv21Height})"
|
|
2392
|
+
}
|
|
2393
|
+
val yuv = Mat(nv21Height + nv21Height / 2, nv21Width, CvType.CV_8UC1)
|
|
2394
|
+
yuv.put(0, 0, nv21)
|
|
2395
|
+
val srcRaw = Mat()
|
|
2396
|
+
Imgproc.cvtColor(yuv, srcRaw, Imgproc.COLOR_YUV2BGR_NV21)
|
|
2397
|
+
yuv.release()
|
|
2398
|
+
if (srcRaw.empty()) {
|
|
2399
|
+
return FrameTelemetry(
|
|
2400
|
+
FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
|
|
2401
|
+
msSince(t0),
|
|
2402
|
+
)
|
|
2403
|
+
}
|
|
2404
|
+
val frame = downsampleToCompose(srcRaw)
|
|
2405
|
+
if (frame !== srcRaw) srcRaw.release()
|
|
2406
|
+
return addFrameMat(
|
|
2407
|
+
frame,
|
|
2408
|
+
qx, qy, qz, qw,
|
|
2409
|
+
fx, fy, cx, cy,
|
|
2410
|
+
imageWidth, imageHeight,
|
|
2411
|
+
yaw, pitch,
|
|
2412
|
+
fovHorizDegrees, fovVertDegrees,
|
|
2413
|
+
t0,
|
|
2414
|
+
)
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
/**
|
|
2418
|
+
* F8.6 — the body extracted from [addFrameAtPath]. Takes a
|
|
2419
|
+
* BGR `Mat` (already downsampled to compose dims) and runs the
|
|
2420
|
+
* pose-driven homography paste pipeline. Behaviour is
|
|
2421
|
+
* identical to the pre-F8.6 `addFrameAtPath` — the body is a
|
|
2422
|
+
* verbatim move.
|
|
2423
|
+
*/
|
|
2424
|
+
private fun addFrameMat(
|
|
2425
|
+
frame: Mat,
|
|
2426
|
+
qx: Double, qy: Double, qz: Double, qw: Double,
|
|
2427
|
+
fx: Double, fy: Double, cx: Double, cy: Double,
|
|
2428
|
+
imageWidth: Int, imageHeight: Int,
|
|
2429
|
+
yaw: Double, pitch: Double,
|
|
2430
|
+
fovHorizDegrees: Double, fovVertDegrees: Double,
|
|
2431
|
+
t0: Long,
|
|
2432
|
+
): FrameTelemetry {
|
|
2552
2433
|
// Build R_new from quaternion.
|
|
2553
2434
|
val rNew = quaternionToRotationMat(qx, qy, qz, qw)
|
|
2554
2435
|
|