react-native-image-stitcher 0.15.1 → 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.
- package/CHANGELOG.md +147 -1
- package/README.md +116 -5
- package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
- package/cpp/crop_quad.cpp +162 -0
- package/cpp/crop_quad.hpp +163 -0
- package/cpp/stitcher.cpp +651 -55
- package/cpp/stitcher.hpp +10 -0
- package/cpp/warp_guard.hpp +212 -0
- package/dist/camera/Camera.d.ts +196 -12
- package/dist/camera/Camera.js +629 -35
- package/dist/camera/CameraView.js +62 -5
- package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
- package/dist/camera/CaptureCountdownOverlay.js +239 -0
- package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
- package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
- package/dist/camera/CaptureMemoryPill.d.ts +9 -1
- package/dist/camera/CaptureMemoryPill.js +3 -3
- package/dist/camera/CapturePreview.js +2 -1
- package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
- package/dist/camera/CaptureStatusOverlay.js +22 -5
- package/dist/camera/CaptureThumbnailStrip.js +2 -1
- package/dist/camera/LateralMotionModal.d.ts +85 -0
- package/dist/camera/LateralMotionModal.js +134 -0
- package/dist/camera/PanHowToOverlay.d.ts +76 -0
- package/dist/camera/PanHowToOverlay.js +222 -0
- package/dist/camera/PanoramaSettings.d.ts +8 -6
- package/dist/camera/PanoramaSettings.js +26 -5
- package/dist/camera/PanoramaSettingsModal.js +4 -4
- package/dist/camera/RectCropPreview.d.ts +161 -0
- package/dist/camera/RectCropPreview.js +480 -0
- package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
- package/dist/camera/RotateToLandscapePrompt.js +138 -0
- package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
- package/dist/camera/buildPanoramaInitialSettings.js +9 -0
- package/dist/camera/cameraErrorMessages.d.ts +30 -1
- package/dist/camera/cameraErrorMessages.js +26 -10
- package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
- package/dist/camera/cameraGuidanceCopy.js +80 -0
- package/dist/camera/captureCountdown.d.ts +52 -0
- package/dist/camera/captureCountdown.js +76 -0
- package/dist/camera/captureWarnings.d.ts +90 -0
- package/dist/camera/captureWarnings.js +108 -0
- package/dist/camera/classifyStitchError.d.ts +30 -0
- package/dist/camera/classifyStitchError.js +42 -0
- package/dist/camera/cropGeometry.d.ts +136 -0
- package/dist/camera/cropGeometry.js +223 -0
- package/dist/camera/displayDecodeImageProps.d.ts +25 -0
- package/dist/camera/displayDecodeImageProps.js +29 -0
- package/dist/camera/guidanceGraphics.d.ts +58 -0
- package/dist/camera/guidanceGraphics.js +280 -0
- package/dist/camera/guidanceTokens.d.ts +54 -0
- package/dist/camera/guidanceTokens.js +58 -0
- package/dist/camera/panModeGate.d.ts +54 -0
- package/dist/camera/panModeGate.js +62 -0
- package/dist/camera/pickCaptureFormat.d.ts +71 -0
- package/dist/camera/pickCaptureFormat.js +85 -0
- package/dist/camera/stitchDebugInfo.d.ts +27 -0
- package/dist/camera/stitchDebugInfo.js +55 -0
- package/dist/camera/usePanMotion.d.ts +250 -0
- package/dist/camera/usePanMotion.js +451 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +33 -2
- package/dist/stitching/computeInscribedRect.d.ts +40 -0
- package/dist/stitching/computeInscribedRect.js +55 -0
- package/dist/stitching/cropQuad.d.ts +78 -0
- package/dist/stitching/cropQuad.js +116 -0
- package/dist/stitching/incremental.d.ts +45 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
- package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
- package/package.json +5 -1
- package/src/camera/Camera.tsx +994 -47
- package/src/camera/CameraView.tsx +75 -5
- package/src/camera/CaptureCountdownOverlay.tsx +272 -0
- package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
- package/src/camera/CaptureMemoryPill.tsx +17 -3
- package/src/camera/CapturePreview.tsx +5 -0
- package/src/camera/CaptureStatusOverlay.tsx +35 -7
- package/src/camera/CaptureThumbnailStrip.tsx +4 -0
- package/src/camera/LateralMotionModal.tsx +199 -0
- package/src/camera/PanHowToOverlay.tsx +246 -0
- package/src/camera/PanoramaSettings.ts +34 -11
- package/src/camera/PanoramaSettingsModal.tsx +4 -4
- package/src/camera/RectCropPreview.tsx +820 -0
- package/src/camera/RotateToLandscapePrompt.tsx +188 -0
- package/src/camera/buildPanoramaInitialSettings.ts +30 -1
- package/src/camera/cameraErrorMessages.ts +39 -2
- package/src/camera/cameraGuidanceCopy.ts +145 -0
- package/src/camera/captureCountdown.ts +83 -0
- package/src/camera/captureWarnings.ts +190 -0
- package/src/camera/classifyStitchError.ts +68 -0
- package/src/camera/cropGeometry.ts +268 -0
- package/src/camera/displayDecodeImageProps.ts +25 -0
- package/src/camera/guidanceGraphics.tsx +347 -0
- package/src/camera/guidanceTokens.ts +57 -0
- package/src/camera/panModeGate.ts +81 -0
- package/src/camera/pickCaptureFormat.ts +130 -0
- package/src/camera/stitchDebugInfo.ts +71 -0
- package/src/camera/usePanMotion.ts +667 -0
- package/src/index.ts +66 -3
- package/src/stitching/computeInscribedRect.ts +81 -0
- package/src/stitching/cropQuad.ts +167 -0
- package/src/stitching/incremental.ts +45 -0
- package/cpp/tests/CMakeLists.txt +0 -104
- package/cpp/tests/README.md +0 -86
- package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
- package/cpp/tests/pose_test.cpp +0 -74
- package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
- package/cpp/tests/stubs/jsi/jsi.h +0 -33
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
- package/cpp/tests/warp_guard_test.cpp +0 -48
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
- package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
- package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
- package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
- package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
- package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
- package/src/camera/__tests__/useContentRotation.test.ts +0 -89
- package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
- 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
|
-
|
|
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",
|
|
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
|
|
1653
|
-
*
|
|
1654
|
-
*
|
|
1655
|
-
* the
|
|
1656
|
-
*
|
|
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
|
-
*
|
|
1659
|
-
*
|
|
1660
|
-
*
|
|
1661
|
-
*
|
|
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 — `
|
|
1665
|
-
*
|
|
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
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
val mb =
|
|
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
|
-
"→ $
|
|
2001
|
+
"rotGuard=$lowRotationGuard → $mode",
|
|
1945
2002
|
)
|
|
1946
|
-
return
|
|
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 =
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
.
|
|
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
|
|
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(
|
|
263
|
+
FileOutputStream(File(outputPath)).use { it.write(jpegBytes) }
|
|
226
264
|
} catch (e: Throwable) {
|
|
227
265
|
return null
|
|
228
266
|
}
|