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.
- package/CHANGELOG.md +80 -0
- package/README.md +41 -44
- package/android/build.gradle +34 -0
- package/android/src/main/cpp/image_stitcher_jni.cpp +52 -7
- package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +135 -59
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
- package/cpp/keyframe_gate.cpp +54 -15
- package/cpp/keyframe_gate.hpp +33 -0
- package/cpp/stitcher.cpp +481 -87
- package/cpp/stitcher.hpp +52 -0
- package/dist/camera/Camera.d.ts +13 -0
- package/dist/camera/Camera.js +9 -64
- package/dist/camera/CaptureFrameCounterOverlay.js +12 -1
- package/dist/camera/CaptureMemoryPill.d.ts +15 -7
- package/dist/camera/CaptureMemoryPill.js +34 -9
- package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
- package/dist/camera/PanoramaBandOverlay.js +9 -3
- package/dist/camera/PanoramaSettings.js +22 -25
- package/dist/camera/RectCropPreview.d.ts +3 -29
- package/dist/camera/RectCropPreview.js +20 -130
- package/dist/stitching/incremental.d.ts +29 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
- package/dist/stitching/useIncrementalStitcher.js +7 -1
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +103 -26
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +21 -1
- package/package.json +1 -1
- package/src/camera/Camera.tsx +21 -70
- package/src/camera/CaptureFrameCounterOverlay.tsx +15 -1
- package/src/camera/CaptureMemoryPill.tsx +33 -9
- package/src/camera/PanoramaBandOverlay.tsx +9 -1
- package/src/camera/PanoramaSettings.ts +22 -25
- package/src/camera/RectCropPreview.tsx +38 -220
- package/src/stitching/incremental.ts +29 -0
- package/src/stitching/useIncrementalStitcher.ts +13 -0
- 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
|
-
|
|
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 ->
|
|
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
|
-
|
|
738
|
+
highLevelWarper, // tree-chosen (was batchWarperType)
|
|
718
739
|
blenderTypeSnapshot,
|
|
719
740
|
seamFinderTypeSnapshot,
|
|
720
741
|
captureOrientationSnapshot,
|
|
721
742
|
useInscribedRectCropSnapshot,
|
|
722
|
-
stitchMode =
|
|
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-
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
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
|
-
):
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
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
|
package/cpp/keyframe_gate.cpp
CHANGED
|
@@ -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 —
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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 =
|
|
815
|
-
s.prevFrameOrigHeight =
|
|
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 =
|
|
974
|
-
s.prevFrameOrigHeight =
|
|
1012
|
+
s.prevFrameOrigWidth = origWidth;
|
|
1013
|
+
s.prevFrameOrigHeight = origHeight;
|
|
975
1014
|
s.lastAcceptedPose = pose;
|
|
976
1015
|
s.lastAcceptSteadyMs = s.currentNowMs;
|
|
977
1016
|
s.acceptedCount += 1;
|
package/cpp/keyframe_gate.hpp
CHANGED
|
@@ -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;
|