react-native-image-stitcher 0.14.1 → 0.15.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 (119) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +35 -0
  3. package/RNImageStitcher.podspec +8 -1
  4. package/android/build.gradle +0 -16
  5. package/android/src/main/cpp/CMakeLists.txt +2 -63
  6. package/android/src/main/cpp/image_stitcher_jni.cpp +14 -0
  7. package/android/src/main/cpp/keyframe_gate_jni.cpp +13 -0
  8. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +285 -3
  9. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +180 -1162
  10. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +29 -0
  11. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +0 -4
  12. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +13 -64
  13. package/cpp/keyframe_gate.cpp +82 -23
  14. package/cpp/keyframe_gate.hpp +31 -2
  15. package/cpp/stitcher.cpp +208 -28
  16. package/cpp/tests/CMakeLists.txt +18 -12
  17. package/cpp/tests/keyframe_timebudget_test.cpp +65 -0
  18. package/cpp/tests/warp_guard_test.cpp +48 -0
  19. package/cpp/warp_guard.hpp +41 -0
  20. package/dist/ar/useARSession.d.ts +9 -0
  21. package/dist/ar/useARSession.js +24 -2
  22. package/dist/camera/Camera.d.ts +31 -16
  23. package/dist/camera/Camera.js +27 -4
  24. package/dist/camera/CaptureStitchStatsToast.d.ts +15 -2
  25. package/dist/camera/CaptureStitchStatsToast.js +27 -7
  26. package/dist/camera/PanoramaSettings.d.ts +10 -223
  27. package/dist/camera/PanoramaSettings.js +6 -28
  28. package/dist/camera/PanoramaSettingsBridge.d.ts +1 -24
  29. package/dist/camera/PanoramaSettingsBridge.js +3 -102
  30. package/dist/camera/PanoramaSettingsModal.js +7 -1
  31. package/dist/camera/buildPanoramaInitialSettings.d.ts +11 -0
  32. package/dist/camera/buildPanoramaInitialSettings.js +4 -0
  33. package/dist/camera/cameraErrorMessages.d.ts +32 -0
  34. package/dist/camera/cameraErrorMessages.js +53 -0
  35. package/dist/camera/selectCaptureDevice.d.ts +5 -1
  36. package/dist/camera/selectCaptureDevice.js +22 -2
  37. package/dist/camera/useCapture.js +38 -0
  38. package/dist/index.d.ts +5 -8
  39. package/dist/index.js +11 -34
  40. package/dist/stitching/incremental.d.ts +1 -117
  41. package/dist/stitching/stitchVideo.d.ts +0 -35
  42. package/dist/types.d.ts +0 -87
  43. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +96 -674
  44. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +9 -12
  45. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +14 -0
  46. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +7 -0
  47. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +6 -0
  48. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +2 -2
  49. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +3 -3
  50. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +28 -60
  51. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +180 -921
  52. package/ios/Sources/RNImageStitcher/RNSARSession.swift +10 -35
  53. package/ios/Sources/RNImageStitcher/Stitcher.swift +84 -35
  54. package/ios/Sources/RNImageStitcher/StitcherBridge.m +13 -0
  55. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +132 -5
  56. package/package.json +3 -2
  57. package/src/ar/useARSession.ts +35 -5
  58. package/src/camera/Camera.tsx +63 -24
  59. package/src/camera/CaptureStitchStatsToast.tsx +58 -14
  60. package/src/camera/PanoramaSettings.ts +16 -289
  61. package/src/camera/PanoramaSettingsBridge.ts +3 -114
  62. package/src/camera/PanoramaSettingsModal.tsx +14 -1
  63. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +3 -188
  64. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +41 -0
  65. package/src/camera/__tests__/cameraErrorMessages.test.ts +76 -0
  66. package/src/camera/__tests__/selectCaptureDevice.test.ts +33 -0
  67. package/src/camera/buildPanoramaInitialSettings.ts +17 -0
  68. package/src/camera/cameraErrorMessages.ts +84 -0
  69. package/src/camera/selectCaptureDevice.ts +28 -3
  70. package/src/camera/useCapture.ts +44 -1
  71. package/src/index.ts +11 -40
  72. package/src/stitching/incremental.ts +3 -140
  73. package/src/stitching/stitchVideo.ts +0 -26
  74. package/src/types.ts +0 -95
  75. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +0 -227
  76. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +0 -1081
  77. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +0 -103
  78. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +0 -256
  79. package/cpp/stitcher_frame_jsi.cpp +0 -214
  80. package/cpp/stitcher_frame_jsi.hpp +0 -108
  81. package/cpp/stitcher_proxy_jsi.cpp +0 -109
  82. package/cpp/stitcher_proxy_jsi.hpp +0 -46
  83. package/cpp/stitcher_worklet_dispatch.cpp +0 -103
  84. package/cpp/stitcher_worklet_dispatch.hpp +0 -71
  85. package/cpp/stitcher_worklet_registry.cpp +0 -91
  86. package/cpp/stitcher_worklet_registry.hpp +0 -146
  87. package/cpp/tests/stitcher_worklet_registry_test.cpp +0 -195
  88. package/dist/stitching/IncrementalStitcherView.d.ts +0 -41
  89. package/dist/stitching/IncrementalStitcherView.js +0 -157
  90. package/dist/stitching/StitcherWorkletRegistry.d.ts +0 -117
  91. package/dist/stitching/StitcherWorkletRegistry.js +0 -78
  92. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +0 -8
  93. package/dist/stitching/ensureStitcherProxyInstalled.js +0 -81
  94. package/dist/stitching/useFrameProcessor.d.ts +0 -119
  95. package/dist/stitching/useFrameProcessor.js +0 -196
  96. package/dist/stitching/useFrameStream.d.ts +0 -34
  97. package/dist/stitching/useFrameStream.js +0 -234
  98. package/dist/stitching/useThrottledFrameProcessor.d.ts +0 -33
  99. package/dist/stitching/useThrottledFrameProcessor.js +0 -132
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +0 -474
  101. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +0 -1328
  102. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +0 -103
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +0 -3285
  104. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +0 -128
  105. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +0 -313
  106. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +0 -185
  107. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
  108. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
  109. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +0 -42
  110. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +0 -160
  111. package/src/stitching/IncrementalStitcherView.tsx +0 -198
  112. package/src/stitching/StitcherWorkletRegistry.ts +0 -156
  113. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +0 -176
  114. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +0 -94
  115. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +0 -178
  116. package/src/stitching/ensureStitcherProxyInstalled.ts +0 -141
  117. package/src/stitching/useFrameProcessor.ts +0 -226
  118. package/src/stitching/useFrameStream.ts +0 -271
  119. package/src/stitching/useThrottledFrameProcessor.ts +0 -145
@@ -10,9 +10,16 @@ import com.facebook.react.bridge.WritableNativeMap
10
10
  import kotlinx.coroutines.CoroutineScope
11
11
  import kotlinx.coroutines.Dispatchers
12
12
  import kotlinx.coroutines.launch
13
+ import org.opencv.core.Core
14
+ import org.opencv.core.CvType
13
15
  import org.opencv.core.Mat
14
16
  import org.opencv.core.MatOfInt
17
+ import org.opencv.core.Point
18
+ import org.opencv.core.Rect
19
+ import org.opencv.core.Scalar
20
+ import org.opencv.core.Size
15
21
  import org.opencv.imgcodecs.Imgcodecs
22
+ import org.opencv.imgproc.Imgproc
16
23
  import java.io.File
17
24
  import java.util.concurrent.atomic.AtomicBoolean
18
25
 
@@ -62,9 +69,11 @@ class BatchStitcher(reactContext: ReactApplicationContext)
62
69
  * | "landscape-left" | "landscape-right"
63
70
  * (drives output bake-rotation table,
64
71
  * mirrors iOS)
65
- * @param useInscribedRectCrop reserved for future parity with
66
- * iOS' inscribed-rect crop toggle;
67
- * currently bbox-only on Android
72
+ * @param useInscribedRectCrop when true, the cv::Stitcher path
73
+ * crops to the maximum inscribed
74
+ * rectangle of the coverage mask
75
+ * (choose_crop_rect in stitcher.cpp);
76
+ * false = looser cv::boundingRect
68
77
  * @return [width, height] of the written JPEG
69
78
  * @throws RuntimeException on stitch failure
70
79
  */
@@ -264,6 +273,279 @@ class BatchStitcher(reactContext: ReactApplicationContext)
264
273
  }
265
274
  }
266
275
 
276
+ // ── v0.15 inscribed-rect debug harness (iOS parity) ──────────
277
+ //
278
+ // Pure-Kotlin / OpenCV-Java twins of iOS' OpenCVStitcher
279
+ // computeInscribedRect / cropToRect / debugMaskOverlay. Exposing
280
+ // them as @ReactMethods is what makes the example app's
281
+ // `inscribedRectDebugAvailable()` return true so the __DEV__
282
+ // top-left rect-debug toggle shows on Android too. The
283
+ // inscribed-rect ALGORITHM is a direct port of cpp/stitcher.cpp's
284
+ // maxInscribedRectFromMask, duplicated in Kotlin per the same
285
+ // "duplicate stage code, DRY when proven" convention the sibling
286
+ // normaliseImage follows. Returns/params
287
+ // match the iOS StitcherBridge contract exactly so the shared JS
288
+ // (InscribedRectDebug.tsx) is platform-agnostic.
289
+
290
+ /** Resolves `{ x, y, width, height, imageWidth, imageHeight }`. */
291
+ @ReactMethod
292
+ fun computeInscribedRect(options: ReadableMap, promise: Promise) {
293
+ val imagePath = options.getString("imagePath")
294
+ ?: return promise.reject("invalid-options", "imagePath required")
295
+ CoroutineScope(Dispatchers.Default).launch {
296
+ val toRelease = mutableListOf<Mat>()
297
+ try {
298
+ ensureOpenCv()
299
+ val cleaned = stripFileScheme(imagePath)
300
+ if (!File(cleaned).exists()) {
301
+ promise.reject("read-failed", "Image not found: $imagePath")
302
+ return@launch
303
+ }
304
+ val img = Imgcodecs.imread(cleaned, Imgcodecs.IMREAD_COLOR)
305
+ toRelease += img
306
+ if (img.empty()) {
307
+ promise.reject("read-failed", "Could not decode $imagePath")
308
+ return@launch
309
+ }
310
+ val mask = coverageOrBrightnessMask(img, cleaned, 1, toRelease)
311
+ val r = maxInscribedRect(mask) // [x, y, w, h]
312
+ promise.resolve(WritableNativeMap().apply {
313
+ putInt("x", r[0])
314
+ putInt("y", r[1])
315
+ putInt("width", r[2])
316
+ putInt("height", r[3])
317
+ putInt("imageWidth", img.cols())
318
+ putInt("imageHeight", img.rows())
319
+ })
320
+ } catch (t: Throwable) {
321
+ promise.reject("compute-inscribed-rect-failed", t.message, t)
322
+ } finally {
323
+ toRelease.forEach { it.release() }
324
+ }
325
+ }
326
+ }
327
+
328
+ /** Crop `imagePath` to `{ x, y, width, height }` in place; resolves `{ width, height }`. */
329
+ @ReactMethod
330
+ fun cropToRect(options: ReadableMap, promise: Promise) {
331
+ val imagePath = options.getString("imagePath")
332
+ ?: return promise.reject("invalid-options", "imagePath required")
333
+ fun optInt(key: String, default: Int): Int =
334
+ if (options.hasKey(key) && !options.isNull(key)) options.getInt(key) else default
335
+ val x = optInt("x", 0)
336
+ val y = optInt("y", 0)
337
+ val width = optInt("width", 0)
338
+ val height = optInt("height", 0)
339
+ val quality = optInt("quality", 90).coerceIn(1, 100)
340
+ CoroutineScope(Dispatchers.Default).launch {
341
+ val toRelease = mutableListOf<Mat>()
342
+ try {
343
+ ensureOpenCv()
344
+ val cleaned = stripFileScheme(imagePath)
345
+ if (!File(cleaned).exists()) {
346
+ promise.reject("read-failed", "Image not found: $imagePath")
347
+ return@launch
348
+ }
349
+ val img = Imgcodecs.imread(cleaned, Imgcodecs.IMREAD_COLOR)
350
+ toRelease += img
351
+ if (img.empty()) {
352
+ promise.reject("read-failed", "Could not decode $imagePath")
353
+ return@launch
354
+ }
355
+ // Clamp the rect to image bounds (never trust JS input).
356
+ val rx = x.coerceIn(0, img.cols() - 1)
357
+ val ry = y.coerceIn(0, img.rows() - 1)
358
+ var rw = width.coerceAtLeast(1)
359
+ var rh = height.coerceAtLeast(1)
360
+ if (rx + rw > img.cols()) rw = img.cols() - rx
361
+ if (ry + rh > img.rows()) rh = img.rows() - ry
362
+ val cropped = Mat(img, Rect(rx, ry, rw, rh)).clone()
363
+ toRelease += cropped
364
+ val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, quality)
365
+ if (!Imgcodecs.imwrite(cleaned, cropped, params)) {
366
+ promise.reject("write-failed", "Could not rewrite $imagePath")
367
+ return@launch
368
+ }
369
+ promise.resolve(WritableNativeMap().apply {
370
+ putInt("width", cropped.cols())
371
+ putInt("height", cropped.rows())
372
+ })
373
+ } catch (t: Throwable) {
374
+ promise.reject("crop-to-rect-failed", t.message, t)
375
+ } finally {
376
+ toRelease.forEach { it.release() }
377
+ }
378
+ }
379
+ }
380
+
381
+ /** Red-tint the dropped pixels; writes `<path>.mask.jpg`. Resolves `{ maskPath, width, height, excludedPercent }`. */
382
+ @ReactMethod
383
+ fun debugMaskOverlay(options: ReadableMap, promise: Promise) {
384
+ val imagePath = options.getString("imagePath")
385
+ ?: return promise.reject("invalid-options", "imagePath required")
386
+ val threshold = if (options.hasKey("threshold") && !options.isNull("threshold")) {
387
+ options.getInt("threshold")
388
+ } else {
389
+ 1
390
+ }
391
+ CoroutineScope(Dispatchers.Default).launch {
392
+ val toRelease = mutableListOf<Mat>()
393
+ try {
394
+ ensureOpenCv()
395
+ val cleaned = stripFileScheme(imagePath)
396
+ if (!File(cleaned).exists()) {
397
+ promise.reject("read-failed", "Image not found: $imagePath")
398
+ return@launch
399
+ }
400
+ val img = Imgcodecs.imread(cleaned, Imgcodecs.IMREAD_COLOR)
401
+ toRelease += img
402
+ if (img.empty()) {
403
+ promise.reject("read-failed", "Could not decode $imagePath")
404
+ return@launch
405
+ }
406
+ val mask = coverageOrBrightnessMask(img, cleaned, threshold.coerceAtLeast(0), toRelease)
407
+ val excluded = Mat()
408
+ toRelease += excluded
409
+ Core.bitwise_not(mask, excluded) // 255 = dropped pixels
410
+ // Blend red (BGR 0,0,255) over the dropped pixels so they stand out.
411
+ val red = Mat(img.size(), img.type(), Scalar(0.0, 0.0, 255.0))
412
+ toRelease += red
413
+ val blended = Mat()
414
+ toRelease += blended
415
+ Core.addWeighted(img, 0.35, red, 0.65, 0.0, blended)
416
+ val overlay = img.clone()
417
+ toRelease += overlay
418
+ blended.copyTo(overlay, excluded)
419
+ val maskPath = "$cleaned.mask.jpg"
420
+ val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, 90)
421
+ if (!Imgcodecs.imwrite(maskPath, overlay, params)) {
422
+ promise.reject("write-failed", "Could not write mask overlay for $imagePath")
423
+ return@launch
424
+ }
425
+ val total = img.rows() * img.cols()
426
+ val excludedPercent = if (total > 0) Core.countNonZero(excluded) * 100 / total else 0
427
+ promise.resolve(WritableNativeMap().apply {
428
+ putString("maskPath", maskPath)
429
+ putInt("width", img.cols())
430
+ putInt("height", img.rows())
431
+ putInt("excludedPercent", excludedPercent)
432
+ })
433
+ } catch (t: Throwable) {
434
+ promise.reject("debug-mask-overlay-failed", t.message, t)
435
+ } finally {
436
+ toRelease.forEach { it.release() }
437
+ }
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Coverage mask for an on-disk image: prefer the TRUE coverage
443
+ * sidecar (`<path>.coverage.png`) the stitch writes next to the
444
+ * panorama; else a brightness proxy (gray > threshold, with
445
+ * border-connected holes filled). Mirrors the iOS debug methods.
446
+ * The returned Mat (and any intermediates) are registered in
447
+ * `toRelease`.
448
+ */
449
+ private fun coverageOrBrightnessMask(
450
+ img: Mat,
451
+ cleaned: String,
452
+ threshold: Int,
453
+ toRelease: MutableList<Mat>,
454
+ ): Mat {
455
+ val coveragePath = "$cleaned.coverage.png"
456
+ if (File(coveragePath).exists()) {
457
+ val cov = Imgcodecs.imread(coveragePath, Imgcodecs.IMREAD_GRAYSCALE)
458
+ toRelease += cov
459
+ if (!cov.empty() && cov.cols() == img.cols() && cov.rows() == img.rows()) {
460
+ val mask = Mat()
461
+ toRelease += mask
462
+ Imgproc.threshold(cov, mask, 0.0, 255.0, Imgproc.THRESH_BINARY)
463
+ return mask
464
+ }
465
+ }
466
+ val gray = Mat()
467
+ toRelease += gray
468
+ Imgproc.cvtColor(img, gray, Imgproc.COLOR_BGR2GRAY)
469
+ val raw = Mat()
470
+ toRelease += raw
471
+ Imgproc.threshold(gray, raw, threshold.toDouble(), 255.0, Imgproc.THRESH_BINARY)
472
+ val filled = fillBorderConnectedHoles(raw)
473
+ toRelease += filled
474
+ return filled
475
+ }
476
+
477
+ /**
478
+ * Fill mask holes NOT connected to the border (interior content the
479
+ * brightness threshold dropped). Mirrors iOS' FillBorderConnectedHoles:
480
+ * pad a black border, flood the border-connected black to white, then
481
+ * OR the surviving interior holes back in.
482
+ */
483
+ private fun fillBorderConnectedHoles(mask: Mat): Mat {
484
+ val padded = Mat()
485
+ Core.copyMakeBorder(mask, padded, 1, 1, 1, 1, Core.BORDER_CONSTANT, Scalar(0.0))
486
+ // floodFill needs a mask 2px larger than the image; a zero mask
487
+ // makes it behave like the no-mask overload iOS uses.
488
+ val ffMask = Mat.zeros(padded.rows() + 2, padded.cols() + 2, CvType.CV_8UC1)
489
+ Imgproc.floodFill(padded, ffMask, Point(0.0, 0.0), Scalar(255.0))
490
+ val exterior = Mat(padded, Rect(1, 1, mask.cols(), mask.rows()))
491
+ val holes = Mat()
492
+ Core.bitwise_not(exterior, holes)
493
+ val filled = Mat()
494
+ Core.bitwise_or(mask, holes, filled)
495
+ padded.release()
496
+ ffMask.release()
497
+ exterior.release()
498
+ holes.release()
499
+ return filled
500
+ }
501
+
502
+ /**
503
+ * Largest axis-aligned rectangle entirely inside the non-zero region
504
+ * of a CV_8UC1 `mask`. Returns `[x, y, width, height]` (all 0 if
505
+ * empty). Direct port of cpp/stitcher.cpp's maxInscribedRectFromMask
506
+ * (max-rectangle-in-histogram, row-swept, O(W*H)); operates on a
507
+ * bulk-extracted ByteArray so there is no per-pixel JNI Mat access.
508
+ */
509
+ private fun maxInscribedRect(mask: Mat): IntArray {
510
+ if (mask.empty() || mask.type() != CvType.CV_8UC1) return intArrayOf(0, 0, 0, 0)
511
+ val h = mask.rows()
512
+ val w = mask.cols()
513
+ val data = ByteArray(h * w)
514
+ mask.get(0, 0, data)
515
+ val heights = IntArray(w)
516
+ var bestArea = 0L
517
+ var bx = 0
518
+ var by = 0
519
+ var bw = 0
520
+ var bh = 0
521
+ val stack = IntArray(w + 1)
522
+ for (row in 0 until h) {
523
+ val base = row * w
524
+ for (col in 0 until w) {
525
+ heights[col] = if (data[base + col].toInt() != 0) heights[col] + 1 else 0
526
+ }
527
+ var sp = 0
528
+ for (col in 0..w) {
529
+ val hh = if (col == w) 0 else heights[col]
530
+ while (sp > 0 && heights[stack[sp - 1]] > hh) {
531
+ val topIdx = stack[--sp]
532
+ val leftIdx = if (sp == 0) -1 else stack[sp - 1]
533
+ val width = col - leftIdx - 1
534
+ val area = heights[topIdx].toLong() * width.toLong()
535
+ if (area > bestArea) {
536
+ bestArea = area
537
+ bx = leftIdx + 1
538
+ by = row - heights[topIdx] + 1
539
+ bw = width
540
+ bh = heights[topIdx]
541
+ }
542
+ }
543
+ stack[sp++] = col
544
+ }
545
+ }
546
+ return intArrayOf(bx, by, bw, bh)
547
+ }
548
+
267
549
  // ── Internals ────────────────────────────────────────────────
268
550
 
269
551
  /**