react-native-image-stitcher 0.15.2 → 0.16.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 (133) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/README.md +116 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
  4. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
  6. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  8. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  9. package/cpp/crop_quad.cpp +162 -0
  10. package/cpp/crop_quad.hpp +163 -0
  11. package/cpp/stitcher.cpp +651 -55
  12. package/cpp/stitcher.hpp +10 -0
  13. package/cpp/warp_guard.hpp +212 -0
  14. package/dist/camera/Camera.d.ts +196 -12
  15. package/dist/camera/Camera.js +629 -35
  16. package/dist/camera/CameraView.js +35 -16
  17. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  18. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  19. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  20. package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
  21. package/dist/camera/CaptureMemoryPill.d.ts +9 -1
  22. package/dist/camera/CaptureMemoryPill.js +3 -3
  23. package/dist/camera/CapturePreview.js +2 -1
  24. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  25. package/dist/camera/CaptureStatusOverlay.js +22 -5
  26. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  27. package/dist/camera/LateralMotionModal.d.ts +85 -0
  28. package/dist/camera/LateralMotionModal.js +134 -0
  29. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  30. package/dist/camera/PanHowToOverlay.js +222 -0
  31. package/dist/camera/PanoramaSettings.d.ts +8 -6
  32. package/dist/camera/PanoramaSettings.js +26 -5
  33. package/dist/camera/PanoramaSettingsModal.js +4 -4
  34. package/dist/camera/RectCropPreview.d.ts +161 -0
  35. package/dist/camera/RectCropPreview.js +480 -0
  36. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  37. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  38. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  39. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  40. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  41. package/dist/camera/cameraErrorMessages.js +26 -10
  42. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  43. package/dist/camera/cameraGuidanceCopy.js +80 -0
  44. package/dist/camera/captureCountdown.d.ts +52 -0
  45. package/dist/camera/captureCountdown.js +76 -0
  46. package/dist/camera/captureWarnings.d.ts +90 -0
  47. package/dist/camera/captureWarnings.js +108 -0
  48. package/dist/camera/classifyStitchError.d.ts +30 -0
  49. package/dist/camera/classifyStitchError.js +42 -0
  50. package/dist/camera/cropGeometry.d.ts +136 -0
  51. package/dist/camera/cropGeometry.js +223 -0
  52. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  53. package/dist/camera/displayDecodeImageProps.js +29 -0
  54. package/dist/camera/guidanceGraphics.d.ts +58 -0
  55. package/dist/camera/guidanceGraphics.js +280 -0
  56. package/dist/camera/guidanceTokens.d.ts +54 -0
  57. package/dist/camera/guidanceTokens.js +58 -0
  58. package/dist/camera/panModeGate.d.ts +54 -0
  59. package/dist/camera/panModeGate.js +62 -0
  60. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  61. package/dist/camera/pickCaptureFormat.js +85 -0
  62. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  63. package/dist/camera/stitchDebugInfo.js +55 -0
  64. package/dist/camera/usePanMotion.d.ts +250 -0
  65. package/dist/camera/usePanMotion.js +451 -0
  66. package/dist/index.d.ts +24 -3
  67. package/dist/index.js +33 -2
  68. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  69. package/dist/stitching/computeInscribedRect.js +55 -0
  70. package/dist/stitching/cropQuad.d.ts +78 -0
  71. package/dist/stitching/cropQuad.js +116 -0
  72. package/dist/stitching/incremental.d.ts +45 -0
  73. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
  74. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  75. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  76. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  77. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
  78. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  79. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  80. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  81. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  82. package/package.json +5 -1
  83. package/src/camera/Camera.tsx +994 -47
  84. package/src/camera/CameraView.tsx +48 -16
  85. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  86. package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
  87. package/src/camera/CaptureMemoryPill.tsx +17 -3
  88. package/src/camera/CapturePreview.tsx +5 -0
  89. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  90. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  91. package/src/camera/LateralMotionModal.tsx +199 -0
  92. package/src/camera/PanHowToOverlay.tsx +246 -0
  93. package/src/camera/PanoramaSettings.ts +34 -11
  94. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  95. package/src/camera/RectCropPreview.tsx +820 -0
  96. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  97. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  98. package/src/camera/cameraErrorMessages.ts +39 -2
  99. package/src/camera/cameraGuidanceCopy.ts +145 -0
  100. package/src/camera/captureCountdown.ts +83 -0
  101. package/src/camera/captureWarnings.ts +190 -0
  102. package/src/camera/classifyStitchError.ts +68 -0
  103. package/src/camera/cropGeometry.ts +268 -0
  104. package/src/camera/displayDecodeImageProps.ts +25 -0
  105. package/src/camera/guidanceGraphics.tsx +347 -0
  106. package/src/camera/guidanceTokens.ts +57 -0
  107. package/src/camera/panModeGate.ts +81 -0
  108. package/src/camera/pickCaptureFormat.ts +130 -0
  109. package/src/camera/stitchDebugInfo.ts +71 -0
  110. package/src/camera/usePanMotion.ts +667 -0
  111. package/src/index.ts +66 -3
  112. package/src/stitching/computeInscribedRect.ts +81 -0
  113. package/src/stitching/cropQuad.ts +167 -0
  114. package/src/stitching/incremental.ts +45 -0
  115. package/cpp/tests/CMakeLists.txt +0 -104
  116. package/cpp/tests/README.md +0 -86
  117. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  118. package/cpp/tests/pose_test.cpp +0 -74
  119. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  120. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  121. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  122. package/cpp/tests/warp_guard_test.cpp +0 -48
  123. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  124. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  125. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  126. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  127. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  128. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  129. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  130. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  131. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  132. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  133. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
@@ -14,6 +14,7 @@ import org.opencv.core.Core
14
14
  import org.opencv.core.CvType
15
15
  import org.opencv.core.Mat
16
16
  import org.opencv.core.MatOfInt
17
+ import org.opencv.core.MatOfPoint2f
17
18
  import org.opencv.core.Point
18
19
  import org.opencv.core.Rect
19
20
  import org.opencv.core.Scalar
@@ -108,8 +109,27 @@ class BatchStitcher(reactContext: ReactApplicationContext)
108
109
  // SCANS canvas size is bounded by sum-of-frames; PANORAMA
109
110
  // can diverge to multi-GB on translation-heavy input).
110
111
  stitchMode: String,
112
+ // 2026-06-15 — pipeline picker (mirrors iOS' OpenCVStitcher
113
+ // `useManualPipeline:` param). true → the MANUAL cv::detail
114
+ // pipeline (graphcut + multiband + the full memory-guard set;
115
+ // the default for batch capture). false → stock high-level
116
+ // cv::Stitcher (the on-demand HIGH-LEVEL preview tab driven by
117
+ // refinePanorama). Appended LAST to match the JNI C signature
118
+ // in image_stitcher_jni.cpp — order/count/type must line up
119
+ // exactly or it's an UnsatisfiedLinkError at runtime.
120
+ useManualPipeline: Boolean,
111
121
  ): IntArray
112
122
 
123
+ // 2026-06-15 — getter for the last successful stitch's debugSummary
124
+ // (pipe/warp/route/seam/blend). The jintArray return of
125
+ // nativeStitchFramePaths can't carry a string; this fetches the value the
126
+ // JNI stashed. Called by stitchSync right after a successful stitch.
127
+ private external fun nativeLastDebugSummary(): String
128
+
129
+ /** debugSummary of the most recent stitchSync() (empty if none/failed). */
130
+ internal var lastDebugSummary: String = ""
131
+ private set
132
+
113
133
  // ── Stitch frames → panorama ─────────────────────────────────
114
134
 
115
135
  @ReactMethod
@@ -180,6 +200,9 @@ class BatchStitcher(reactContext: ReactApplicationContext)
180
200
  seamEstimationResolMP,
181
201
  compositingResolMP,
182
202
  stitchMode,
203
+ // Direct @ReactMethod batch stitch → MANUAL pipeline
204
+ // (the memory-safe default; mirrors iOS).
205
+ true,
183
206
  )
184
207
  val duration = System.currentTimeMillis() - start
185
208
  // 2026-05-15 (D) — dims layout from native JNI:
@@ -378,6 +401,121 @@ class BatchStitcher(reactContext: ReactApplicationContext)
378
401
  }
379
402
  }
380
403
 
404
+ /**
405
+ * item-7 — free-quad perspective crop (iOS `cropToQuadAtPath` parity).
406
+ *
407
+ * `options` carries `imagePath` + the 4 IMAGE-PIXEL corners as a flat
408
+ * `quad` array of 8 numbers `[tlX,tlY,trX,trY,brX,brY,blX,blY]`
409
+ * (ordered TL→TR→BR→BL by the JS editor's `orderQuadCorners`) +
410
+ * optional `quality` (default 90). Rectifies the quadrilateral to an
411
+ * upright rectangle (Imgproc.getPerspectiveTransform +
412
+ * Imgproc.warpPerspective), overwrites in place, resolves
413
+ * `{ width, height }`. Mirrors `cropToRect`; the JS editor chooses
414
+ * this when the dragged quad isn't ~axis-aligned.
415
+ *
416
+ * The destination size (averaged opposite edges) + the convex /
417
+ * min-area / in-bounds gate + the output-canvas OOM guard are
418
+ * Kotlin ports of cpp/crop_quad.hpp (quadDstRect / isQuadAcceptable)
419
+ * and cpp/warp_guard.hpp (canvasExceedsGuard), kept in sync with iOS
420
+ * per the same "duplicate stage code" convention `cropToRect` follows.
421
+ */
422
+ @ReactMethod
423
+ fun cropToQuad(options: ReadableMap, promise: Promise) {
424
+ val imagePath = options.getString("imagePath")
425
+ ?: return promise.reject("invalid-options", "imagePath required")
426
+ val quadArr = options.getArray("quad")
427
+ if (quadArr == null || quadArr.size() != 8) {
428
+ return promise.reject(
429
+ "invalid-options",
430
+ "quad must be an array of 8 numbers [tlX,tlY,trX,trY,brX,brY,blX,blY]",
431
+ )
432
+ }
433
+ val p = DoubleArray(8) { quadArr.getDouble(it) }
434
+ val quality =
435
+ (if (options.hasKey("quality") && !options.isNull("quality")) {
436
+ options.getInt("quality")
437
+ } else {
438
+ 90
439
+ }).coerceIn(1, 100)
440
+ CoroutineScope(Dispatchers.Default).launch {
441
+ val toRelease = mutableListOf<Mat>()
442
+ try {
443
+ ensureOpenCv()
444
+ val cleaned = stripFileScheme(imagePath)
445
+ if (!File(cleaned).exists()) {
446
+ promise.reject("read-failed", "Image not found: $imagePath")
447
+ return@launch
448
+ }
449
+ val img = Imgcodecs.imread(cleaned, Imgcodecs.IMREAD_COLOR)
450
+ toRelease += img
451
+ if (img.empty()) {
452
+ promise.reject("read-failed", "Could not decode $imagePath")
453
+ return@launch
454
+ }
455
+
456
+ // Geometry gate — convex, non-degenerate, inside the image.
457
+ if (!isQuadAcceptableForCrop(p, img.cols().toDouble(), img.rows().toDouble())) {
458
+ promise.reject(
459
+ "crop-to-quad-failed",
460
+ "Crop quad is degenerate (non-convex, zero-area, or out of bounds)",
461
+ )
462
+ return@launch
463
+ }
464
+ // Destination size = avg of opposite edge lengths (rounded).
465
+ val dstW = Math.round((quadEdge(p, 0, 1) + quadEdge(p, 3, 2)) / 2.0).toInt()
466
+ val dstH = Math.round((quadEdge(p, 0, 3) + quadEdge(p, 1, 2)) / 2.0).toInt()
467
+ // Output-canvas OOM net — same 50 MP guard the stitch uses.
468
+ if (dstW <= 0 || dstH <= 0 || canvasExceedsGuard(dstW.toLong(), dstH.toLong())) {
469
+ promise.reject(
470
+ "crop-to-quad-failed",
471
+ "Crop quad output canvas is degenerate or exceeds the size guard (${dstW}x${dstH})",
472
+ )
473
+ return@launch
474
+ }
475
+
476
+ val src = MatOfPoint2f(
477
+ Point(p[0], p[1]), // TL
478
+ Point(p[2], p[3]), // TR
479
+ Point(p[4], p[5]), // BR
480
+ Point(p[6], p[7]), // BL
481
+ )
482
+ toRelease += src
483
+ val dst = MatOfPoint2f(
484
+ Point(0.0, 0.0),
485
+ Point(dstW.toDouble(), 0.0),
486
+ Point(dstW.toDouble(), dstH.toDouble()),
487
+ Point(0.0, dstH.toDouble()),
488
+ )
489
+ toRelease += dst
490
+ val transform = Imgproc.getPerspectiveTransform(src, dst)
491
+ toRelease += transform
492
+ val warped = Mat()
493
+ toRelease += warped
494
+ Imgproc.warpPerspective(
495
+ img, warped, transform, Size(dstW.toDouble(), dstH.toDouble()),
496
+ Imgproc.INTER_LINEAR,
497
+ )
498
+ if (warped.empty()) {
499
+ promise.reject("crop-to-quad-failed", "Perspective warp produced an empty image")
500
+ return@launch
501
+ }
502
+ val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, quality)
503
+ if (!Imgcodecs.imwrite(cleaned, warped, params)) {
504
+ promise.reject("write-failed", "Could not rewrite $imagePath")
505
+ return@launch
506
+ }
507
+ promise.resolve(WritableNativeMap().apply {
508
+ putInt("width", warped.cols())
509
+ putInt("height", warped.rows())
510
+ })
511
+ } catch (t: Throwable) {
512
+ promise.reject("crop-to-quad-failed", t.message, t)
513
+ } finally {
514
+ toRelease.forEach { it.release() }
515
+ }
516
+ }
517
+ }
518
+
381
519
  /** Red-tint the dropped pixels; writes `<path>.mask.jpg`. Resolves `{ maskPath, width, height, excludedPercent }`. */
382
520
  @ReactMethod
383
521
  fun debugMaskOverlay(options: ReadableMap, promise: Promise) {
@@ -546,6 +684,79 @@ class BatchStitcher(reactContext: ReactApplicationContext)
546
684
  return intArrayOf(bx, by, bw, bh)
547
685
  }
548
686
 
687
+ // ── item-7 free-quad crop geometry (ports of cpp/crop_quad.hpp +
688
+ // cpp/warp_guard.hpp; kept in sync with iOS cropToQuad) ───────
689
+
690
+ /**
691
+ * Euclidean length of the edge between corners `i` and `j` in the flat
692
+ * `[x0,y0,x1,y1,...]` quad array `p` (corner k = (p[2k], p[2k+1])).
693
+ */
694
+ private fun quadEdge(p: DoubleArray, i: Int, j: Int): Double {
695
+ val dx = p[2 * i] - p[2 * j]
696
+ val dy = p[2 * i + 1] - p[2 * j + 1]
697
+ return Math.hypot(dx, dy)
698
+ }
699
+
700
+ /**
701
+ * Port of cpp/crop_quad.hpp:isQuadAcceptable for the flat 8-number
702
+ * quad `p` ([tlX,tlY,trX,trY,brX,brY,blX,blY]). True when the quad is
703
+ * convex, has |area| ≥ `minArea` px², and every corner lies inside
704
+ * `[0..imageW]×[0..imageH]` (½-px epsilon). Mirrors the iOS gate so
705
+ * both platforms reject the same degenerate quads.
706
+ */
707
+ private fun isQuadAcceptableForCrop(
708
+ p: DoubleArray,
709
+ imageW: Double,
710
+ imageH: Double,
711
+ minArea: Double = 1.0,
712
+ ): Boolean {
713
+ // Convexity — all consecutive edge cross-products share one sign.
714
+ var sign = 0
715
+ for (i in 0 until 4) {
716
+ val ax = p[2 * i]; val ay = p[2 * i + 1]
717
+ val bx = p[2 * ((i + 1) % 4)]; val by = p[2 * ((i + 1) % 4) + 1]
718
+ val cx = p[2 * ((i + 2) % 4)]; val cy = p[2 * ((i + 2) % 4) + 1]
719
+ val cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx)
720
+ if (cross != 0.0) {
721
+ val s = if (cross > 0.0) 1 else -1
722
+ if (sign == 0) sign = s else if (s != sign) return false
723
+ }
724
+ }
725
+ // Min-area via the shoelace formula (|2A| ≥ 2·minArea).
726
+ var area2 = 0.0
727
+ for (i in 0 until 4) {
728
+ val ax = p[2 * i]; val ay = p[2 * i + 1]
729
+ val bx = p[2 * ((i + 1) % 4)]; val by = p[2 * ((i + 1) % 4) + 1]
730
+ area2 += ax * by - bx * ay
731
+ }
732
+ if (Math.abs(area2) < minArea * 2.0) return false
733
+ // In-bounds — every corner inside the decoded image (½-px slop).
734
+ if (imageW > 0.0 && imageH > 0.0) {
735
+ val eps = 0.5
736
+ for (i in 0 until 4) {
737
+ val x = p[2 * i]; val y = p[2 * i + 1]
738
+ if (x < -eps || x > imageW + eps || y < -eps || y > imageH + eps) return false
739
+ }
740
+ }
741
+ return true
742
+ }
743
+
744
+ /**
745
+ * Port of cpp/warp_guard.hpp:canvasExceedsGuard — true when a
746
+ * `width`×`height` output canvas is degenerate (non-positive) or
747
+ * strictly larger than `maxPixels` (default 50 MP, boundary inclusive).
748
+ * int64 area math matches the C++ so the same quads are rejected.
749
+ */
750
+ private fun canvasExceedsGuard(
751
+ width: Long,
752
+ height: Long,
753
+ maxPixels: Long = 50L * 1000L * 1000L,
754
+ ): Boolean {
755
+ if (width <= 0 || height <= 0) return true
756
+ if (width > 3_000_000_000L || height > 3_000_000_000L) return true
757
+ return width * height > maxPixels
758
+ }
759
+
549
760
  // ── Internals ────────────────────────────────────────────────
550
761
 
551
762
  /**
@@ -623,9 +834,15 @@ class BatchStitcher(reactContext: ReactApplicationContext)
623
834
  // translation captures safely (PANORAMA on translation can
624
835
  // diverge → multi-GB canvas → lmkd kill).
625
836
  stitchMode: String = "scans",
837
+ // 2026-06-15 — pipeline picker (mirrors iOS' OpenCVStitcher
838
+ // `useManualPipeline:`). Defaults to true (MANUAL) so the
839
+ // batch-keyframe finalize orchestrator gets the memory-safe
840
+ // manual path without re-stating it. The refine/high-level
841
+ // path passes false to drive the stock cv::Stitcher pipeline.
842
+ useManualPipeline: Boolean = true,
626
843
  ): IntArray {
627
844
  ensureNativeStitcher()
628
- return nativeStitchFramePaths(
845
+ val dims = nativeStitchFramePaths(
629
846
  framePaths,
630
847
  outputPath,
631
848
  jpegQuality,
@@ -638,7 +855,12 @@ class BatchStitcher(reactContext: ReactApplicationContext)
638
855
  seamEstimationResolMP,
639
856
  compositingResolMP,
640
857
  stitchMode,
858
+ useManualPipeline,
641
859
  )
860
+ // Capture the run's debugSummary (pipe/warp/route/seam/blend) for the
861
+ // DEV overlay; best-effort so a getter hiccup never fails the stitch.
862
+ lastDebugSummary = try { nativeLastDebugSummary() } catch (_: Throwable) { "" }
863
+ return dims
642
864
  }
643
865
 
644
866
  /**
@@ -420,7 +420,7 @@ class IncrementalStitcher(
420
420
  // stalled scan still advances). iOS parity:
421
421
  // IncrementalStitcher.swift maxKeyframeIntervalMs block.
422
422
  val maxKfIntervalMs = configOverrides
423
- ?.getDoubleOrDefault("maxKeyframeIntervalMs", 2000.0) ?: 2000.0
423
+ ?.getDoubleOrDefault("maxKeyframeIntervalMs", 1500.0) ?: 1500.0
424
424
  keyframeGate.maxKeyframeIntervalMs = maxKfIntervalMs.coerceAtLeast(0.0)
425
425
  // 2026-05-22 (audit F5) — flow-strategy Shi-Tomasi
426
426
  // tunables. Pre-audit, Android had no JNI for these
@@ -748,6 +748,30 @@ class IncrementalStitcher(
748
748
  // resolved cv::Stitcher mode so JS can surface it
749
749
  // on the output preview + debug toast.
750
750
  map.putString("stitchModeResolved", stitchModeResolved)
751
+ // 2026-06-15 (iOS parity) — the exact keyframe JPEG
752
+ // paths used for this stitch, so JS can re-stitch
753
+ // them ON DEMAND via refinePanorama (the high-level
754
+ // preview tab) without enumerating the session dir.
755
+ // Camera.tsx gates that tab on this array being
756
+ // present, so without it the tab never appears on
757
+ // Android (the bug this fixes). Mirrors iOS'
758
+ // FinalizePayload "batchKeyframePaths": payload.paths.
759
+ val keyframePathsArray = Arguments.createArray()
760
+ keyframePathsSnapshot.forEach { keyframePathsArray.pushString(it) }
761
+ map.putArray("batchKeyframePaths", keyframePathsArray)
762
+ // The orientation THIS stitch baked into the output.
763
+ // The on-demand high-level re-stitch MUST pass the
764
+ // same value back through refinePanorama or the
765
+ // output comes out in raw sensor landscape (sideways)
766
+ // — refinePanorama otherwise defaults to "portrait"
767
+ // (no bake-rotation). Mirrors iOS' FinalizePayload
768
+ // "captureOrientation": payload.captureOrientation.
769
+ map.putString("captureOrientation", captureOrientationSnapshot)
770
+ // 2026-06-15 — DEV overlay parity with iOS: the stitcher's
771
+ // runtime recipe (pipe/warp/route/seam/blend) so the Android
772
+ // pill shows the same detail, not just mode/score/frames.
773
+ val dbg = stitcher.lastDebugSummary
774
+ if (dbg.isNotEmpty()) map.putString("debugSummary", dbg)
751
775
  } else {
752
776
  // The live engines (hybrid + firstwins/slit) and their
753
777
  // auto-refine hook were archived in the 2026-06 batch-
@@ -1493,6 +1517,15 @@ class IncrementalStitcher(
1493
1517
  config?.getBooleanOrDefault("useInscribedRectCrop", false) ?: false
1494
1518
  val stitchMode = (config?.getString("stitchMode") ?: "auto")
1495
1519
  .let { if (it in setOf("auto", "panorama", "scans")) it else "auto" }
1520
+ // 2026-06-15 — pipeline is caller-selectable (mirrors iOS'
1521
+ // refinePanorama `refineManual`). The on-demand HIGH-LEVEL
1522
+ // preview tab (Camera.tsx requestHighLevelAlt) calls
1523
+ // refinePanorama with useManualPipeline:false to re-stitch the
1524
+ // captured keyframes via stock cv::Stitcher. Default false
1525
+ // (high-level) preserves the refine path's historical
1526
+ // cv::Stitcher behaviour.
1527
+ val useManualPipeline =
1528
+ config?.getBooleanOrDefault("useManualPipeline", false) ?: false
1496
1529
  val jpegQuality = max(1, min(100,
1497
1530
  config?.getIntOrDefault("jpegQuality", 90) ?: 90))
1498
1531
 
@@ -1538,9 +1571,18 @@ class IncrementalStitcher(
1538
1571
  warperType,
1539
1572
  blenderType,
1540
1573
  seamFinderType,
1574
+ // captureOrientation flows through so the high-level
1575
+ // re-stitch bakes the SAME rotation the capture used
1576
+ // — without it the output is sideways (raw sensor
1577
+ // landscape). The high-level tab passes back the
1578
+ // orientation the finalize emitted.
1541
1579
  captureOrientation,
1542
1580
  useInscribedRectCrop,
1543
1581
  stitchMode = effectiveMode,
1582
+ // false = stock high-level cv::Stitcher (the on-demand
1583
+ // HIGH-LEVEL preview tab); true would force the manual
1584
+ // pipeline. Sourced from the JS config above.
1585
+ useManualPipeline = useManualPipeline,
1544
1586
  )
1545
1587
  // Stitch returned — BatchStitcher writes the JPEG
1546
1588
  // synchronously, so "writing" reflects the final
@@ -1568,6 +1610,10 @@ class IncrementalStitcher(
1568
1610
  putInt("framesIncluded", framesIncluded)
1569
1611
  putInt("framesDropped", framesRequested - framesIncluded)
1570
1612
  putDouble("finalConfidenceThresh", finalConfidenceThresh)
1613
+ // DEV overlay — the high-level re-stitch's recipe so the
1614
+ // pill shows pipe/warp/route/seam/blend on the high-level tab.
1615
+ val dbg = stitcher.lastDebugSummary
1616
+ if (dbg.isNotEmpty()) putString("debugSummary", dbg)
1571
1617
  }
1572
1618
  emitRefineProgress(
1573
1619
  stage = "done",
@@ -1649,39 +1695,38 @@ class IncrementalStitcher(
1649
1695
  * via `task_info(TASK_VM_INFO)` — see
1650
1696
  * `IncrementalStitcherBridge.swift:231-259`).
1651
1697
  *
1652
- * Returns the **total PSS** (proportional set size) of this
1653
- * process in MB. PSS is the metric Android's Low-Memory-Killer
1654
- * (`lmkd`) ranks against, so it's the right one-true-number for
1655
- * the on-screen memory pill: it's "how close are we to being
1656
- * killed by the system?".
1698
+ * Returns the process **RSS** (resident set size) in MB, read from
1699
+ * `/proc/self/statm` (resident-pages × page-size). RSS is what the shared
1700
+ * C++ `[memstat]` lines report (`rss_mb()` reads the same `/proc`), so the
1701
+ * pill and the stitch logs show the SAME number handy when correlating a
1702
+ * spike with a logcat trace.
1657
1703
  *
1658
- * Total PSS = USS (private) + sum(shared / refcount). Read via
1659
- * `ActivityManager.getProcessMemoryInfo()`, which is the same API
1660
- * Android Studio's profiler uses. Granularity is 1 KB; we
1661
- * divide by 1024 to MB so the JS side displays a number directly
1662
- * comparable to the iOS phys_footprint value.
1704
+ * Why not `ActivityManager.getProcessMemoryInfo().totalPss`? It's
1705
+ * RATE-LIMITED on Android 8+ (returns a cached value when polled often), so
1706
+ * at the pill's 500 ms cadence it froze at the launch-time reading and never
1707
+ * moved. `/proc/self/statm` is a single unthrottled read.
1663
1708
  *
1664
- * Returns -1.0 on failure (very rare — `getProcessMemoryInfo()`
1665
- * is generally infallible since Android 5.0 because PSS is read
1666
- * from `/proc/self/smaps` synchronously on the calling thread).
1709
+ * Returns -1.0 on failure (very rare — `/proc/self/statm` is always present
1710
+ * and cheap to read on the calling thread).
1667
1711
  */
1668
1712
  @ReactMethod
1669
1713
  fun getMemoryFootprintMB(promise: Promise) {
1670
1714
  try {
1671
- val am = reactContext.getSystemService(ActivityManager::class.java)
1672
- if (am == null) {
1673
- promise.resolve(-1.0)
1674
- return
1675
- }
1676
- val pid = android.os.Process.myPid()
1677
- val infos = am.getProcessMemoryInfo(intArrayOf(pid))
1678
- if (infos == null || infos.isEmpty()) {
1679
- promise.resolve(-1.0)
1680
- return
1681
- }
1682
- // totalPss is in KB. Divide by 1024 → MB. Use Double so
1683
- // the JS overlay can render fractional MB if it wants.
1684
- val mb = infos[0].totalPss.toDouble() / 1024.0
1715
+ // Read RSS from /proc/self/statm (field[1] = resident pages). This
1716
+ // is UNTHROTTLED and matches the C++ [memstat] rss_mb() in logcat, so
1717
+ // the pill tracks the same number I read from the stitch logs.
1718
+ //
1719
+ // 2026-06-15 — was ActivityManager.getProcessMemoryInfo().totalPss,
1720
+ // which is RATE-LIMITED on Android 8+: polled frequently (the pill
1721
+ // ticks every 500 ms) it returns a CACHED value, so the pill froze at
1722
+ // its launch-time reading (~310 MB) and never showed the stitch spike.
1723
+ val fields = java.io.File("/proc/self/statm")
1724
+ .readText().trim().split(' ')
1725
+ val residentPages = fields[1].toLong()
1726
+ val pageSize =
1727
+ android.system.Os.sysconf(android.system.OsConstants._SC_PAGESIZE)
1728
+ val mb = residentPages.toDouble() * pageSize.toDouble() /
1729
+ (1024.0 * 1024.0)
1685
1730
  promise.resolve(mb)
1686
1731
  } catch (t: Throwable) {
1687
1732
  android.util.Log.w(
@@ -1935,15 +1980,27 @@ class IncrementalStitcher(
1935
1980
  if (denom <= 1e-9) return "panorama" // no motion either way
1936
1981
  val ratio = tScore / denom
1937
1982
 
1983
+ // 2026-06-15 — LOW-ROTATION GUARD. The gyro rotation (rRadians) is
1984
+ // trustworthy; the IMU translation (tMeters, in non-AR) is NOT — a
1985
+ // continuous rotation leaks gravity into the double-integrated accel and
1986
+ // inflates it, which can falsely push `ratio` over 0.55 → SCANS, whose
1987
+ // affine warper can't represent the rotation. So when the gyro shows a
1988
+ // clear pan (> ~20°) with only modest translation, force PANORAMA
1989
+ // regardless of the (possibly-inflated) translation. Genuine shelf
1990
+ // scans (low rotation, large real translation) skip this and still
1991
+ // reach SCANS via the ratio. (Conservative: keeps the tMeters cap so a
1992
+ // genuine large-translation capture isn't forced to PANORAMA.)
1993
+ val lowRotationGuard = rRadians > 0.35 && tMeters < 0.25
1994
+ val mode = if (!lowRotationGuard && ratio >= 0.55) "scans" else "panorama"
1938
1995
  android.util.Log.i(
1939
1996
  "IncrementalStitcher",
1940
1997
  "stitch-mode auto: tPose=${"%.3f".format(tPose)}m " +
1941
1998
  "tImu=${"%.3f".format(imuTranslationMetres)}m " +
1942
1999
  "r=${"%.3f".format(rRadians)}rad " +
1943
2000
  "ratio=${"%.3f".format(ratio)} " +
1944
- "→ ${if (ratio >= 0.55) "scans" else "panorama"}",
2001
+ "rotGuard=$lowRotationGuard → $mode",
1945
2002
  )
1946
- return if (ratio >= 0.55) "scans" else "panorama"
2003
+ return mode
1947
2004
  }
1948
2005
 
1949
2006
  /**
@@ -151,7 +151,7 @@ internal class KeyframeGate : AutoCloseable {
151
151
  /// initializer below does NOT fire this setter, so the caller
152
152
  /// (IncrementalStitcher.kt) writes it explicitly at capture start
153
153
  /// to push the value into C++ (same contract as the iOS facade).
154
- var maxKeyframeIntervalMs: Double = 2000.0
154
+ var maxKeyframeIntervalMs: Double = 1500.0
155
155
  set(value) {
156
156
  field = value
157
157
  nativeSetMaxKeyframeIntervalMs(nativeHandle, value)
@@ -860,21 +860,27 @@ class RNSARSession(reactContext: ReactApplicationContext)
860
860
  if (configs.isEmpty()) return
861
861
  fun aspect(s: android.util.Size): Float = s.width.toFloat() / s.height.toFloat()
862
862
 
863
- val matched = configs.filter {
864
- kotlin.math.abs(aspect(it.imageSize) - aspect(it.textureSize)) < 0.02f
865
- }
866
- val pool = if (matched.isNotEmpty()) matched else configs
867
- val chosen = pool.sortedWith(
863
+ // Option B (max FOV + bounded memory): prefer the 4:3-aspect
864
+ // IMAGE for full vertical sensor FOV, regardless of texture aspect
865
+ // (the A35 only pairs its 4:3 image with a 16:9 texture, so an
866
+ // aspect-MATCH filter would force 16:9 and lose that FOV). Among
867
+ // 4:3 images prefer the SMALLEST resolution — the keyframe is
868
+ // downscaled to AR_KEYFRAME_MAX_LONG_EDGE anyway, so smallest is
869
+ // closest to that budget + cheapest. Device-agnostic: any
870
+ // device's 4:3 image is chosen, then normalised by the downscale
871
+ // guard in YuvImageConverter. Trade-off: the 16:9 preview texture
872
+ // shows less than the 4:3 capture (accepted for max FOV).
873
+ val chosen = configs.sortedWith(
868
874
  compareBy<CameraConfig> { kotlin.math.abs(aspect(it.imageSize) - 4f / 3f) }
869
- .thenByDescending { it.imageSize.width * it.imageSize.height },
875
+ .thenBy { it.imageSize.width * it.imageSize.height },
870
876
  ).firstOrNull() ?: return
871
877
  session.setCameraConfig(chosen)
872
878
  Log.i(
873
879
  TAG,
874
- "selectMatchingCameraConfig: chose image=" +
880
+ "selectMatchingCameraConfig: chose 4:3-pref image=" +
875
881
  "${chosen.imageSize.width}x${chosen.imageSize.height} texture=" +
876
882
  "${chosen.textureSize.width}x${chosen.textureSize.height} " +
877
- "(from ${configs.size} configs, ${matched.size} aspect-matched)",
883
+ "(from ${configs.size} configs)",
878
884
  )
879
885
  } catch (t: Throwable) {
880
886
  Log.w(TAG, "selectMatchingCameraConfig failed; keeping default config: ${t.message}")
@@ -1,6 +1,8 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  package io.imagestitcher.rn.ar
3
3
 
4
+ import android.graphics.Bitmap
5
+ import android.graphics.BitmapFactory
4
6
  import android.graphics.ImageFormat
5
7
  import android.graphics.Rect
6
8
  import android.graphics.YuvImage
@@ -10,6 +12,14 @@ import androidx.exifinterface.media.ExifInterface
10
12
  import java.io.ByteArrayOutputStream
11
13
  import java.io.File
12
14
  import java.io.FileOutputStream
15
+ import kotlin.math.max
16
+ import kotlin.math.roundToInt
17
+
18
+ /** AR keyframe long-edge budget (px). Every device's acquired AR frame is
19
+ * downscaled to this before the keyframe JPEG is written, so the stitch
20
+ * held-set (and thus memory) is consistent across devices regardless of
21
+ * their ARCore 4:3 image resolution. Matches the non-AR keyframe size. */
22
+ private const val AR_KEYFRAME_MAX_LONG_EDGE = 640
13
23
 
14
24
  /**
15
25
  * Convert an ARCore `Image` (YUV_420_888) to a JPEG file on disk.
@@ -221,8 +231,36 @@ internal object YuvImageConverter {
221
231
  baos,
222
232
  )
223
233
  if (!ok) return null
234
+ // AR keyframe downscale guard — normalise the long edge to
235
+ // AR_KEYFRAME_MAX_LONG_EDGE so every device (whatever its ARCore 4:3
236
+ // image resolution) writes the same ~0.3 MP keyframe -> consistent
237
+ // stitch memory cross-device. Only the SAVED keyframe is scaled; the
238
+ // C++ keyframe gate already ran on the full-res Y plane upstream.
239
+ var jpegBytes = baos.toByteArray()
240
+ if (max(packed.width, packed.height) > AR_KEYFRAME_MAX_LONG_EDGE) {
241
+ val src = BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size)
242
+ if (src != null) {
243
+ val scale =
244
+ AR_KEYFRAME_MAX_LONG_EDGE.toFloat() / max(src.width, src.height)
245
+ val dst = Bitmap.createScaledBitmap(
246
+ src,
247
+ (src.width * scale).roundToInt().coerceAtLeast(1),
248
+ (src.height * scale).roundToInt().coerceAtLeast(1),
249
+ true,
250
+ )
251
+ val baos2 = ByteArrayOutputStream()
252
+ dst.compress(
253
+ Bitmap.CompressFormat.JPEG,
254
+ jpegQuality.coerceIn(1, 100),
255
+ baos2,
256
+ )
257
+ jpegBytes = baos2.toByteArray()
258
+ if (dst !== src) dst.recycle()
259
+ src.recycle()
260
+ }
261
+ }
224
262
  try {
225
- FileOutputStream(File(outputPath)).use { it.write(baos.toByteArray()) }
263
+ FileOutputStream(File(outputPath)).use { it.write(jpegBytes) }
226
264
  } catch (e: Throwable) {
227
265
  return null
228
266
  }