react-native-image-stitcher 0.2.1 → 0.4.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 +511 -1
- package/README.md +1 -1
- package/android/src/main/cpp/keyframe_gate_jni.cpp +138 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +412 -40
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +128 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +87 -45
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +46 -4
- package/cpp/stitcher.cpp +101 -1
- package/cpp/stitcher.hpp +8 -0
- package/dist/camera/Camera.d.ts +9 -0
- package/dist/camera/Camera.js +165 -43
- package/dist/camera/CaptureDebugOverlay.d.ts +45 -0
- package/dist/camera/CaptureDebugOverlay.js +146 -0
- package/dist/camera/CaptureKeyframePill.d.ts +28 -0
- package/dist/camera/CaptureKeyframePill.js +60 -0
- package/dist/camera/CaptureMemoryPill.d.ts +28 -0
- package/dist/camera/CaptureMemoryPill.js +109 -0
- package/dist/camera/CaptureOrientationPill.d.ts +22 -0
- package/dist/camera/CaptureOrientationPill.js +44 -0
- package/dist/camera/CaptureStitchStatsToast.d.ts +45 -0
- package/dist/camera/CaptureStitchStatsToast.js +133 -0
- package/dist/camera/PanoramaSettings.d.ts +478 -0
- package/dist/camera/PanoramaSettings.js +120 -0
- package/dist/camera/PanoramaSettingsBridge.d.ts +84 -0
- package/dist/camera/PanoramaSettingsBridge.js +208 -0
- package/dist/camera/PanoramaSettingsModal.d.ts +50 -298
- package/dist/camera/PanoramaSettingsModal.js +189 -354
- package/dist/camera/buildPanoramaInitialSettings.d.ts +70 -0
- package/dist/camera/buildPanoramaInitialSettings.js +97 -0
- package/dist/camera/lowMemDevice.d.ts +24 -0
- package/dist/camera/lowMemDevice.js +69 -0
- package/dist/index.d.ts +16 -2
- package/dist/index.js +37 -2
- package/dist/sensors/useIMUTranslationGate.d.ts +26 -0
- package/dist/sensors/useIMUTranslationGate.js +83 -1
- package/dist/stitching/incremental.d.ts +25 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +12 -1
- package/dist/stitching/useIncrementalStitcher.js +7 -1
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +321 -7
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +8 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +12 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +13 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +15 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +1 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +17 -4
- package/ios/Sources/RNImageStitcher/Stitcher.swift +6 -1
- package/package.json +6 -2
- package/src/camera/Camera.tsx +220 -54
- package/src/camera/CaptureDebugOverlay.tsx +180 -0
- package/src/camera/CaptureKeyframePill.tsx +77 -0
- package/src/camera/CaptureMemoryPill.tsx +96 -0
- package/src/camera/CaptureOrientationPill.tsx +57 -0
- package/src/camera/CaptureStitchStatsToast.tsx +155 -0
- package/src/camera/PanoramaSettings.ts +605 -0
- package/src/camera/PanoramaSettingsBridge.ts +238 -0
- package/src/camera/PanoramaSettingsModal.tsx +296 -988
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +375 -0
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +119 -0
- package/src/camera/__tests__/lowMemDevice.test.ts +52 -0
- package/src/camera/buildPanoramaInitialSettings.ts +139 -0
- package/src/camera/lowMemDevice.ts +71 -0
- package/src/index.ts +61 -3
- package/src/sensors/useIMUTranslationGate.ts +112 -1
- package/src/stitching/incremental.ts +25 -0
- package/src/stitching/useIncrementalStitcher.ts +18 -0
|
@@ -207,6 +207,15 @@ class IncrementalStitcher(
|
|
|
207
207
|
/// specific failure mode.
|
|
208
208
|
private var frameIngestLogTick: Int = 0
|
|
209
209
|
|
|
210
|
+
/// 2026-05-22 (audit F5) — Frame counter for the flowEvalEveryNFrames
|
|
211
|
+
/// throttle. Incremented on every per-frame entry point
|
|
212
|
+
/// (ingestFromARCameraView for AR, processFrameAtPath for non-AR);
|
|
213
|
+
/// gate evaluation only runs when (counter - 1) % evalCadence == 0
|
|
214
|
+
/// so frame #1 always evaluates regardless of cadence. iOS parity:
|
|
215
|
+
/// IncrementalStitcher.swift:2459-2471 (`consumeFrameCounter`).
|
|
216
|
+
/// Reset to 0 on each `start()` call.
|
|
217
|
+
private var consumeFrameCounter: Long = 0L
|
|
218
|
+
|
|
210
219
|
private val isRunning = AtomicBoolean(false)
|
|
211
220
|
/// Critic #5 fix: serial dispatcher so concurrent
|
|
212
221
|
/// processFrameAtPath() calls can't race on the engine's canvas.
|
|
@@ -243,6 +252,17 @@ class IncrementalStitcher(
|
|
|
243
252
|
/// on start/stop so the view feeds frames only during a capture.
|
|
244
253
|
@Volatile private var arCameraViewRef: RNSARCameraView? = null
|
|
245
254
|
|
|
255
|
+
/// 2026-05-21 (v0.3) — public-to-the-view-file getter so
|
|
256
|
+
/// `RNSARCameraView.forwardToIncremental` can ask whether the
|
|
257
|
+
/// currently-running engine is the batch-keyframe path (v0.3
|
|
258
|
+
/// Y-plane pixel-data flow) or the legacy hybrid/firstwins live
|
|
259
|
+
/// engine (which still needs a per-frame JPEG path). Used to
|
|
260
|
+
/// elide the eager JPEG encode for batchKeyframe mode (the
|
|
261
|
+
/// production Camera component's path); the legacy live engine
|
|
262
|
+
/// still pays the per-frame JPEG cost.
|
|
263
|
+
internal val isBatchKeyframeMode: Boolean
|
|
264
|
+
get() = batchKeyframeMode
|
|
265
|
+
|
|
246
266
|
init {
|
|
247
267
|
// Static back-pointer so `RNSARCameraView` can call into
|
|
248
268
|
// the singleton-style bridge module without a DI dance. RN
|
|
@@ -403,17 +423,67 @@ class IncrementalStitcher(
|
|
|
403
423
|
val txBudgetCm = configOverrides
|
|
404
424
|
?.getDoubleOrDefault("flowMaxTranslationCm", 0.0) ?: 0.0
|
|
405
425
|
keyframeGate.flowMaxTranslationM = (txBudgetCm / 100.0).coerceAtLeast(0.0)
|
|
406
|
-
|
|
407
|
-
//
|
|
408
|
-
//
|
|
426
|
+
// 2026-05-22 (audit F5) — flow-strategy Shi-Tomasi
|
|
427
|
+
// tunables. Pre-audit, Android had no JNI for these
|
|
428
|
+
// (iOS-only via KeyframeGateBridge); JS Settings sliders
|
|
429
|
+
// were silent no-ops. Now both platforms honour them.
|
|
430
|
+
// Clamp ranges match iOS (IncrementalStitcher.swift:907-924).
|
|
431
|
+
val maxCorners = configOverrides
|
|
432
|
+
?.getIntOrDefault("flowMaxCorners", 150) ?: 150
|
|
433
|
+
keyframeGate.flowMaxCorners = maxCorners.coerceIn(50, 300)
|
|
434
|
+
val quality = configOverrides
|
|
435
|
+
?.getDoubleOrDefault("flowQualityLevel", 0.01) ?: 0.01
|
|
436
|
+
keyframeGate.flowQualityLevel = quality.coerceIn(0.005, 0.05)
|
|
437
|
+
val minDist = configOverrides
|
|
438
|
+
?.getDoubleOrDefault("flowMinDistance", 10.0) ?: 10.0
|
|
439
|
+
keyframeGate.flowMinDistance = minDist.coerceIn(1.0, 50.0)
|
|
440
|
+
// Eval throttle: caller (this class) applies the cadence
|
|
441
|
+
// at the per-frame call sites. iOS parity at
|
|
442
|
+
// IncrementalStitcher.swift:2459-2471.
|
|
443
|
+
val evalCadence = configOverrides
|
|
444
|
+
?.getIntOrDefault("flowEvalEveryNFrames", 1) ?: 1
|
|
445
|
+
keyframeGate.flowEvalEveryNFrames = evalCadence.coerceIn(1, 10)
|
|
446
|
+
|
|
447
|
+
// 2026-05-22 — non-AR mode opt-out for angular fallback.
|
|
448
|
+
// captureSource = 'non-ar' means the host is using
|
|
409
449
|
// vision-camera (no ARKit/ARCore pose). Disable the gate's
|
|
410
|
-
// angular fallback so it doesn't compute on garbage pose
|
|
411
|
-
|
|
412
|
-
|
|
450
|
+
// angular fallback so it doesn't compute on garbage pose
|
|
451
|
+
// (gyro drift accumulating into the integrated angle was
|
|
452
|
+
// making the gate accept near-identical frames → degenerate
|
|
453
|
+
// cv::Stitcher params → "warpRoi too large" crash).
|
|
454
|
+
//
|
|
455
|
+
// Audit fix: pre-v0.3 the check tested the legacy
|
|
456
|
+
// 'wide'/'ultrawide' enum (replaced 2026-05-14 by 'ar'/'non-ar').
|
|
457
|
+
// The string mismatch silently nullified this opt-out for the
|
|
458
|
+
// entire Android non-AR path. See PanoramaSettings audit
|
|
459
|
+
// table row `captureSource`.
|
|
460
|
+
val captureSource = configOverrides?.getString("captureSource") ?: "ar"
|
|
461
|
+
val isNonAR = (captureSource == "non-ar")
|
|
413
462
|
keyframeGate.disableAngularFallback = isNonAR
|
|
414
463
|
|
|
415
|
-
|
|
464
|
+
// 2026-05-22 (audit F6) — honour frameSelectionMode.
|
|
465
|
+
// Pre-audit Android force-enabled the gate with the C++
|
|
466
|
+
// default (Pose) strategy regardless of the JS setting,
|
|
467
|
+
// making `frameSelectionMode = 'flow-based'` silently
|
|
468
|
+
// ineffective on Android (the Flow KLT path was never
|
|
469
|
+
// taken — only on iOS). Match iOS' mapping:
|
|
470
|
+
// 'time-based' → gate disabled (passthrough)
|
|
471
|
+
// 'pose-based' → gate enabled, Pose strategy
|
|
472
|
+
// 'flow-based' → gate enabled, Flow strategy
|
|
473
|
+
val frameMode = configOverrides?.getString("frameSelectionMode")
|
|
474
|
+
?: "flow-based"
|
|
475
|
+
keyframeGate.enabled =
|
|
476
|
+
(frameMode == "pose-based" || frameMode == "flow-based")
|
|
477
|
+
keyframeGate.strategy = if (frameMode == "flow-based") {
|
|
478
|
+
KeyframeGate.Strategy.Flow
|
|
479
|
+
} else {
|
|
480
|
+
KeyframeGate.Strategy.Pose
|
|
481
|
+
}
|
|
416
482
|
keyframeGate.reset()
|
|
483
|
+
// 2026-05-22 (audit F5) — reset the eval-throttle frame
|
|
484
|
+
// counter so the first frame of every capture is
|
|
485
|
+
// ALWAYS evaluated regardless of evalCadence.
|
|
486
|
+
consumeFrameCounter = 0L
|
|
417
487
|
} else if (isFirstwins) {
|
|
418
488
|
batchKeyframeMode = false
|
|
419
489
|
batchKeyframePaths.clear()
|
|
@@ -511,6 +581,67 @@ class IncrementalStitcher(
|
|
|
511
581
|
* for a Phase 3 follow-up; this MVP is just enough to make
|
|
512
582
|
* batch-keyframe work end-to-end on Android.
|
|
513
583
|
*/
|
|
584
|
+
/**
|
|
585
|
+
* 2026-05-21 (v0.3) — JPEG-to-grayscale decode for the JS-driver
|
|
586
|
+
* (non-AR) keyframe-gate path. Used by the batch-keyframe branch
|
|
587
|
+
* of processFrameAtPath to feed the C++ KeyframeGate with real
|
|
588
|
+
* pixel data so its Flow strategy actually runs (pre-0.3 the
|
|
589
|
+
* non-AR call was `evaluate(pose, null)` which silently fell back
|
|
590
|
+
* to the Pose strategy because no pixel data was supplied).
|
|
591
|
+
*
|
|
592
|
+
* Uses OpenCV's Imgcodecs.imread with IMREAD_GRAYSCALE, which
|
|
593
|
+
* decodes the JPEG straight into a single-channel CV_8UC1 Mat —
|
|
594
|
+
* faster than BitmapFactory + manual luma loop (~10-20 ms here
|
|
595
|
+
* vs ~100+ ms in interpreted Kotlin for a 1920×1080 image).
|
|
596
|
+
*
|
|
597
|
+
* The non-AR snapshot cadence is ~4 FPS so the per-call cost is
|
|
598
|
+
* well under the inter-snapshot interval. v0.4 will replace
|
|
599
|
+
* this code path entirely by moving non-AR capture to
|
|
600
|
+
* vision-camera's Frame Processor API (which delivers raw
|
|
601
|
+
* pixels directly with no JPEG roundtrip). Tracked at issue #11.
|
|
602
|
+
*
|
|
603
|
+
* Returns null when the decode fails (corrupt JPEG, OOM, etc.);
|
|
604
|
+
* caller is expected to fall back to the pose-only evaluate
|
|
605
|
+
* path so the capture doesn't lock up.
|
|
606
|
+
*/
|
|
607
|
+
private data class GrayscaleFrame(
|
|
608
|
+
val bytes: ByteArray,
|
|
609
|
+
val width: Int,
|
|
610
|
+
val height: Int,
|
|
611
|
+
val stride: Int,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
private fun decodeJpegToGrayscale(path: String): GrayscaleFrame? {
|
|
615
|
+
val mat = Imgcodecs.imread(path, Imgcodecs.IMREAD_GRAYSCALE)
|
|
616
|
+
if (mat.empty()) {
|
|
617
|
+
android.util.Log.w(
|
|
618
|
+
"IncrementalStitcher",
|
|
619
|
+
"decodeJpegToGrayscale: imread returned empty Mat for $path"
|
|
620
|
+
)
|
|
621
|
+
return null
|
|
622
|
+
}
|
|
623
|
+
return try {
|
|
624
|
+
val width = mat.cols()
|
|
625
|
+
val height = mat.rows()
|
|
626
|
+
// step1() returns bytes-per-row for a CV_8UC1 Mat. For a
|
|
627
|
+
// continuous Mat from imread (no ROI) stride == width.
|
|
628
|
+
val stride = mat.step1().toInt()
|
|
629
|
+
val size = stride * height
|
|
630
|
+
val bytes = ByteArray(size)
|
|
631
|
+
mat.get(0, 0, bytes)
|
|
632
|
+
GrayscaleFrame(bytes, width, height, stride)
|
|
633
|
+
} catch (e: Exception) {
|
|
634
|
+
android.util.Log.w(
|
|
635
|
+
"IncrementalStitcher",
|
|
636
|
+
"decodeJpegToGrayscale: failed to copy Mat bytes for $path: ${e.message}",
|
|
637
|
+
e,
|
|
638
|
+
)
|
|
639
|
+
null
|
|
640
|
+
} finally {
|
|
641
|
+
mat.release()
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
514
645
|
private fun copyKeyframeToStore(srcPath: String): String? {
|
|
515
646
|
// V16 Phase 2 (Android Fix-1) — write into the per-session
|
|
516
647
|
// subdir created by start(). If start() didn't run (defensive
|
|
@@ -591,8 +722,34 @@ class IncrementalStitcher(
|
|
|
591
722
|
trackingState = RNSARSession.TRACKING_TRACKING,
|
|
592
723
|
)
|
|
593
724
|
// Vision-camera path: no plane available (gyro can't fit
|
|
594
|
-
// planes). Pass null → C++
|
|
595
|
-
|
|
725
|
+
// planes). Pass null → C++ skips the plane-overlap math.
|
|
726
|
+
//
|
|
727
|
+
// 2026-05-21 (v0.3) — pixel-aware Flow-strategy evaluation.
|
|
728
|
+
// The JS driver hands us a JPEG file path; we decode it to
|
|
729
|
+
// grayscale here so the C++ gate actually runs sparse-flow
|
|
730
|
+
// novelty (pre-0.3 the call was `evaluate(pose, null)` and
|
|
731
|
+
// the C++ side silently fell back to Pose strategy because
|
|
732
|
+
// no pixel data was supplied — same bug as Android AR
|
|
733
|
+
// mode, both fixed in v0.3). BitmapFactory + manual luma
|
|
734
|
+
// is fine here — non-AR snapshot cadence is ~4 FPS, the
|
|
735
|
+
// ~15-30 ms JPEG-decode-to-grayscale per call is well
|
|
736
|
+
// under the inter-snapshot interval. (v0.4 will move
|
|
737
|
+
// non-AR to vision-camera's Frame Processor API, at which
|
|
738
|
+
// point the JPEG step goes away entirely. Tracked at
|
|
739
|
+
// https://github.com/bhargavkanda/react-native-image-stitcher/issues/11)
|
|
740
|
+
val gray = decodeJpegToGrayscale(path)
|
|
741
|
+
val decision = if (gray != null) {
|
|
742
|
+
keyframeGate.evaluateWithFrame(
|
|
743
|
+
pose, null,
|
|
744
|
+
gray.bytes, gray.width, gray.height, gray.stride,
|
|
745
|
+
)
|
|
746
|
+
} else {
|
|
747
|
+
// JPEG decode failed (corrupt file? OOM?). Fall back
|
|
748
|
+
// to pose-only path so the capture doesn't lock up;
|
|
749
|
+
// this matches the C++ side's own defensive fallback
|
|
750
|
+
// for null grayData.
|
|
751
|
+
keyframeGate.evaluate(pose, null)
|
|
752
|
+
}
|
|
596
753
|
val result = Arguments.createMap()
|
|
597
754
|
// Outcome mapping for iOS-parity JS contract:
|
|
598
755
|
// 1 = accepted, 2 = rejected (gate), 3 = rejected (cap).
|
|
@@ -603,6 +760,19 @@ class IncrementalStitcher(
|
|
|
603
760
|
else -> 2
|
|
604
761
|
}
|
|
605
762
|
result.putInt("outcome", outcome)
|
|
763
|
+
if (!decision.accept) {
|
|
764
|
+
// 2026-05-22 (audit follow-up) — emit reject-state on
|
|
765
|
+
// the non-AR JS-driver path too so the debug overlay
|
|
766
|
+
// sees overlap % update on every snapshot (~4 Hz).
|
|
767
|
+
// Same rationale as the AR path's reject emission
|
|
768
|
+
// above; iOS parity.
|
|
769
|
+
emitBatchKeyframeRejectState(
|
|
770
|
+
decision = decision,
|
|
771
|
+
keyframeCount = batchKeyframePaths.size,
|
|
772
|
+
keyframeMax = keyframeGate.maxCount,
|
|
773
|
+
isLandscape = pose.imageWidth >= pose.imageHeight,
|
|
774
|
+
)
|
|
775
|
+
}
|
|
606
776
|
if (decision.accept) {
|
|
607
777
|
batchKeyframePaths.add(path) // vision-camera path
|
|
608
778
|
// gives us a unique
|
|
@@ -619,6 +789,7 @@ class IncrementalStitcher(
|
|
|
619
789
|
keyframeCount = batchKeyframePaths.size,
|
|
620
790
|
keyframeMax = keyframeGate.maxCount,
|
|
621
791
|
isLandscape = pose.imageWidth >= pose.imageHeight,
|
|
792
|
+
newContentFraction = decision.newContentFraction,
|
|
622
793
|
)
|
|
623
794
|
}
|
|
624
795
|
result.putInt("acceptedCount", batchKeyframePaths.size)
|
|
@@ -793,15 +964,25 @@ class IncrementalStitcher(
|
|
|
793
964
|
// gracefully (slightly worse seams, never blows up).
|
|
794
965
|
val firstPose = batchFirstAcceptedPose
|
|
795
966
|
val lastPose = batchLastAcceptedPose
|
|
967
|
+
// 2026-05-22 (audit F2b) — JS-supplied IMU translation
|
|
968
|
+
// magnitude (metres). In non-AR mode the JS-driver path
|
|
969
|
+
// never carries pose tx/ty/tz so resolveStitchModeAuto's
|
|
970
|
+
// pose-only signal is always 0 → resolver always picks
|
|
971
|
+
// panorama. The IMU translation gate's measured displacement
|
|
972
|
+
// fills the gap. Defaults to 0 (back-compat) → resolver
|
|
973
|
+
// falls back to pose data only. Always non-negative.
|
|
974
|
+
val imuTranslationMetres = (options.getDoubleOrDefault("imuTranslationMetres", 0.0) ?: 0.0)
|
|
975
|
+
.coerceAtLeast(0.0)
|
|
796
976
|
val stitchModeResolved: String = when (batchStitchMode) {
|
|
797
977
|
"panorama" -> "panorama"
|
|
798
978
|
"scans" -> "scans"
|
|
799
|
-
else -> resolveStitchModeAuto(firstPose, lastPose)
|
|
979
|
+
else -> resolveStitchModeAuto(firstPose, lastPose, imuTranslationMetres)
|
|
800
980
|
}
|
|
801
981
|
android.util.Log.i(
|
|
802
982
|
"IncrementalStitcher",
|
|
803
983
|
"finalize stitch-mode: configured=$batchStitchMode resolved=$stitchModeResolved " +
|
|
804
|
-
"firstPose=${firstPose != null} lastPose=${lastPose != null}"
|
|
984
|
+
"firstPose=${firstPose != null} lastPose=${lastPose != null} " +
|
|
985
|
+
"imuT=${"%.3f".format(imuTranslationMetres)}m",
|
|
805
986
|
)
|
|
806
987
|
batchKeyframeMode = false
|
|
807
988
|
batchKeyframePaths.clear()
|
|
@@ -879,6 +1060,10 @@ class IncrementalStitcher(
|
|
|
879
1060
|
map.putInt("framesIncluded", framesIncluded)
|
|
880
1061
|
map.putInt("framesDropped", framesRequested - framesIncluded)
|
|
881
1062
|
map.putDouble("finalConfidenceThresh", finalConfidenceThresh)
|
|
1063
|
+
// 2026-05-22 (audit F2g) — iOS parity. Echo the
|
|
1064
|
+
// resolved cv::Stitcher mode so JS can surface it
|
|
1065
|
+
// on the output preview + debug toast.
|
|
1066
|
+
map.putString("stitchModeResolved", stitchModeResolved)
|
|
882
1067
|
} else if (firstwins != null) {
|
|
883
1068
|
val snap = firstwins.finalize(outputPath, quality)
|
|
884
1069
|
?: throw IllegalStateException("firstwins.finalize returned null")
|
|
@@ -979,14 +1164,47 @@ class IncrementalStitcher(
|
|
|
979
1164
|
|
|
980
1165
|
/**
|
|
981
1166
|
* Called by `RNSARCameraView` per ARCore frame when it has
|
|
982
|
-
* a fresh
|
|
983
|
-
* caller's perspective
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
1167
|
+
* a fresh Y-plane + pose to ingest. Synchronous from the
|
|
1168
|
+
* caller's perspective. Drops the frame silently if no engine
|
|
1169
|
+
* is running (race between view lifecycle and stitcher start/stop).
|
|
1170
|
+
*
|
|
1171
|
+
* 2026-05-21 (v0.3) — pixel-data path. Pre-0.3 this method took
|
|
1172
|
+
* a `path: String` argument pointing at a JPEG that the camera
|
|
1173
|
+
* view had already encoded for every ARCore frame whether the
|
|
1174
|
+
* gate would accept it or not (~25 ms of JPEG-encode + disk I/O
|
|
1175
|
+
* per frame at ~60 Hz). The gate then ran a pose-only
|
|
1176
|
+
* evaluation because it had no pixel data, so the C++ Flow
|
|
1177
|
+
* strategy silently fell back to Pose.
|
|
1178
|
+
*
|
|
1179
|
+
* The new contract: caller hands us the frame's grayscale Y
|
|
1180
|
+
* plane bytes (already in memory from the YUV camera image —
|
|
1181
|
+
* zero new JPEG cost) and an `onAccept` lambda that knows how
|
|
1182
|
+
* to encode + persist a JPEG given a target path. The lambda
|
|
1183
|
+
* runs ONLY if the gate accepts. Net wins:
|
|
1184
|
+
* • Flow strategy actually runs on accepted-or-not decisions.
|
|
1185
|
+
* • Per-frame disk I/O eliminated for rejected frames
|
|
1186
|
+
* (the typical 95%+ of frames in a capture).
|
|
1187
|
+
* • Lazy JPEG encode + write happens at most ~6 times per
|
|
1188
|
+
* capture (the gate's keyframeMaxCount), inside the lambda
|
|
1189
|
+
* while the caller still holds the ARCore Image open.
|
|
1190
|
+
*
|
|
1191
|
+
* @param grayData Y-plane (or otherwise grayscale) bytes.
|
|
1192
|
+
* Length must be ≥ grayStride * grayHeight.
|
|
1193
|
+
* @param grayWidth Image width in pixels.
|
|
1194
|
+
* @param grayHeight Image height in pixels.
|
|
1195
|
+
* @param grayStride Bytes per row; may exceed grayWidth when
|
|
1196
|
+
* the source plane has padding (ARCore can pad).
|
|
1197
|
+
* @param onAccept Invoked ONLY if the gate accepts this frame.
|
|
1198
|
+
* Receives the absolute target path
|
|
1199
|
+
* `<captureSessionDir>/keyframe-N.jpg` that the
|
|
1200
|
+
* callee MUST write a full-resolution JPEG of
|
|
1201
|
+
* the current camera image to. Returns true
|
|
1202
|
+
* on success, false if the encode/write
|
|
1203
|
+
* failed (the frame is then dropped; gate
|
|
1204
|
+
* counter was already incremented, next
|
|
1205
|
+
* acceptable frame still lands on its own).
|
|
987
1206
|
*/
|
|
988
1207
|
internal fun ingestFromARCameraView(
|
|
989
|
-
path: String,
|
|
990
1208
|
tx: Double, ty: Double, tz: Double,
|
|
991
1209
|
qx: Double, qy: Double, qz: Double, qw: Double,
|
|
992
1210
|
fx: Double, fy: Double, cx: Double, cy: Double,
|
|
@@ -996,6 +1214,19 @@ class IncrementalStitcher(
|
|
|
996
1214
|
fovHorizDegrees: Double,
|
|
997
1215
|
fovVertDegrees: Double,
|
|
998
1216
|
trackingPoor: Boolean,
|
|
1217
|
+
grayData: ByteArray,
|
|
1218
|
+
grayWidth: Int,
|
|
1219
|
+
grayHeight: Int,
|
|
1220
|
+
grayStride: Int,
|
|
1221
|
+
onAccept: (targetPath: String) -> Boolean,
|
|
1222
|
+
// 2026-05-21 (v0.3) — only required when batchKeyframeMode
|
|
1223
|
+
// is false (the legacy hybrid/firstwins live-engine path,
|
|
1224
|
+
// which feeds JPEG paths into addFrameAtPath for each ARCore
|
|
1225
|
+
// frame). Pass null when batchKeyframeMode is true; the
|
|
1226
|
+
// batch path uses `grayData` + `onAccept` instead. Callers
|
|
1227
|
+
// can check `isBatchKeyframeMode` to elide the per-frame
|
|
1228
|
+
// JPEG encode for the batch path.
|
|
1229
|
+
legacyJpegPath: String? = null,
|
|
999
1230
|
) {
|
|
1000
1231
|
// ── V16 batch-keyframe: AR-driven path ─────────────────────
|
|
1001
1232
|
//
|
|
@@ -1041,7 +1272,31 @@ class IncrementalStitcher(
|
|
|
1041
1272
|
FloatArray(16).also { p.toMatrix(it, 0) }
|
|
1042
1273
|
}
|
|
1043
1274
|
|
|
1044
|
-
|
|
1275
|
+
// 2026-05-22 (audit F5) — eval-throttle. When
|
|
1276
|
+
// flowEvalEveryNFrames > 1, evaluate the gate every Nth
|
|
1277
|
+
// ARCore frame instead of every frame. Cuts CPU on the
|
|
1278
|
+
// 30-60Hz delegate path linearly with N. First frame
|
|
1279
|
+
// (counter=1) always evaluates regardless of N because
|
|
1280
|
+
// (1 - 1) % N == 0 for any N ≥ 1. iOS parity:
|
|
1281
|
+
// IncrementalStitcher.swift:2459-2471. Skipped frames
|
|
1282
|
+
// are dropped entirely — NOT saved as keyframes, NOT
|
|
1283
|
+
// counted toward the keyframe budget.
|
|
1284
|
+
consumeFrameCounter += 1L
|
|
1285
|
+
val evalCadence = keyframeGate.flowEvalEveryNFrames.coerceAtLeast(1)
|
|
1286
|
+
if ((consumeFrameCounter - 1) % evalCadence != 0L) {
|
|
1287
|
+
return
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// 2026-05-21 (v0.3) — pixel-aware evaluation. Hands the
|
|
1291
|
+
// gate the Y-plane bytes so the Flow strategy actually
|
|
1292
|
+
// runs sparse-flow novelty on real image content (pre-0.3
|
|
1293
|
+
// this fell back to Pose strategy because the JS-bridge
|
|
1294
|
+
// path supplied no pixel data — same bug as the iOS
|
|
1295
|
+
// non-AR path; both fixed in v0.3).
|
|
1296
|
+
val decision = keyframeGate.evaluateWithFrame(
|
|
1297
|
+
pose, planeMatrix,
|
|
1298
|
+
grayData, grayWidth, grayHeight, grayStride,
|
|
1299
|
+
)
|
|
1045
1300
|
|
|
1046
1301
|
// ── P3-G diagnostic ──────────────────────────────────
|
|
1047
1302
|
// Rate-limit at the same cadence as the plane evaluator
|
|
@@ -1059,26 +1314,53 @@ class IncrementalStitcher(
|
|
|
1059
1314
|
)
|
|
1060
1315
|
}
|
|
1061
1316
|
if (!decision.accept) {
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1317
|
+
// 2026-05-22 (audit follow-up) — emit a reject-state
|
|
1318
|
+
// event so the JS debug overlay sees a live overlap %
|
|
1319
|
+
// (matches iOS' emitKeyframeRejectState at
|
|
1320
|
+
// IncrementalStitcher.swift:2143). No disk I/O — the
|
|
1321
|
+
// onAccept lambda is NOT invoked. Cost: one extra
|
|
1322
|
+
// JS event per evaluated frame; with the F5 eval-
|
|
1323
|
+
// throttle at default 5, that's ~6 events/sec at
|
|
1324
|
+
// 30 Hz ARCore — fine.
|
|
1325
|
+
emitBatchKeyframeRejectState(
|
|
1326
|
+
decision = decision,
|
|
1327
|
+
keyframeCount = batchKeyframePaths.size,
|
|
1328
|
+
keyframeMax = keyframeGate.maxCount,
|
|
1329
|
+
isLandscape = imageWidth >= imageHeight,
|
|
1330
|
+
)
|
|
1067
1331
|
return
|
|
1068
1332
|
}
|
|
1069
|
-
// Accepted —
|
|
1070
|
-
//
|
|
1071
|
-
//
|
|
1072
|
-
|
|
1073
|
-
|
|
1333
|
+
// Accepted — generate the per-keyframe target path and
|
|
1334
|
+
// invoke the caller's onAccept lambda for the lazy JPEG
|
|
1335
|
+
// encode + write. The caller (the AR camera view) still
|
|
1336
|
+
// holds the ARCore Image open at this point, so it can
|
|
1337
|
+
// encode raw camera pixels directly to disk without any
|
|
1338
|
+
// redundant copy. Single disk write per accepted frame
|
|
1339
|
+
// (pre-0.3 was: write to tmp, then copy to store = two
|
|
1340
|
+
// disk writes; now we write to the final path directly).
|
|
1341
|
+
val dir = captureSessionDir
|
|
1342
|
+
if (dir == null) {
|
|
1074
1343
|
android.util.Log.w(
|
|
1075
1344
|
"IncrementalStitcher",
|
|
1076
|
-
"ingestFromARCameraView batch: ACCEPTED but
|
|
1345
|
+
"ingestFromARCameraView batch: ACCEPTED but " +
|
|
1346
|
+
"captureSessionDir is null — frame dropped " +
|
|
1347
|
+
"(start() should have created it)",
|
|
1077
1348
|
)
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1349
|
+
return
|
|
1350
|
+
}
|
|
1351
|
+
val persistentPath = java.io.File(
|
|
1352
|
+
dir, "keyframe-${batchKeyframePaths.size}.jpg"
|
|
1353
|
+
).absolutePath
|
|
1354
|
+
val ok = onAccept(persistentPath)
|
|
1355
|
+
if (!ok) {
|
|
1356
|
+
android.util.Log.w(
|
|
1357
|
+
"IncrementalStitcher",
|
|
1358
|
+
"ingestFromARCameraView batch: ACCEPTED but onAccept returned false — frame dropped",
|
|
1359
|
+
)
|
|
1360
|
+
// Encode/persist failed — drop the frame. Counter
|
|
1361
|
+
// was already incremented inside the gate; that's
|
|
1362
|
+
// fine — the next acceptable frame still lands on
|
|
1363
|
+
// its own merits.
|
|
1082
1364
|
return
|
|
1083
1365
|
}
|
|
1084
1366
|
batchKeyframePaths.add(persistentPath)
|
|
@@ -1107,6 +1389,7 @@ class IncrementalStitcher(
|
|
|
1107
1389
|
keyframeCount = batchKeyframePaths.size,
|
|
1108
1390
|
keyframeMax = keyframeGate.maxCount,
|
|
1109
1391
|
isLandscape = imageWidth >= imageHeight,
|
|
1392
|
+
newContentFraction = decision.newContentFraction,
|
|
1110
1393
|
)
|
|
1111
1394
|
return
|
|
1112
1395
|
}
|
|
@@ -1114,6 +1397,23 @@ class IncrementalStitcher(
|
|
|
1114
1397
|
val hybrid = this.engine
|
|
1115
1398
|
val firstwins = this.firstwinsEngine
|
|
1116
1399
|
if (hybrid == null && firstwins == null) return
|
|
1400
|
+
// 2026-05-21 (v0.3) — legacy live-engine path requires a JPEG
|
|
1401
|
+
// path (hybrid/firstwins addFrameAtPath feeds the cv::Mat
|
|
1402
|
+
// pipeline). The batch-keyframe path above lazily encodes
|
|
1403
|
+
// only on accept and reaches `return` before this point, so
|
|
1404
|
+
// we only get here when batchKeyframeMode == false. Caller
|
|
1405
|
+
// (RNSARCameraView) was expected to supply legacyJpegPath in
|
|
1406
|
+
// that case — defensively drop the frame if it didn't.
|
|
1407
|
+
val path = legacyJpegPath ?: run {
|
|
1408
|
+
android.util.Log.w(
|
|
1409
|
+
"IncrementalStitcher",
|
|
1410
|
+
"ingestFromARCameraView legacy: batchKeyframeMode=false " +
|
|
1411
|
+
"but legacyJpegPath is null — dropping frame. " +
|
|
1412
|
+
"Caller should have encoded a JPEG when " +
|
|
1413
|
+
"isBatchKeyframeMode == false.",
|
|
1414
|
+
)
|
|
1415
|
+
return
|
|
1416
|
+
}
|
|
1117
1417
|
workScope.launch {
|
|
1118
1418
|
val state: WritableMap? = if (firstwins != null) {
|
|
1119
1419
|
val tele = firstwins.addFrameAtPath(
|
|
@@ -1667,12 +1967,62 @@ class IncrementalStitcher(
|
|
|
1667
1967
|
* exactly (same field names, types, order) so the JS subscriber
|
|
1668
1968
|
* in incremental.ts doesn't need to branch on platform.
|
|
1669
1969
|
*/
|
|
1970
|
+
/**
|
|
1971
|
+
* 2026-05-22 (audit follow-up) — emit a state event when the gate
|
|
1972
|
+
* REJECTS a frame in batch-keyframe mode. iOS does this via
|
|
1973
|
+
* `emitKeyframeRejectState` so the debug overlay's overlap %
|
|
1974
|
+
* updates continuously as the operator pans (even when no new
|
|
1975
|
+
* keyframe is being accepted). Without this, Android's overlay
|
|
1976
|
+
* was frozen between accepts — operator could see "5 / 6
|
|
1977
|
+
* frames" but not "currently 92% overlap, need to pan more".
|
|
1978
|
+
*
|
|
1979
|
+
* Outcome enum: 5 = RejectedOverlap (matches iOS' RLISFrameOutcomeRejectedOverlap).
|
|
1980
|
+
*/
|
|
1981
|
+
private fun emitBatchKeyframeRejectState(
|
|
1982
|
+
decision: KeyframeGateDecision,
|
|
1983
|
+
keyframeCount: Int,
|
|
1984
|
+
keyframeMax: Int,
|
|
1985
|
+
isLandscape: Boolean,
|
|
1986
|
+
) {
|
|
1987
|
+
val state = Arguments.createMap()
|
|
1988
|
+
state.putNull("panoramaPath")
|
|
1989
|
+
state.putInt("width", 0)
|
|
1990
|
+
state.putInt("height", 0)
|
|
1991
|
+
state.putInt("acceptedCount", keyframeCount)
|
|
1992
|
+
// Map gate-reject reason → numeric outcome. "max-reached" is
|
|
1993
|
+
// its own outcome (6 = RejectedMaxKeyframes); everything else
|
|
1994
|
+
// is the generic overlap-rejected (5).
|
|
1995
|
+
val outcome = if (decision.reason == "max-reached") 6 else 5
|
|
1996
|
+
state.putInt("outcome", outcome)
|
|
1997
|
+
state.putDouble("confidence", 0.0)
|
|
1998
|
+
val overlapPercent = if (decision.newContentFraction >= 0.0) {
|
|
1999
|
+
(1.0 - decision.newContentFraction) * 100.0
|
|
2000
|
+
} else {
|
|
2001
|
+
-1.0
|
|
2002
|
+
}
|
|
2003
|
+
state.putDouble("overlapPercent", overlapPercent)
|
|
2004
|
+
state.putInt("processingMs", 0)
|
|
2005
|
+
state.putBoolean("isLandscape", isLandscape)
|
|
2006
|
+
state.putInt("paintedExtent", 0)
|
|
2007
|
+
state.putInt("panExtent", 0)
|
|
2008
|
+
state.putInt("keyframeMax", keyframeMax)
|
|
2009
|
+
emitState(state)
|
|
2010
|
+
}
|
|
2011
|
+
|
|
1670
2012
|
private fun emitBatchKeyframeAcceptedState(
|
|
1671
2013
|
thumbnailPath: String,
|
|
1672
2014
|
keyframeIndex: Int,
|
|
1673
2015
|
keyframeCount: Int,
|
|
1674
2016
|
keyframeMax: Int,
|
|
1675
2017
|
isLandscape: Boolean,
|
|
2018
|
+
// 2026-05-22 (audit follow-up) — overlap % was hardcoded to
|
|
2019
|
+
// -1 here, so the debug overlay's `overlap` row was blank
|
|
2020
|
+
// on Android. iOS computes overlapPercent from the gate's
|
|
2021
|
+
// newContentFraction via `(1 - newContent) * 100`. Match
|
|
2022
|
+
// that conversion here. Pass -1.0 to keep the legacy
|
|
2023
|
+
// "unknown" behaviour for call sites that don't have a
|
|
2024
|
+
// decision in hand.
|
|
2025
|
+
newContentFraction: Double,
|
|
1676
2026
|
) {
|
|
1677
2027
|
val state = Arguments.createMap()
|
|
1678
2028
|
state.putNull("panoramaPath")
|
|
@@ -1685,7 +2035,12 @@ class IncrementalStitcher(
|
|
|
1685
2035
|
// accepts all carry outcome=acceptedHigh.
|
|
1686
2036
|
state.putInt("outcome", 0)
|
|
1687
2037
|
state.putDouble("confidence", 1.0)
|
|
1688
|
-
|
|
2038
|
+
val overlapPercent = if (newContentFraction >= 0.0) {
|
|
2039
|
+
(1.0 - newContentFraction) * 100.0
|
|
2040
|
+
} else {
|
|
2041
|
+
-1.0
|
|
2042
|
+
}
|
|
2043
|
+
state.putDouble("overlapPercent", overlapPercent)
|
|
1689
2044
|
state.putInt("processingMs", 0)
|
|
1690
2045
|
state.putBoolean("isLandscape", isLandscape)
|
|
1691
2046
|
state.putInt("paintedExtent", 0) // batch-keyframe doesn't
|
|
@@ -1727,15 +2082,31 @@ class IncrementalStitcher(
|
|
|
1727
2082
|
private fun resolveStitchModeAuto(
|
|
1728
2083
|
firstPose: DoubleArray?,
|
|
1729
2084
|
lastPose: DoubleArray?,
|
|
2085
|
+
// 2026-05-22 (audit F2b) — JS-measured cumulative IMU
|
|
2086
|
+
// translation in METRES. Used as a fallback when pose-derived
|
|
2087
|
+
// translation is 0 (non-AR mode).
|
|
2088
|
+
imuTranslationMetres: Double = 0.0,
|
|
1730
2089
|
): String {
|
|
1731
|
-
if (firstPose == null || lastPose == null)
|
|
1732
|
-
|
|
2090
|
+
if (firstPose == null || lastPose == null) {
|
|
2091
|
+
// No pose data at all — fall back on the IMU signal. IMU
|
|
2092
|
+
// > 5 cm hints SCANS; everything else hints PANORAMA.
|
|
2093
|
+
return if (imuTranslationMetres > 0.05) "scans" else "panorama"
|
|
2094
|
+
}
|
|
2095
|
+
if (firstPose.size != 7 || lastPose.size != 7) {
|
|
2096
|
+
return if (imuTranslationMetres > 0.05) "scans" else "panorama"
|
|
2097
|
+
}
|
|
1733
2098
|
|
|
1734
|
-
// Translation magnitude (Euclidean, in metres).
|
|
2099
|
+
// Translation magnitude (Euclidean, in metres) — pose-derived.
|
|
1735
2100
|
val dtx = lastPose[0] - firstPose[0]
|
|
1736
2101
|
val dty = lastPose[1] - firstPose[1]
|
|
1737
2102
|
val dtz = lastPose[2] - firstPose[2]
|
|
1738
|
-
val
|
|
2103
|
+
val tPose = kotlin.math.sqrt(dtx * dtx + dty * dty + dtz * dtz)
|
|
2104
|
+
// 2026-05-22 (audit F2b) — non-AR mode delivers pose-derived
|
|
2105
|
+
// translation = 0 because the JS-driver path doesn't carry
|
|
2106
|
+
// tx/ty/tz. Take the larger of pose-derived and IMU-measured
|
|
2107
|
+
// so AR (accurate pose) and non-AR (IMU only) both produce a
|
|
2108
|
+
// meaningful ratio.
|
|
2109
|
+
val tMeters = kotlin.math.max(tPose, imuTranslationMetres)
|
|
1739
2110
|
|
|
1740
2111
|
// Rotation magnitude — angle between camera-forward vectors.
|
|
1741
2112
|
// Camera-forward in body frame is (0, 0, -1) for ARKit/ARCore
|
|
@@ -1757,12 +2128,13 @@ class IncrementalStitcher(
|
|
|
1757
2128
|
val tScore = tMeters / 0.10
|
|
1758
2129
|
val rScore = rRadians / 1.00
|
|
1759
2130
|
val denom = tScore + rScore
|
|
1760
|
-
if (denom <= 1e-9) return "
|
|
2131
|
+
if (denom <= 1e-9) return "panorama" // no motion either way
|
|
1761
2132
|
val ratio = tScore / denom
|
|
1762
2133
|
|
|
1763
2134
|
android.util.Log.i(
|
|
1764
2135
|
"IncrementalStitcher",
|
|
1765
|
-
"stitch-mode auto:
|
|
2136
|
+
"stitch-mode auto: tPose=${"%.3f".format(tPose)}m " +
|
|
2137
|
+
"tImu=${"%.3f".format(imuTranslationMetres)}m " +
|
|
1766
2138
|
"r=${"%.3f".format(rRadians)}rad " +
|
|
1767
2139
|
"ratio=${"%.3f".format(ratio)} " +
|
|
1768
2140
|
"→ ${if (ratio >= 0.55) "scans" else "panorama"}",
|