react-native-image-stitcher 0.16.0 → 0.16.2

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 (38) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +41 -44
  3. package/android/build.gradle +34 -0
  4. package/android/src/main/cpp/image_stitcher_jni.cpp +52 -7
  5. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  6. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +135 -59
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  8. package/cpp/keyframe_gate.cpp +54 -15
  9. package/cpp/keyframe_gate.hpp +33 -0
  10. package/cpp/stitcher.cpp +481 -87
  11. package/cpp/stitcher.hpp +52 -0
  12. package/dist/camera/Camera.d.ts +13 -0
  13. package/dist/camera/Camera.js +9 -64
  14. package/dist/camera/CaptureFrameCounterOverlay.js +12 -1
  15. package/dist/camera/CaptureMemoryPill.d.ts +15 -7
  16. package/dist/camera/CaptureMemoryPill.js +34 -9
  17. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  18. package/dist/camera/PanoramaBandOverlay.js +9 -3
  19. package/dist/camera/PanoramaSettings.js +22 -25
  20. package/dist/camera/RectCropPreview.d.ts +3 -29
  21. package/dist/camera/RectCropPreview.js +20 -130
  22. package/dist/stitching/incremental.d.ts +29 -0
  23. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  24. package/dist/stitching/useIncrementalStitcher.js +7 -1
  25. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +103 -26
  26. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  27. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  28. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +21 -1
  29. package/package.json +1 -1
  30. package/src/camera/Camera.tsx +21 -70
  31. package/src/camera/CaptureFrameCounterOverlay.tsx +15 -1
  32. package/src/camera/CaptureMemoryPill.tsx +33 -9
  33. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  34. package/src/camera/PanoramaSettings.ts +22 -25
  35. package/src/camera/RectCropPreview.tsx +38 -220
  36. package/src/stitching/incremental.ts +29 -0
  37. package/src/stitching/useIncrementalStitcher.ts +13 -0
  38. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
@@ -633,7 +633,8 @@ class IncrementalStitcher(
633
633
  val wasBatchKeyframe = batchKeyframeMode
634
634
  val keyframePathsSnapshot = batchKeyframePaths.toList()
635
635
  val captureOrientationSnapshot = batchCaptureOrientation
636
- val warperTypeSnapshot = batchWarperType
636
+ // batchWarperType (settings) is superseded by the high-level warper tree
637
+ // (pickHighLevelWarper) below — kept as a field for back-compat, unused here.
637
638
  val blenderTypeSnapshot = batchBlenderType
638
639
  val seamFinderTypeSnapshot = batchSeamFinderType
639
640
  val useInscribedRectCropSnapshot = batchUseInscribedRectCrop
@@ -664,11 +665,31 @@ class IncrementalStitcher(
664
665
  // falls back to pose data only. Always non-negative.
665
666
  val imuTranslationMetres = (options.getDoubleOrDefault("imuTranslationMetres", 0.0) ?: 0.0)
666
667
  .coerceAtLeast(0.0)
668
+ // Resolve once so the dev readout gets the SAME tMeters / ratio / rRadians
669
+ // that drove the decision — and gets them even when the mode was forced
670
+ // (informative: shows what auto WOULD have picked).
671
+ val autoResolution = resolveStitchModeAuto(firstPose, lastPose, imuTranslationMetres)
667
672
  val stitchModeResolved: String = when (batchStitchMode) {
668
673
  "panorama" -> "panorama"
669
674
  "scans" -> "scans"
670
- else -> resolveStitchModeAuto(firstPose, lastPose, imuTranslationMetres)
675
+ else -> autoResolution.mode
671
676
  }
677
+ // Surface the gyro rotation + translation + decision ratio for EVERY
678
+ // capture (the forced modes skip the auto decision, but the dev preview
679
+ // still reads these to tune the panorama-vs-SCANS threshold).
680
+ val rRadiansResolved: Double = autoResolution.rRadians
681
+ val tMetersResolved: Double = autoResolution.tMeters
682
+ val decisionRatioResolved: Double = autoResolution.ratio
683
+ // 2026-06-16 — HIGH-LEVEL ACROSS THE BOARD. Pick the warper from the
684
+ // (motion, Mode A/B, zoom) tree and always run cv::Stitcher PANORAMA
685
+ // (useManualPipeline=false at the stitchSync call below). stitchModeResolved
686
+ // is now only the MOTION classifier feeding the tree + the dev readout;
687
+ // the actual stitch mode is always panorama. Zoom comes from the EXPLICIT
688
+ // lens label the user selected ('1x'|'0.5x') — the reliable signal (FOV
689
+ // from intrinsics was unreliable: multi-cam 0.5x doesn't change fx, and
690
+ // the non-AR path may supply fx=0 → FOV defaulted to 65° → never 0.5x).
691
+ val lensOpt = options.getString("lens") ?: "1x"
692
+ val highLevelWarper = pickHighLevelWarper(captureOrientationSnapshot, lensOpt)
672
693
  android.util.Log.i(
673
694
  "IncrementalStitcher",
674
695
  "finalize stitch-mode: configured=$batchStitchMode resolved=$stitchModeResolved " +
@@ -714,12 +735,13 @@ class IncrementalStitcher(
714
735
  keyframePathsSnapshot.toTypedArray(),
715
736
  outputPath,
716
737
  quality,
717
- warperTypeSnapshot,
738
+ highLevelWarper, // tree-chosen (was batchWarperType)
718
739
  blenderTypeSnapshot,
719
740
  seamFinderTypeSnapshot,
720
741
  captureOrientationSnapshot,
721
742
  useInscribedRectCropSnapshot,
722
- stitchMode = stitchModeResolved,
743
+ stitchMode = "panorama", // always high-level PANORAMA
744
+ useManualPipeline = false, // high level across the board
723
745
  )
724
746
  // 2026-05-15 (D) — dims layout from native JNI:
725
747
  // [0] width, [1] height, [2] framesRequested,
@@ -748,6 +770,11 @@ class IncrementalStitcher(
748
770
  // resolved cv::Stitcher mode so JS can surface it
749
771
  // on the output preview + debug toast.
750
772
  map.putString("stitchModeResolved", stitchModeResolved)
773
+ map.putDouble("rRadians", rRadiansResolved)
774
+ // Dev tuning readout — translation magnitude + the auto
775
+ // decision ratio that drove panorama-vs-SCANS.
776
+ map.putDouble("tMeters", tMetersResolved)
777
+ map.putDouble("decisionRatio", decisionRatioResolved)
751
778
  // 2026-06-15 (iOS parity) — the exact keyframe JPEG
752
779
  // paths used for this stitch, so JS can re-stitch
753
780
  // them ON DEMAND via refinePanorama (the high-level
@@ -875,36 +902,13 @@ class IncrementalStitcher(
875
902
  grayHeight: Int,
876
903
  grayStride: Int,
877
904
  onAccept: (targetPath: String) -> Boolean,
878
- // 2026-05-21 (v0.3) — only required when batchKeyframeMode
879
- // is false (the legacy hybrid/firstwins live-engine path,
880
- // which feeds JPEG paths into addFrameAtPath for each ARCore
881
- // frame). Pass null when batchKeyframeMode is true; the
882
- // batch path uses `grayData` + `onAccept` instead. Modern
883
- // callers prefer `nv21PixelData` below`legacyJpegPath` is
884
- // kept only as a defensive fallback for older call sites
885
- // that have not yet been migrated.
886
- legacyJpegPath: String? = null,
887
- // F8.6 — pixel-data path for live engines. When supplied
888
- // (and `batchKeyframeMode == false`), takes precedence over
889
- // `legacyJpegPath`: the live engine ingests via
890
- // `addFramePixelData` (NV21 → BGR Mat in-process) instead of
891
- // `addFrameAtPath` (JPEG decode round-trip). Saves ~30-50 ms
892
- // per accepted frame on a mid-tier device. Pass null to use
893
- // the legacy JPEG path.
894
- //
895
- // OWNERSHIP: wrapped in `TransferredNV21` (audit #4A,
896
- // v0.10.0). The wrapper enforces single-use: the engine
897
- // calls `.takeOnce()` on the producer thread before
898
- // dispatching to `workScope`; subsequent attempts to extract
899
- // the bytes throw. Callers MUST construct a fresh
900
- // `TransferredNV21` per frame and MUST NOT hand the same
901
- // instance to two consumers (e.g., a sync gate-eval + an
902
- // async workScope.launch). The Frame Processor plugin and
903
- // the AR camera view both allocate fresh NV21 arrays per
904
- // frame; the wrapper is a defensive-programming guard.
905
- nv21PixelData: TransferredNV21? = null,
906
- nv21PixelWidth: Int = 0,
907
- nv21PixelHeight: Int = 0,
905
+ // 2026-06-16 (audit #8/L3) — the live-engine ingest params
906
+ // (legacyJpegPath / nv21PixelData / nv21PixelWidth/Height) were
907
+ // removed here. The live engines were archived in 2026-06, so the
908
+ // only remaining path is batch-keyframe (always on), which ingests via
909
+ // `grayData` + `onAccept`. The TransferredNV21 ownership wrapper had no
910
+ // live consumer (takeOnce() called nowhere verified by grep) and is
911
+ // deleted along with these params.
908
912
  ) {
909
913
  // ── V16 batch-keyframe: AR-driven path ─────────────────────
910
914
  //
@@ -1213,21 +1217,6 @@ class IncrementalStitcher(
1213
1217
  grayWidth = width,
1214
1218
  grayHeight = height,
1215
1219
  grayStride = yRowStride,
1216
- // F8.6 — pass the already-packed NV21 so the live
1217
- // engine branch (hybrid / firstwins) can ingest via
1218
- // `addFramePixelData` instead of JPEG-decoding a
1219
- // separately-written path. Batch-keyframe mode
1220
- // ignores these (it uses `grayData` + `onAccept`).
1221
- //
1222
- // v0.10.0 audit #4A — wrap in TransferredNV21 so the
1223
- // engine takes ownership exactly once on the producer
1224
- // thread (engine calls `.takeOnce()` before workScope).
1225
- // Misuse (handing this same instance to two consumers)
1226
- // throws at the second `.takeOnce()` site, not silently
1227
- // corrupting frames.
1228
- nv21PixelData = TransferredNV21(nv21Bytes),
1229
- nv21PixelWidth = width,
1230
- nv21PixelHeight = height,
1231
1220
  onAccept = { targetPath ->
1232
1221
  // Synchronous JPEG encode via the existing
1233
1222
  // YuvImageConverter (also used by RNSARCameraView's
@@ -1737,6 +1726,33 @@ class IncrementalStitcher(
1737
1726
  }
1738
1727
  }
1739
1728
 
1729
+ /**
1730
+ * Total physical RAM in MB. Lets the DEV memory pill derive RAM-aware
1731
+ * pressure bands instead of the iPhone-fixed 1500/2200 MB thresholds (which
1732
+ * never trip on a 4 GB Android phone that jetsams ~1.3 GB — false comfort).
1733
+ * Reads `_SC_PHYS_PAGES × _SC_PAGE_SIZE` (TOTAL + stable across runs, unlike
1734
+ * the rate-limited ActivityManager path). -1.0 on failure.
1735
+ */
1736
+ @ReactMethod
1737
+ fun getDeviceTotalRamMB(promise: Promise) {
1738
+ try {
1739
+ val pages = android.system.Os.sysconf(android.system.OsConstants._SC_PHYS_PAGES)
1740
+ val pageSize =
1741
+ android.system.Os.sysconf(android.system.OsConstants._SC_PAGESIZE)
1742
+ if (pages <= 0 || pageSize <= 0) {
1743
+ promise.resolve(-1.0)
1744
+ return
1745
+ }
1746
+ promise.resolve(pages.toDouble() * pageSize.toDouble() / (1024.0 * 1024.0))
1747
+ } catch (t: Throwable) {
1748
+ android.util.Log.w(
1749
+ "IncrementalStitcher",
1750
+ "getDeviceTotalRamMB: failed: ${t.message}",
1751
+ )
1752
+ promise.resolve(-1.0)
1753
+ }
1754
+ }
1755
+
1740
1756
  /**
1741
1757
  * Release the C++ KeyframeGate heap allocation when RN tears
1742
1758
  * down the bridge module (e.g. on a JS reload). Without this,
@@ -1928,6 +1944,24 @@ class IncrementalStitcher(
1928
1944
  *
1929
1945
  * Returns "panorama" or "scans" — never "auto".
1930
1946
  */
1947
+ /**
1948
+ * Result of [resolveStitchModeAuto]: the chosen mode PLUS the gyro rotation
1949
+ * magnitude that drove the decision. rRadians is surfaced to JS (the dev
1950
+ * 3-tab preview shows it) so the panorama-vs-SCANS rotation threshold can be
1951
+ * tuned from real captures. rRadians is 0.0 only on the no-pose fallbacks
1952
+ * (non-AR with no pose data) — there is no gyro-derived rotation to report.
1953
+ */
1954
+ private data class StitchModeResolution(
1955
+ val mode: String,
1956
+ val rRadians: Double,
1957
+ // tMeters = translation magnitude (m) that fed the ratio; ratio = the
1958
+ // tScore/(tScore+rScore) decision value (>=0.55 → SCANS). Surfaced to the
1959
+ // dev readout so the panorama-vs-SCANS threshold can be tuned from real
1960
+ // captures, alongside rRadians.
1961
+ val tMeters: Double,
1962
+ val ratio: Double,
1963
+ )
1964
+
1931
1965
  private fun resolveStitchModeAuto(
1932
1966
  firstPose: DoubleArray?,
1933
1967
  lastPose: DoubleArray?,
@@ -1935,14 +1969,16 @@ class IncrementalStitcher(
1935
1969
  // translation in METRES. Used as a fallback when pose-derived
1936
1970
  // translation is 0 (non-AR mode).
1937
1971
  imuTranslationMetres: Double = 0.0,
1938
- ): String {
1972
+ ): StitchModeResolution {
1939
1973
  if (firstPose == null || lastPose == null) {
1940
1974
  // No pose data at all — fall back on the IMU signal. IMU
1941
1975
  // > 5 cm hints SCANS; everything else hints PANORAMA.
1942
- return if (imuTranslationMetres > 0.05) "scans" else "panorama"
1976
+ return StitchModeResolution(
1977
+ if (imuTranslationMetres > 0.05) "scans" else "panorama", 0.0, 0.0, 0.0)
1943
1978
  }
1944
1979
  if (firstPose.size != 7 || lastPose.size != 7) {
1945
- return if (imuTranslationMetres > 0.05) "scans" else "panorama"
1980
+ return StitchModeResolution(
1981
+ if (imuTranslationMetres > 0.05) "scans" else "panorama", 0.0, 0.0, 0.0)
1946
1982
  }
1947
1983
 
1948
1984
  // Translation magnitude (Euclidean, in metres) — pose-derived.
@@ -1962,11 +1998,7 @@ class IncrementalStitcher(
1962
1998
  // conventions; rotated by the pose quaternion gives the world-
1963
1999
  // frame forward direction. Angle between the first and last
1964
2000
  // camera-forward vectors is the total rotation around any axis.
1965
- val fwdFirst = qrotForward(firstPose[3], firstPose[4], firstPose[5], firstPose[6])
1966
- val fwdLast = qrotForward(lastPose[3], lastPose[4], lastPose[5], lastPose[6])
1967
- val dot = (fwdFirst[0] * fwdLast[0] + fwdFirst[1] * fwdLast[1] + fwdFirst[2] * fwdLast[2])
1968
- .coerceIn(-1.0, 1.0)
1969
- val rRadians = kotlin.math.acos(dot)
2001
+ val rRadians = rotationRadians(firstPose, lastPose)
1970
2002
 
1971
2003
  // Normalisation: 10 cm of translation ≈ 1 rad of rotation as
1972
2004
  // "equivalent magnitude" for the ratio. Empirically: shelf
@@ -1977,7 +2009,7 @@ class IncrementalStitcher(
1977
2009
  val tScore = tMeters / 0.10
1978
2010
  val rScore = rRadians / 1.00
1979
2011
  val denom = tScore + rScore
1980
- if (denom <= 1e-9) return "panorama" // no motion either way
2012
+ if (denom <= 1e-9) return StitchModeResolution("panorama", rRadians, tMeters, 0.0) // no motion
1981
2013
  val ratio = tScore / denom
1982
2014
 
1983
2015
  // 2026-06-15 — LOW-ROTATION GUARD. The gyro rotation (rRadians) is
@@ -2000,7 +2032,51 @@ class IncrementalStitcher(
2000
2032
  "ratio=${"%.3f".format(ratio)} " +
2001
2033
  "rotGuard=$lowRotationGuard → $mode",
2002
2034
  )
2003
- return mode
2035
+ return StitchModeResolution(mode, rRadians, tMeters, ratio)
2036
+ }
2037
+
2038
+ /**
2039
+ * 2026-06-16 — high-level warper decision tree (the pipeline is now ALWAYS
2040
+ * high-level cv::Stitcher PANORAMA — useManualPipeline=false). Warper is a
2041
+ * pure function of (lens, pan direction); the rotation-vs-translation
2042
+ * (ex-SCANS) distinction was DROPPED as redundant — at 1x the same
2043
+ * direction-based warpers serve both, and 0.5x is always spherical. Inputs:
2044
+ * orientation = capture hold ("landscape*" = Mode A vertical pan;
2045
+ * "portrait*" = Mode B horizontal pan)
2046
+ * lens = the EXPLICIT lens the user selected ("0.5x" ultra-wide |
2047
+ * "1x" wide). Reliable zoom signal (FOV-from-intrinsics was
2048
+ * unreliable — multi-cam 0.5x reaches the ultra-wide by zoom
2049
+ * without changing fx, and the non-AR path may supply fx=0).
2050
+ *
2051
+ * 0.5x ultra-wide → spherical (bounded both axes; any pan)
2052
+ * 1x + Mode A (vertical) → plane
2053
+ * 1x + Mode B (horizontal) → cylindrical
2054
+ *
2055
+ * Quality-preferred warper; the C++ memory ladder force-falls to spherical
2056
+ * (and downscales compositingResol) under pressure.
2057
+ */
2058
+ private fun pickHighLevelWarper(
2059
+ orientation: String,
2060
+ lens: String,
2061
+ ): String {
2062
+ if (lens == "0.5x") return "spherical" // ultra-wide → always spherical
2063
+ val verticalPanModeA = orientation.startsWith("landscape")
2064
+ return if (verticalPanModeA) "plane" else "cylindrical" // 1x: A→plane, B→cylindrical
2065
+ }
2066
+
2067
+ /**
2068
+ * Gyro rotation magnitude (radians) between two 7-element poses
2069
+ * `[tx,ty,tz,qx,qy,qz,qw]` — the angle between the camera-forward vectors.
2070
+ * Returns 0.0 if either pose is missing/malformed (non-AR with no pose).
2071
+ * Shared by [resolveStitchModeAuto] and the finalize `rRadians` readout (DRY).
2072
+ */
2073
+ private fun rotationRadians(firstPose: DoubleArray?, lastPose: DoubleArray?): Double {
2074
+ if (firstPose == null || lastPose == null) return 0.0
2075
+ if (firstPose.size != 7 || lastPose.size != 7) return 0.0
2076
+ val f = qrotForward(firstPose[3], firstPose[4], firstPose[5], firstPose[6])
2077
+ val l = qrotForward(lastPose[3], lastPose[4], lastPose[5], lastPose[6])
2078
+ val dot = (f[0] * l[0] + f[1] * l[1] + f[2] * l[2]).coerceIn(-1.0, 1.0)
2079
+ return kotlin.math.acos(dot)
2004
2080
  }
2005
2081
 
2006
2082
  /**
@@ -612,26 +612,13 @@ class RNSARCameraView @JvmOverloads constructor(
612
612
  val rotationForEncode = if (lastDisplayRotation >= 0)
613
613
  lastDisplayRotation else android.view.Surface.ROTATION_0
614
614
 
615
- // F8.6 (v0.6) the eager JPEG encode for live-engine mode
616
- // is gone. Pass the already-packed NV21 directly via
617
- // `nv21PixelData`; the engine's new `addFramePixelData`
618
- // path builds the BGR cv::Mat in-process via cvtColor,
619
- // skipping the JPEG decode round-trip downstream. In
620
- // batch-keyframe mode the engine ignores `nv21PixelData`
621
- // (it uses `grayData` + `onAccept` lazily); no behaviour
622
- // change there.
623
- //
624
- // (Was: eager JPEG encode for non-batch-keyframe modes,
625
- // written to `tmpJpegFile`, passed as `legacyJpegPath`.
626
- // See the v0.3 / F8.6 entries in CHANGELOG.md.)
627
- //
628
- // Synchronous engine ingest. The ARCore Image ownership
629
- // contract requires the engine to consume the TransferredNV21
630
- // before ARCore recycles the Image, so this runs inline. Only
631
- // ingest when the host has actively engaged capture
632
- // (`setIncrementalIngestionActive(true)`). (The v0.8.0 worklet-
633
- // runtime `runFirstParty` indirection + host-worklet fan-out
634
- // were archived in the 2026-06 batch-keyframe cleanup.)
615
+ // Batch-keyframe ingest. The gate reads the Y plane of the packed
616
+ // NV21 synchronously (grayData) and the lazy onAccept JPEG-encodes only
617
+ // accepted frames — no eager encode, no live-engine pixel-data path
618
+ // (the live engines + the TransferredNV21 ownership wrapper were removed
619
+ // in the 2026-06 cleanup; see audit #8). Runs inline so the gate read
620
+ // completes before ARCore recycles the Image. Only ingest when the host
621
+ // has actively engaged capture (`setIncrementalIngestionActive(true)`).
635
622
  if (ingestActive) {
636
623
  module.ingestFromARCameraView(
637
624
  tx = tArr[0].toDouble(),
@@ -654,22 +641,6 @@ class RNSARCameraView @JvmOverloads constructor(
654
641
  grayWidth = packed.width,
655
642
  grayHeight = packed.height,
656
643
  grayStride = packed.width,
657
- legacyJpegPath = null,
658
- // F8.6 — pixel-data path for live engines. Batch-
659
- // keyframe mode ignores these (bails earlier).
660
- //
661
- // v0.10.0 audit #4A — wrap `packed.nv21` in
662
- // TransferredNV21 so ownership is enforced at runtime.
663
- // The AR caller passes the SAME `packed.nv21` array as
664
- // both `grayData` (sync, gate-eval read) and
665
- // `nv21PixelData` (async, engine ingest). Today no race
666
- // because grayData is consumed inside evaluateWithFrame
667
- // before workScope.launch fires; the wrapper makes a
668
- // future refactor that reorders consumption fail loudly
669
- // instead of silently corrupting frames.
670
- nv21PixelData = TransferredNV21(packed.nv21),
671
- nv21PixelWidth = packed.width,
672
- nv21PixelHeight = packed.height,
673
644
  onAccept = { targetPath ->
674
645
  // Lazy JPEG encode. Runs ONLY if the C++ KeyframeGate
675
646
  // accepted the frame. Encodes from the pre-packed
@@ -356,6 +356,11 @@ struct KeyframeGate::Impl {
356
356
  // the downscale factor. Re-set whenever prevFrameGrayWork is.
357
357
  int32_t prevFrameOrigWidth = 0;
358
358
  int32_t prevFrameOrigHeight = 0;
359
+ // 2026-06-16 (audit #4) — the CURRENT frame's downscaled working image,
360
+ // produced by ingestWorkingFrame() (under the JNI pin) and consumed by
361
+ // evaluateWithWorkingMat() (after the pin is released). Empty unless an
362
+ // ingest is pending. Owned (downscaleToWorking clones / resizes).
363
+ cv::Mat pendingWorkMat;
359
364
  };
360
365
 
361
366
  // Compile-time layout check on the shared POD struct — ensures iOS
@@ -750,6 +755,45 @@ KeyframeGateDecision KeyframeGate::evaluateWithFrame(
750
755
  int32_t height,
751
756
  int32_t stride,
752
757
  int64_t monotonicNowMs)
758
+ {
759
+ // 2026-06-16 (audit #4) — thin wrapper = ingest (reads pixels) + evaluate.
760
+ // Kept for iOS / non-pinned callers; the Android JNI calls the two halves
761
+ // separately so the heavy OpenCV runs OUTSIDE the GC-pinned critical region.
762
+ ingestWorkingFrame(grayData, width, height, stride);
763
+ return evaluateWithWorkingMat(pose, latchedPlane, width, height, monotonicNowMs);
764
+ }
765
+
766
+ void KeyframeGate::ingestWorkingFrame(
767
+ const uint8_t* grayData,
768
+ int32_t width,
769
+ int32_t height,
770
+ int32_t stride)
771
+ {
772
+ Impl& s = *pImpl_;
773
+ s.pendingWorkMat.release();
774
+ // Only the Flow strategy reads pixels; disabled / force-accept / Pose ignore
775
+ // them (the Pose path is deliberately OpenCV-free). For those — and for
776
+ // invalid input — stash nothing; evaluateWithWorkingMat short-circuits
777
+ // without a frame. This is the ONLY step that touches `grayData`, so the
778
+ // JNI can ReleasePrimitiveArrayCritical immediately after it returns.
779
+ if (!s.enabled || s.forceAcceptNext || s.strategy != GateStrategy::Flow
780
+ || grayData == nullptr || width <= 0 || height <= 0 || stride < width) {
781
+ return;
782
+ }
783
+ // Non-owning view over the (pinned) bytes; downscaleToWorking returns an
784
+ // OWNED Mat (clone or resize output), so it stays valid after the pin drops.
785
+ cv::Mat currGrayFull(height, width, CV_8UC1,
786
+ const_cast<uint8_t*>(grayData),
787
+ static_cast<size_t>(stride));
788
+ s.pendingWorkMat = downscaleToWorking(currGrayFull);
789
+ }
790
+
791
+ KeyframeGateDecision KeyframeGate::evaluateWithWorkingMat(
792
+ const Pose& pose,
793
+ const PlaneTransform* latchedPlane,
794
+ int32_t origWidth,
795
+ int32_t origHeight,
796
+ int64_t monotonicNowMs)
753
797
  {
754
798
  Impl& s = *pImpl_;
755
799
  // Stash the monotonic stamp (see evaluate()). The Pose-strategy
@@ -785,19 +829,14 @@ KeyframeGateDecision KeyframeGate::evaluateWithFrame(
785
829
  return evaluate(pose, latchedPlane, s.currentNowMs);
786
830
  }
787
831
 
788
- // Flow path — wrap incoming pixel data as a non-owning cv::Mat
789
- // and downscale to working resolution. The non-owning view is
790
- // SAFE because we deep-copy (via clone) before storing on Impl.
791
- if (grayData == nullptr || width <= 0 || height <= 0 || stride < width) {
792
- // Defensive: caller forgot to supply image data despite
793
- // strategy=Flow. Fall back to pose path so we degrade
794
- // gracefully rather than crashing on a null deref.
832
+ // Flow path — operate on the working frame ingestWorkingFrame() stashed.
833
+ // Empty ingest declined (null/invalid grayData under Flow); fall back to
834
+ // the pose path so we degrade gracefully rather than deref a null frame.
835
+ cv::Mat currGrayWork = std::move(s.pendingWorkMat);
836
+ s.pendingWorkMat.release();
837
+ if (currGrayWork.empty()) {
795
838
  return evaluate(pose, latchedPlane, s.currentNowMs);
796
839
  }
797
- cv::Mat currGrayFull(height, width, CV_8UC1,
798
- const_cast<uint8_t*>(grayData),
799
- static_cast<size_t>(stride));
800
- cv::Mat currGrayWork = downscaleToWorking(currGrayFull);
801
840
 
802
841
  // §4 — first-frame accept under Flow. No prev to track against;
803
842
  // we anchor here and detect features so subsequent frames have
@@ -811,8 +850,8 @@ KeyframeGateDecision KeyframeGate::evaluateWithFrame(
811
850
  s.flowMinDistance);
812
851
  s.prevFrameGrayWork = currGrayWork; // clone-owned via downscale path
813
852
  s.prevFeatures = std::move(features);
814
- s.prevFrameOrigWidth = width;
815
- s.prevFrameOrigHeight = height;
853
+ s.prevFrameOrigWidth = origWidth;
854
+ s.prevFrameOrigHeight = origHeight;
816
855
  s.lastAcceptedPose = pose;
817
856
  s.lastAcceptSteadyMs = s.currentNowMs;
818
857
  s.acceptedCount = 1;
@@ -970,8 +1009,8 @@ KeyframeGateDecision KeyframeGate::evaluateWithFrame(
970
1009
  s.flowMinDistance);
971
1010
  s.prevFrameGrayWork = currGrayWork; // owned via downscale's clone
972
1011
  s.prevFeatures = std::move(nextFeatures);
973
- s.prevFrameOrigWidth = width;
974
- s.prevFrameOrigHeight = height;
1012
+ s.prevFrameOrigWidth = origWidth;
1013
+ s.prevFrameOrigHeight = origHeight;
975
1014
  s.lastAcceptedPose = pose;
976
1015
  s.lastAcceptSteadyMs = s.currentNowMs;
977
1016
  s.acceptedCount += 1;
@@ -248,6 +248,39 @@ public:
248
248
  int32_t stride,
249
249
  int64_t monotonicNowMs = -1);
250
250
 
251
+ // ── Split entry point (2026-06-16, audit #4) ──────────────────
252
+ //
253
+ // Android JNI pins the gray byte[] with GetPrimitiveArrayCritical, which
254
+ // blocks the GC for the pin's whole duration. Running the heavy OpenCV
255
+ // (goodFeaturesToTrack / optical flow, multi-ms) inside that window stalls
256
+ // the frame-rate producer thread and violates the JNI no-blocking-between-
257
+ // critical rule. This pair splits the work so ONLY the pinned-buffer read
258
+ // happens under the pin:
259
+ //
260
+ // ingestWorkingFrame(grayData, w, h, stride) // <- under the pin
261
+ // Builds the downscaled working frame (the only step that reads the
262
+ // pinned pixels) and stashes it INSIDE the gate. Does nothing (no
263
+ // pixel read) for Pose/disabled/force-accept/invalid input — those
264
+ // don't need the frame. cv::Mat is kept out of this header so the
265
+ // JNI needn't link OpenCV; the frame lives on the gate's Impl.
266
+ //
267
+ // evaluateWithWorkingMat(pose, plane, origW, origH) // <- pin released
268
+ // Runs the rest of the gate (the heavy OpenCV) on the stashed working
269
+ // frame, OUTSIDE the critical section. origW/origH are the ORIGINAL
270
+ // full-res dims (the working frame is downscaled).
271
+ //
272
+ // evaluateWithFrame() above is exactly ingest+evaluate in sequence — kept
273
+ // for iOS and any non-pinned caller (DRY: it delegates to these two).
274
+ void ingestWorkingFrame(const uint8_t* grayData,
275
+ int32_t width,
276
+ int32_t height,
277
+ int32_t stride);
278
+ KeyframeGateDecision evaluateWithWorkingMat(const Pose& pose,
279
+ const PlaneTransform* latchedPlane,
280
+ int32_t origWidth,
281
+ int32_t origHeight,
282
+ int64_t monotonicNowMs = -1);
283
+
251
284
  // ── State accessors (read-only, post-evaluate) ────────────────
252
285
  int32_t getAcceptedCount() const;
253
286
  int32_t getMaxCount() const;