react-native-image-stitcher 0.14.2 → 0.15.1

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 (120) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +35 -0
  3. package/RNImageStitcher.podspec +8 -7
  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 +129 -71
  13. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +49 -0
  14. package/cpp/keyframe_gate.cpp +82 -23
  15. package/cpp/keyframe_gate.hpp +31 -2
  16. package/cpp/stitcher.cpp +208 -28
  17. package/cpp/tests/CMakeLists.txt +18 -12
  18. package/cpp/tests/keyframe_timebudget_test.cpp +65 -0
  19. package/cpp/tests/warp_guard_test.cpp +48 -0
  20. package/cpp/warp_guard.hpp +41 -0
  21. package/dist/camera/Camera.d.ts +31 -16
  22. package/dist/camera/Camera.js +11 -3
  23. package/dist/camera/CameraView.js +93 -3
  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/RNSARCameraView.swift +82 -7
  53. package/ios/Sources/RNImageStitcher/RNSARSession.swift +10 -35
  54. package/ios/Sources/RNImageStitcher/Stitcher.swift +84 -35
  55. package/ios/Sources/RNImageStitcher/StitcherBridge.m +13 -0
  56. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +132 -5
  57. package/package.json +3 -2
  58. package/src/camera/Camera.tsx +44 -23
  59. package/src/camera/CameraView.tsx +113 -4
  60. package/src/camera/CaptureStitchStatsToast.tsx +58 -14
  61. package/src/camera/PanoramaSettings.ts +16 -289
  62. package/src/camera/PanoramaSettingsBridge.ts +3 -114
  63. package/src/camera/PanoramaSettingsModal.tsx +14 -1
  64. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +3 -188
  65. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +41 -0
  66. package/src/camera/__tests__/cameraErrorMessages.test.ts +76 -0
  67. package/src/camera/__tests__/selectCaptureDevice.test.ts +33 -0
  68. package/src/camera/buildPanoramaInitialSettings.ts +17 -0
  69. package/src/camera/cameraErrorMessages.ts +84 -0
  70. package/src/camera/selectCaptureDevice.ts +28 -3
  71. package/src/camera/useCapture.ts +44 -1
  72. package/src/index.ts +11 -40
  73. package/src/stitching/incremental.ts +3 -140
  74. package/src/stitching/stitchVideo.ts +0 -26
  75. package/src/types.ts +0 -95
  76. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +0 -227
  77. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +0 -1081
  78. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +0 -103
  79. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +0 -256
  80. package/cpp/stitcher_frame_jsi.cpp +0 -214
  81. package/cpp/stitcher_frame_jsi.hpp +0 -108
  82. package/cpp/stitcher_proxy_jsi.cpp +0 -109
  83. package/cpp/stitcher_proxy_jsi.hpp +0 -46
  84. package/cpp/stitcher_worklet_dispatch.cpp +0 -103
  85. package/cpp/stitcher_worklet_dispatch.hpp +0 -71
  86. package/cpp/stitcher_worklet_registry.cpp +0 -91
  87. package/cpp/stitcher_worklet_registry.hpp +0 -146
  88. package/cpp/tests/stitcher_worklet_registry_test.cpp +0 -195
  89. package/dist/stitching/IncrementalStitcherView.d.ts +0 -41
  90. package/dist/stitching/IncrementalStitcherView.js +0 -157
  91. package/dist/stitching/StitcherWorkletRegistry.d.ts +0 -117
  92. package/dist/stitching/StitcherWorkletRegistry.js +0 -78
  93. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +0 -8
  94. package/dist/stitching/ensureStitcherProxyInstalled.js +0 -81
  95. package/dist/stitching/useFrameProcessor.d.ts +0 -119
  96. package/dist/stitching/useFrameProcessor.js +0 -196
  97. package/dist/stitching/useFrameStream.d.ts +0 -34
  98. package/dist/stitching/useFrameStream.js +0 -234
  99. package/dist/stitching/useThrottledFrameProcessor.d.ts +0 -33
  100. package/dist/stitching/useThrottledFrameProcessor.js +0 -132
  101. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +0 -474
  102. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +0 -1328
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +0 -103
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +0 -3285
  105. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +0 -128
  106. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +0 -313
  107. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +0 -185
  108. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
  109. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
  110. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +0 -42
  111. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +0 -160
  112. package/src/stitching/IncrementalStitcherView.tsx +0 -198
  113. package/src/stitching/StitcherWorkletRegistry.ts +0 -156
  114. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +0 -176
  115. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +0 -94
  116. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +0 -178
  117. package/src/stitching/ensureStitcherProxyInstalled.ts +0 -141
  118. package/src/stitching/useFrameProcessor.ts +0 -226
  119. package/src/stitching/useFrameStream.ts +0 -271
  120. 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
  /**