react-native-image-stitcher 0.2.1 → 0.3.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +316 -1
  2. package/README.md +1 -1
  3. package/android/src/main/cpp/keyframe_gate_jni.cpp +138 -0
  4. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +412 -40
  5. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +128 -0
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +87 -45
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +46 -4
  8. package/cpp/stitcher.cpp +101 -1
  9. package/cpp/stitcher.hpp +8 -0
  10. package/dist/camera/Camera.d.ts +9 -0
  11. package/dist/camera/Camera.js +118 -8
  12. package/dist/camera/CaptureDebugOverlay.d.ts +45 -0
  13. package/dist/camera/CaptureDebugOverlay.js +146 -0
  14. package/dist/camera/CaptureKeyframePill.d.ts +28 -0
  15. package/dist/camera/CaptureKeyframePill.js +60 -0
  16. package/dist/camera/CaptureMemoryPill.d.ts +28 -0
  17. package/dist/camera/CaptureMemoryPill.js +109 -0
  18. package/dist/camera/CaptureOrientationPill.d.ts +22 -0
  19. package/dist/camera/CaptureOrientationPill.js +44 -0
  20. package/dist/camera/CaptureStitchStatsToast.d.ts +45 -0
  21. package/dist/camera/CaptureStitchStatsToast.js +133 -0
  22. package/dist/camera/PanoramaSettingsModal.d.ts +6 -5
  23. package/dist/index.d.ts +10 -0
  24. package/dist/index.js +15 -1
  25. package/dist/sensors/useIMUTranslationGate.d.ts +26 -0
  26. package/dist/sensors/useIMUTranslationGate.js +83 -1
  27. package/dist/stitching/incremental.d.ts +25 -0
  28. package/dist/stitching/useIncrementalStitcher.d.ts +12 -1
  29. package/dist/stitching/useIncrementalStitcher.js +7 -1
  30. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +321 -7
  31. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +8 -0
  32. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +12 -0
  33. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +13 -0
  34. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +15 -0
  35. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +1 -0
  36. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +17 -4
  37. package/ios/Sources/RNImageStitcher/Stitcher.swift +6 -1
  38. package/package.json +1 -1
  39. package/src/camera/Camera.tsx +165 -7
  40. package/src/camera/CaptureDebugOverlay.tsx +180 -0
  41. package/src/camera/CaptureKeyframePill.tsx +77 -0
  42. package/src/camera/CaptureMemoryPill.tsx +96 -0
  43. package/src/camera/CaptureOrientationPill.tsx +57 -0
  44. package/src/camera/CaptureStitchStatsToast.tsx +155 -0
  45. package/src/camera/PanoramaSettingsModal.tsx +6 -5
  46. package/src/index.ts +19 -0
  47. package/src/sensors/useIMUTranslationGate.ts +112 -1
  48. package/src/stitching/incremental.ts +25 -0
  49. 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
- // 2026-05-14 non-AR mode opt-out for angular fallback.
408
- // captureSource {wide, ultrawide} means the host is using
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
- val captureSource = configOverrides?.getString("captureSource") ?: "auto"
412
- val isNonAR = (captureSource == "wide" || captureSource == "ultrawide")
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
- keyframeGate.enabled = true
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++ uses angular fallback.
595
- val decision = keyframeGate.evaluate(pose, null)
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 JPEG + pose to ingest. Synchronous-feeling from the
983
- * caller's perspective but actually dispatched onto the engine's
984
- * own queue so we don't stall the GL render thread. Drops the
985
- * frame silently if no engine is running (race between view
986
- * lifecycle and stitcher start/stop).
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
- val decision = keyframeGate.evaluate(pose, planeMatrix)
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
- // Frame rejected by the gate could be overlap-too-
1063
- // high (most common), max-reached, or projection-
1064
- // degenerate. Drop silently; the next frame will be
1065
- // evaluated. TODO(P1-followup): emit a state event
1066
- // so the JS UI can surface the reason in the pill.
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 — copy the (reused) tmp JPEG to a persistent
1070
- // per-keyframe path so subsequent frames overwriting the
1071
- // source don't clobber it.
1072
- val persistentPath = copyKeyframeToStore(path)
1073
- if (persistentPath == null) {
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 copy FAILED — frame dropped",
1345
+ "ingestFromARCameraView batch: ACCEPTED but " +
1346
+ "captureSessionDir is null — frame dropped " +
1347
+ "(start() should have created it)",
1077
1348
  )
1078
- // Copy failed — drop the frame. Logged inside
1079
- // copyKeyframeToStore. Counter was already incremented
1080
- // inside the gate; that's fine — the next acceptable
1081
- // frame will still be accepted on its own merits.
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
- state.putDouble("overlapPercent", -1.0)
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) return "scans"
1732
- if (firstPose.size != 7 || lastPose.size != 7) return "scans"
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 tMeters = kotlin.math.sqrt(dtx * dtx + dty * dty + dtz * dtz)
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 "scans" // degenerate — no motion at all
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: t=${"%.3f".format(tMeters)}m " +
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"}",