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
@@ -96,15 +96,6 @@ class IncrementalStitcher(
96
96
  @ReactMethod
97
97
  fun removeListeners(count: Int) { /* no-op */ }
98
98
 
99
- /// V7 hybrid engine — selected for engineMode == 'hybrid'.
100
- private var engine: IncrementalEngine? = null
101
- /// V12.7 firstwins engine — selected for any engineMode starting
102
- /// with 'firstwins' (firstwins, firstwins-zoomed, firstwins-rectilinear).
103
- /// Native engine is identical for firstwins and firstwins-zoomed
104
- /// (the difference is JS-side viewport zoom only). useRectilinear
105
- /// is set for 'firstwins-rectilinear'.
106
- private var firstwinsEngine: IncrementalFirstwinsEngine? = null
107
-
108
99
  // ── V16 batch-keyframe mode (Android parity with iOS' V16 Phase 1) ─
109
100
  //
110
101
  // Selected for engineMode == 'batch-keyframe'. No live engine
@@ -112,15 +103,11 @@ class IncrementalStitcher(
112
103
  // and at finalize() time we hand them all to the JNI shim
113
104
  // (libimage_stitcher.so) for one-shot cv::Stitcher processing.
114
105
  //
115
- // The MVP gate is frame-count-based ("accept every Nth frame
116
- // until cap"). iOS uses a pose-based gate (overlap < threshold)
117
- // adding that here is a follow-up that needs ARCore-pose
118
- // accumulation across `ingestFromARCameraView` calls. For now,
119
- // every N-th frame is good enough to validate end-to-end
120
- // stitching parity.
106
+ // Accepted frames are selected by the shared C++ KeyframeGate
107
+ // (pose / flow / time strategies, configured in start()); the
108
+ // collected paths are handed to the JNI shim at finalize().
121
109
  private var batchKeyframeMode: Boolean = false
122
110
  private val batchKeyframePaths: MutableList<String> = mutableListOf()
123
- private var batchKeyframeFrameCounter: Int = 0
124
111
  /// V16 Phase 2 (Android Fix-1) — per-capture-session subdirectory
125
112
  /// under `cacheDir` where this capture's batch-keyframe JPEGs are
126
113
  /// written. Created on each batch-keyframe `start()` with a fresh
@@ -155,10 +142,6 @@ class IncrementalStitcher(
155
142
  /// Parity: matches iOS `OpenCVKeyframeCollector.sessionDir`
156
143
  /// (created with `Library/AppSupport/Captures/{NSUUID}/`).
157
144
  private var captureSessionDir: java.io.File? = null
158
- /// Accept every Nth frame. 10 is the iOS default capture cadence
159
- /// (5-6 keyframes over a ~2-3 second pan = roughly one every 10
160
- /// frames at 30fps).
161
- private var batchKeyframeAcceptStride: Int = 10
162
145
  /// Hard cap on keyframes to match iOS' default (V16 Phase 1's
163
146
  /// keyframeMaxCount=6). Going higher inflates cv::Stitcher's
164
147
  /// MultiBandBlender memory; iOS hit OOM at 7+ on some scenes.
@@ -342,211 +325,164 @@ class IncrementalStitcher(
342
325
  // Critic #29: clamp snapshotEveryNAccepts to >= 1 so a
343
326
  // value of 0 doesn't mean "snapshot every frame forever".
344
327
  val snapN = max(1, options.getIntOrDefault("snapshotEveryNAccepts", 1))
345
- // V12.7 engineMode now distinguishes 4 variants. See
346
- // src/stitching/incremental.ts for the full description.
347
- // V16 added 'batch-keyframe' as a fifth variant: no live
348
- // engine, frames are saved as JPEGs and handed to
349
- // cv::Stitcher (via the JNI shim) at finalize.
350
- val engineMode = options.getString("engine") ?: "hybrid"
351
- // 2026-05-15 Route 'slitscan*' engineModes to the same
352
- // IncrementalFirstwinsEngine that handles 'firstwins*'.
353
- // Per IncrementalFirstwinsEngine's docstring (lines 260,
354
- // 431, 436, 672, 957): "Mirrors iOS' OpenCVSlitScanStitcher.mm
355
- // exactly". Before this change, 'slitscan' / 'slitscan-rotate'
356
- // / 'slitscan-both' engineModes fell through to IncrementalEngine
357
- // (the hybrid engine), producing identical output to picking
358
- // 'hybrid' — silent platform divergence vs iOS.
359
- //
360
- // iOS-parity reference: IncrementalStitcher.swift:556
361
- // computes `useFirstwinsClass = normalisedMode.hasPrefix("slitscan")`
362
- // which routes BOTH 'slitscan-rotate' AND 'slitscan-both' AND
363
- // the deprecated aliases to OpenCVFirstWinsCylindricalStitcher.
364
- // We mirror that logic here so Android Settings → Engine
365
- // dropdown actually toggles the underlying engine.
366
- val isFirstwinsClass =
367
- engineMode.startsWith("firstwins") ||
368
- engineMode.startsWith("slitscan")
369
- val isFirstwins = isFirstwinsClass // legacy name kept
370
- // for the remainder of
371
- // start() — refactor to
372
- // isFirstwinsClass when
373
- // the engineMode taxonomy
374
- // is rationalised.
375
- val useRectilinear =
376
- engineMode == "firstwins-rectilinear" ||
377
- engineMode == "slitscan-rotate"
378
- val isBatchKeyframe = engineMode == "batch-keyframe"
328
+ // Engine selection. The live engines (hybrid `IncrementalEngine`,
329
+ // firstwins / slitscan `IncrementalFirstwinsEngine`) were archived
330
+ // in the 2026-06 batch-keyframe cleanup -- the SDK now ships only
331
+ // 'batch-keyframe': no live engine; frames are saved as JPEGs and
332
+ // handed to cv::Stitcher (via the JNI shim) at finalize. Any other
333
+ // engineMode is still accepted for backward compatibility but falls
334
+ // back to batch-keyframe with a deprecation log (mirrors iOS'
335
+ // IncrementalStitcher.start()).
336
+ val engineMode = options.getString("engine") ?: "batch-keyframe"
337
+ if (engineMode != "batch-keyframe") {
338
+ android.util.Log.w(
339
+ "IncrementalStitcher",
340
+ "[bridge] DEPRECATED engine '$engineMode' -- live engines " +
341
+ "archived, using batch-keyframe",
342
+ )
343
+ }
379
344
 
380
345
  val configOverrides: ReadableMap? =
381
346
  if (options.hasKey("config")) options.getMap("config") else null
382
347
 
383
- if (isBatchKeyframe) {
384
- // No live engine runs. Reset the keyframe collector
385
- // state. Read knobs from `config` per the V16 Phase
386
- // 1 plumbing pattern.
387
- engine = null
388
- firstwinsEngine = null
389
- batchKeyframeMode = true
390
- batchKeyframePaths.clear()
391
- batchKeyframeFrameCounter = 0
392
- // V16 Phase 2 (Android Fix-1) — fresh per-session subdir
393
- // for this capture's keyframe JPEGs. Replaces the
394
- // V16-Phase-1 "rlis-keyframe-{N}.jpg in cacheDir"
395
- // scheme that caused thumbnails from a previous capture
396
- // to leak into the next one via RN's bitmap cache (see
397
- // `captureSessionDir` declaration above for the full
398
- // RCA). Matches iOS' OpenCVKeyframeCollector behaviour.
399
- captureSessionDir = java.io.File(
400
- reactContext.cacheDir,
401
- "rlis-capture-${java.util.UUID.randomUUID()}",
402
- ).also { it.mkdirs() }
403
- batchKeyframeMaxCount = configOverrides
404
- ?.getIntOrDefault("keyframeMaxCount", 6) ?: 6
405
- batchWarperType = configOverrides?.getString("warperType")
406
- ?: "plane"
407
- batchBlenderType = configOverrides?.getString("blenderType")
408
- ?: "multiband"
409
- batchSeamFinderType = configOverrides?.getString("seamFinderType")
410
- ?: "graphcut"
411
- batchUseInscribedRectCrop = configOverrides
412
- ?.getBooleanOrDefault("enableMaxInscribedRectCrop", false)
413
- ?: false
414
- // 2026-05-14 stitch-mode picker from JS Settings.
415
- // Default 'auto'. Validated against the closed set
416
- // {auto, panorama, scans}; unknown values fall back
417
- // to 'auto'. Reset accumulated-pose state for the
418
- // new capture so finalize() picks a fresh mode.
419
- batchStitchMode = (configOverrides?.getString("stitchMode") ?: "auto")
420
- .let { if (it in setOf("auto", "panorama", "scans")) it else "auto" }
421
- batchFirstAcceptedPose = null
422
- batchLastAcceptedPose = null
423
- // captureOrientation is JS-supplied here (Android
424
- // doesn't yet have a native ARCore classifier
425
- // equivalent to iOS' nativeCaptureOrientation; the
426
- // JS hook is stale but at least it's directional).
427
- batchCaptureOrientation = options.getString("captureOrientation")
428
- ?: "portrait"
429
- // P3-F — configure the shared-C++ KeyframeGate for
430
- // this capture. Same knob set + defaults as iOS:
431
- // overlapThreshold default 0.4 (40% new content)
432
- // maxCount default 6
433
- // Both clamped to safe ranges that iOS also uses (see
434
- // IncrementalStitcher.swift:608-615).
435
- val threshold = configOverrides
436
- ?.getDoubleOrDefault("keyframeOverlapThreshold", 0.4) ?: 0.4
437
- keyframeGate.overlapThreshold = threshold.coerceIn(0.10, 0.80)
438
- keyframeGate.maxCount = batchKeyframeMaxCount.coerceIn(3, 10)
439
-
440
- // 2026-05-14 thread flow-strategy tunables through to the
441
- // shared C++ gate. Before this commit the Android JNI was
442
- // missing setFlowNoveltyPercentile + setFlowMaxTranslationM
443
- // bindings (iOS-only via KeyframeGateBridge), which meant
444
- // operators flipping these in Settings only affected iOS
445
- // captures. Now both platforms honour them.
446
- val pctile = configOverrides
447
- ?.getDoubleOrDefault("flowNoveltyPercentile", 0.85) ?: 0.85
448
- keyframeGate.flowNoveltyPercentile = pctile.coerceIn(0.50, 0.99)
449
- // Settings UI exposes flowMaxTranslationCm in CENTIMETRES;
450
- // C++ API is in METRES. Convert. 0 = disabled.
451
- val txBudgetCm = configOverrides
452
- ?.getDoubleOrDefault("flowMaxTranslationCm", 0.0) ?: 0.0
453
- keyframeGate.flowMaxTranslationM = (txBudgetCm / 100.0).coerceAtLeast(0.0)
454
- // 2026-05-22 (audit F5)flow-strategy Shi-Tomasi
455
- // tunables. Pre-audit, Android had no JNI for these
456
- // (iOS-only via KeyframeGateBridge); JS Settings sliders
457
- // were silent no-ops. Now both platforms honour them.
458
- // Clamp ranges match iOS (IncrementalStitcher.swift:907-924).
459
- val maxCorners = configOverrides
460
- ?.getIntOrDefault("flowMaxCorners", 150) ?: 150
461
- keyframeGate.flowMaxCorners = maxCorners.coerceIn(50, 300)
462
- val quality = configOverrides
463
- ?.getDoubleOrDefault("flowQualityLevel", 0.01) ?: 0.01
464
- keyframeGate.flowQualityLevel = quality.coerceIn(0.005, 0.05)
465
- val minDist = configOverrides
466
- ?.getDoubleOrDefault("flowMinDistance", 10.0) ?: 10.0
467
- keyframeGate.flowMinDistance = minDist.coerceIn(1.0, 50.0)
468
- // Eval throttle: caller (this class) applies the cadence
469
- // at the per-frame call sites. iOS parity at
470
- // IncrementalStitcher.swift:2459-2471.
471
- val evalCadence = configOverrides
472
- ?.getIntOrDefault("flowEvalEveryNFrames", 1) ?: 1
473
- keyframeGate.flowEvalEveryNFrames = evalCadence.coerceIn(1, 10)
474
-
475
- // 2026-05-22 non-AR mode opt-out for angular fallback.
476
- // captureSource = 'non-ar' means the host is using
477
- // vision-camera (no ARKit/ARCore pose). Disable the gate's
478
- // angular fallback so it doesn't compute on garbage pose
479
- // (gyro drift accumulating into the integrated angle was
480
- // making the gate accept near-identical frames → degenerate
481
- // cv::Stitcher params "warpRoi too large" crash).
482
- //
483
- // Audit fix: pre-v0.3 the check tested the legacy
484
- // 'wide'/'ultrawide' enum (replaced 2026-05-14 by 'ar'/'non-ar').
485
- // The string mismatch silently nullified this opt-out for the
486
- // entire Android non-AR path. See PanoramaSettings audit
487
- // table row `captureSource`.
488
- val captureSource = configOverrides?.getString("captureSource") ?: "ar"
489
- val isNonAR = (captureSource == "non-ar")
490
- keyframeGate.disableAngularFallback = isNonAR
491
-
492
- // 2026-05-22 (audit F6) honour frameSelectionMode.
493
- // Pre-audit Android force-enabled the gate with the C++
494
- // default (Pose) strategy regardless of the JS setting,
495
- // making `frameSelectionMode = 'flow-based'` silently
496
- // ineffective on Android (the Flow KLT path was never
497
- // taken — only on iOS). Match iOS' mapping:
498
- // 'time-based' gate disabled (passthrough)
499
- // 'pose-based' gate enabled, Pose strategy
500
- // 'flow-based' gate enabled, Flow strategy
501
- val frameMode = configOverrides?.getString("frameSelectionMode")
502
- ?: "flow-based"
503
- keyframeGate.enabled =
504
- (frameMode == "pose-based" || frameMode == "flow-based")
505
- keyframeGate.strategy = if (frameMode == "flow-based") {
506
- KeyframeGate.Strategy.Flow
507
- } else {
508
- KeyframeGate.Strategy.Pose
509
- }
510
- keyframeGate.reset()
511
- // 2026-05-22 (audit F5) — reset the eval-throttle frame
512
- // counter so the first frame of every capture is
513
- // ALWAYS evaluated regardless of evalCadence.
514
- consumeFrameCounter = 0L
515
- } else if (isFirstwins) {
516
- batchKeyframeMode = false
517
- batchKeyframePaths.clear()
518
- keyframeGate.enabled = false // gate is batch-only; off for live engines
519
- firstwinsEngine = IncrementalFirstwinsEngine(
520
- composeWidth = composeW,
521
- composeHeight = composeH,
522
- canvasWidth = canvasW,
523
- canvasHeight = canvasH,
524
- snapshotJpegQuality = snapQ,
525
- snapshotEveryNAccepts = snapN,
526
- frameRotationDegrees = rotation,
527
- useRectilinear = useRectilinear,
528
- // Critic #27 fix: writable app-sandbox dir for
529
- // live-snapshot JPEGs. java.io.tmpdir resolves to
530
- // /data/local/tmp on Android (rooted-only).
531
- snapshotCacheDir = reactContext.cacheDir.absolutePath,
532
- )
533
- engine = null
348
+ // No live engine runs. Reset the keyframe collector state.
349
+ // Read knobs from `config` per the V16 Phase 1 plumbing pattern.
350
+ batchKeyframeMode = true
351
+ batchKeyframePaths.clear()
352
+ // V16 Phase 2 (Android Fix-1) — fresh per-session subdir
353
+ // for this capture's keyframe JPEGs. Replaces the
354
+ // V16-Phase-1 "rlis-keyframe-{N}.jpg in cacheDir"
355
+ // scheme that caused thumbnails from a previous capture
356
+ // to leak into the next one via RN's bitmap cache (see
357
+ // `captureSessionDir` declaration above for the full
358
+ // RCA). Matches iOS' OpenCVKeyframeCollector behaviour.
359
+ captureSessionDir = java.io.File(
360
+ reactContext.cacheDir,
361
+ "rlis-capture-${java.util.UUID.randomUUID()}",
362
+ ).also { it.mkdirs() }
363
+ batchKeyframeMaxCount = configOverrides
364
+ ?.getIntOrDefault("keyframeMaxCount", 6) ?: 6
365
+ batchWarperType = configOverrides?.getString("warperType")
366
+ ?: "plane"
367
+ batchBlenderType = configOverrides?.getString("blenderType")
368
+ ?: "multiband"
369
+ batchSeamFinderType = configOverrides?.getString("seamFinderType")
370
+ ?: "graphcut"
371
+ batchUseInscribedRectCrop = configOverrides
372
+ ?.getBooleanOrDefault("enableMaxInscribedRectCrop", false)
373
+ ?: false
374
+ // 2026-05-14 — stitch-mode picker from JS Settings.
375
+ // Default 'auto'. Validated against the closed set
376
+ // {auto, panorama, scans}; unknown values fall back
377
+ // to 'auto'. Reset accumulated-pose state for the
378
+ // new capture so finalize() picks a fresh mode.
379
+ batchStitchMode = (configOverrides?.getString("stitchMode") ?: "auto")
380
+ .let { if (it in setOf("auto", "panorama", "scans")) it else "auto" }
381
+ batchFirstAcceptedPose = null
382
+ batchLastAcceptedPose = null
383
+ // captureOrientation is JS-supplied here (Android
384
+ // doesn't yet have a native ARCore classifier
385
+ // equivalent to iOS' nativeCaptureOrientation; the
386
+ // JS hook is stale but at least it's directional).
387
+ batchCaptureOrientation = options.getString("captureOrientation")
388
+ ?: "portrait"
389
+ // P3-F configure the shared-C++ KeyframeGate for
390
+ // this capture. Same knob set + defaults as iOS:
391
+ // overlapThreshold default 0.4 (40% new content)
392
+ // maxCount default 6
393
+ // Both clamped to safe ranges that iOS also uses (see
394
+ // IncrementalStitcher.swift:608-615).
395
+ val threshold = configOverrides
396
+ ?.getDoubleOrDefault("keyframeOverlapThreshold", 0.4) ?: 0.4
397
+ keyframeGate.overlapThreshold = threshold.coerceIn(0.10, 0.80)
398
+ keyframeGate.maxCount = batchKeyframeMaxCount.coerceIn(3, 10)
399
+
400
+ // 2026-05-14 thread flow-strategy tunables through to the
401
+ // shared C++ gate. Before this commit the Android JNI was
402
+ // missing setFlowNoveltyPercentile + setFlowMaxTranslationM
403
+ // bindings (iOS-only via KeyframeGateBridge), which meant
404
+ // operators flipping these in Settings only affected iOS
405
+ // captures. Now both platforms honour them.
406
+ val pctile = configOverrides
407
+ ?.getDoubleOrDefault("flowNoveltyPercentile", 0.85) ?: 0.85
408
+ keyframeGate.flowNoveltyPercentile = pctile.coerceIn(0.50, 0.99)
409
+ // Settings UI exposes flowMaxTranslationCm in CENTIMETRES;
410
+ // C++ API is in METRES. Convert. 0 = disabled.
411
+ val txBudgetCm = configOverrides
412
+ ?.getDoubleOrDefault("flowMaxTranslationCm", 0.0) ?: 0.0
413
+ keyframeGate.flowMaxTranslationM = (txBudgetCm / 100.0).coerceAtLeast(0.0)
414
+ // Wall-clock keyframe-interval budget, in MILLISECONDS.
415
+ // Force-accept once this much time has elapsed since the last
416
+ // accepted keyframe (applies to BOTH Pose and Flow strategies).
417
+ // Passed straight through — the JS value is already in ms (no
418
+ // cm→m style conversion). Clamp to ≥ 0. Default 2000 ms when
419
+ // absent (NOT 0time-budget acceptance is on by default so a
420
+ // stalled scan still advances). iOS parity:
421
+ // IncrementalStitcher.swift maxKeyframeIntervalMs block.
422
+ val maxKfIntervalMs = configOverrides
423
+ ?.getDoubleOrDefault("maxKeyframeIntervalMs", 2000.0) ?: 2000.0
424
+ keyframeGate.maxKeyframeIntervalMs = maxKfIntervalMs.coerceAtLeast(0.0)
425
+ // 2026-05-22 (audit F5) flow-strategy Shi-Tomasi
426
+ // tunables. Pre-audit, Android had no JNI for these
427
+ // (iOS-only via KeyframeGateBridge); JS Settings sliders
428
+ // were silent no-ops. Now both platforms honour them.
429
+ // Clamp ranges match iOS (IncrementalStitcher.swift:907-924).
430
+ val maxCorners = configOverrides
431
+ ?.getIntOrDefault("flowMaxCorners", 150) ?: 150
432
+ keyframeGate.flowMaxCorners = maxCorners.coerceIn(50, 300)
433
+ val quality = configOverrides
434
+ ?.getDoubleOrDefault("flowQualityLevel", 0.01) ?: 0.01
435
+ keyframeGate.flowQualityLevel = quality.coerceIn(0.005, 0.05)
436
+ val minDist = configOverrides
437
+ ?.getDoubleOrDefault("flowMinDistance", 10.0) ?: 10.0
438
+ keyframeGate.flowMinDistance = minDist.coerceIn(1.0, 50.0)
439
+ // Eval throttle: caller (this class) applies the cadence
440
+ // at the per-frame call sites. iOS parity at
441
+ // IncrementalStitcher.swift:2459-2471.
442
+ val evalCadence = configOverrides
443
+ ?.getIntOrDefault("flowEvalEveryNFrames", 1) ?: 1
444
+ keyframeGate.flowEvalEveryNFrames = evalCadence.coerceIn(1, 10)
445
+
446
+ // 2026-05-22 non-AR mode opt-out for angular fallback.
447
+ // captureSource = 'non-ar' means the host is using
448
+ // vision-camera (no ARKit/ARCore pose). Disable the gate's
449
+ // angular fallback so it doesn't compute on garbage pose
450
+ // (gyro drift accumulating into the integrated angle was
451
+ // making the gate accept near-identical frames degenerate
452
+ // cv::Stitcher params → "warpRoi too large" crash).
453
+ //
454
+ // Audit fix: pre-v0.3 the check tested the legacy
455
+ // 'wide'/'ultrawide' enum (replaced 2026-05-14 by 'ar'/'non-ar').
456
+ // The string mismatch silently nullified this opt-out for the
457
+ // entire Android non-AR path. See PanoramaSettings audit
458
+ // table row `captureSource`.
459
+ val captureSource = configOverrides?.getString("captureSource") ?: "ar"
460
+ val isNonAR = (captureSource == "non-ar")
461
+ keyframeGate.disableAngularFallback = isNonAR
462
+
463
+ // 2026-05-22 (audit F6) honour frameSelectionMode.
464
+ // Pre-audit Android force-enabled the gate with the C++
465
+ // default (Pose) strategy regardless of the JS setting,
466
+ // making `frameSelectionMode = 'flow-based'` silently
467
+ // ineffective on Android (the Flow KLT path was never
468
+ // taken — only on iOS). Match iOS' mapping:
469
+ // 'time-based' gate disabled (passthrough)
470
+ // 'pose-based' gate enabled, Pose strategy
471
+ // 'flow-based' → gate enabled, Flow strategy
472
+ val frameMode = configOverrides?.getString("frameSelectionMode")
473
+ ?: "flow-based"
474
+ keyframeGate.enabled =
475
+ (frameMode == "pose-based" || frameMode == "flow-based")
476
+ keyframeGate.strategy = if (frameMode == "flow-based") {
477
+ KeyframeGate.Strategy.Flow
534
478
  } else {
535
- batchKeyframeMode = false
536
- batchKeyframePaths.clear()
537
- keyframeGate.enabled = false // gate is batch-only; off for hybrid engine
538
- engine = IncrementalEngine(
539
- composeWidth = composeW,
540
- composeHeight = composeH,
541
- canvasWidth = canvasW,
542
- canvasHeight = canvasH,
543
- featherPx = featherP,
544
- snapshotJpegQuality = snapQ,
545
- snapshotEveryNAccepts = snapN,
546
- frameRotationDegrees = rotation,
547
- )
548
- firstwinsEngine = null
479
+ KeyframeGate.Strategy.Pose
549
480
  }
481
+ keyframeGate.reset()
482
+ // 2026-05-22 (audit F5) — reset the eval-throttle frame
483
+ // counter so the first frame of every capture is
484
+ // ALWAYS evaluated regardless of evalCadence.
485
+ consumeFrameCounter = 0L
550
486
  // Engage the ARCameraView's per-frame ingestion path if a
551
487
  // view is mounted — this is what gives Android parity
552
488
  // with iOS' ARSession-driven path. No-op when the view
@@ -641,20 +577,11 @@ class IncrementalStitcher(
641
577
  // iOS has used since the V16 ship. See
642
578
  // `private val keyframeGate = KeyframeGate()` above for the
643
579
  // instance + lifetime.
644
- //
645
- // `batchKeyframeAcceptStride` is no longer consulted (the proper
646
- // gate uses pose-driven overlap, not a frame-counter stride);
647
- // the field is kept around for now because removing it would
648
- // touch unrelated init/serialization paths. Wire it back in if
649
- // we ever add a "force every Nth frame regardless of overlap"
650
- // override.
651
580
 
652
581
 
653
582
  @ReactMethod
654
583
  fun finalize(options: ReadableMap, promise: Promise) {
655
- val hybrid = this.engine
656
- val firstwins = this.firstwinsEngine
657
- if (hybrid == null && firstwins == null && !batchKeyframeMode) {
584
+ if (!batchKeyframeMode) {
658
585
  return promise.reject(
659
586
  "incremental-not-running",
660
587
  "No active capture — call start() first.",
@@ -750,16 +677,9 @@ class IncrementalStitcher(
750
677
  )
751
678
  batchKeyframeMode = false
752
679
  batchKeyframePaths.clear()
753
- batchKeyframeFrameCounter = 0
754
680
  batchFirstAcceptedPose = null
755
681
  batchLastAcceptedPose = null
756
682
 
757
- // Null the bridge refs synchronously NOW so any worker that's
758
- // about to run sees them as gone (V12.1 pattern). We keep
759
- // local refs to do the actual finalize.
760
- engine = null
761
- firstwinsEngine = null
762
-
763
683
  workScope.launch {
764
684
  try {
765
685
  val map = Arguments.createMap()
@@ -828,62 +748,18 @@ class IncrementalStitcher(
828
748
  // resolved cv::Stitcher mode so JS can surface it
829
749
  // on the output preview + debug toast.
830
750
  map.putString("stitchModeResolved", stitchModeResolved)
831
- } else if (firstwins != null) {
832
- val snap = firstwins.finalize(outputPath, quality)
833
- ?: throw IllegalStateException("firstwins.finalize returned null")
834
- map.putString("panoramaPath", snap.panoramaPath)
835
- map.putInt("width", snap.width)
836
- map.putInt("height", snap.height)
837
- map.putInt("acceptedCount", snap.acceptedCount)
838
- // Critic #22 fix: explicit native-buffer release.
839
- firstwins.release()
840
751
  } else {
841
- val snap = hybrid!!.finalize(outputPath, quality)
842
- map.putString("panoramaPath", snap.panoramaPath)
843
- map.putInt("width", snap.width)
844
- map.putInt("height", snap.height)
845
- map.putInt("acceptedCount", snap.acceptedCount)
846
- hybrid.release()
847
- // 2026-05-16 — realtime+batch fusion (Option A
848
- // "Replace on completion") hook. The live
849
- // panorama has been written to outputPath; now
850
- // fire-and-forget an async refinement on the
851
- // engine's accepted keyframes via the shared C++
852
- // stitcher.
853
- //
854
- // Today's `IncrementalEngine` (the hybrid live
855
- // engine) does NOT retain per-frame JPEGs — it
856
- // paints into a single persistent canvas Mat
857
- // that's torn down by `release()` above. So the
858
- // keyframe-paths list passed to runHybridAutoRefine
859
- // is empty for the hybrid branch, which means
860
- // the auto-trigger detects "< 2 keyframes on
861
- // disk" and emits `isRefining=false` without
862
- // running cv::Stitcher. Per the prompt's
863
- // "no-op when no keyframes on disk" constraint.
864
- //
865
- // When a future change wires the hybrid engine
866
- // to a keyframe collector (parallel to iOS'
867
- // OpenCVKeyframeCollector), the same hook will
868
- // light up automatically — just pass the
869
- // populated list here.
870
- val keyframePathsForHybrid: List<String> = emptyList()
871
- val refinedOutputPath = refinedPathFromLive(outputPath)
872
- runHybridAutoRefine(
873
- framePaths = keyframePathsForHybrid,
874
- refinedOutputPath = refinedOutputPath,
875
- captureOrientation = captureOrientationSnapshot,
876
- warperType = warperTypeSnapshot,
877
- blenderType = blenderTypeSnapshot,
878
- seamFinderType = seamFinderTypeSnapshot,
879
- useInscribedRectCrop = useInscribedRectCropSnapshot,
752
+ // The live engines (hybrid + firstwins/slit) and their
753
+ // auto-refine hook were archived in the 2026-06 batch-
754
+ // keyframe cleanup; batchKeyframeMode is always true now.
755
+ throw IllegalStateException(
756
+ "finalize: live engines were archived; " +
757
+ "expected batchKeyframeMode.",
880
758
  )
881
759
  }
882
760
  map.putInt("droppedBackpressure", 0)
883
761
  promise.resolve(map)
884
762
  } catch (t: Throwable) {
885
- firstwins?.release()
886
- hybrid?.release()
887
763
  promise.reject("incremental-finalize-failed", t.message, t)
888
764
  }
889
765
  }
@@ -891,17 +767,12 @@ class IncrementalStitcher(
891
767
 
892
768
  @ReactMethod
893
769
  fun cancel(promise: Promise) {
894
- // Critic #4 fix: synchronously flip isRunning + null engine
895
- // refs BEFORE releasing. Any in-flight worker bails at the
896
- // re-check before touching the now-null engine. Matches
897
- // iOS V12.1 cancel path.
770
+ // Critic #4 fix: synchronously flip isRunning BEFORE the work
771
+ // queue runs. Any in-flight worker bails at the re-check.
772
+ // Matches iOS V12.1 cancel path.
898
773
  arCameraViewRef?.setIncrementalIngestionActive(false)
899
774
  isRunning.set(false)
900
775
  frameProcessorIngestEnabled.set(false) // F8.4 — cut producer-thread ingest at cancel
901
- val hybrid = engine
902
- val firstwins = firstwinsEngine
903
- engine = null
904
- firstwinsEngine = null
905
776
  // V16 Phase 2 (Android Fix-1) — clean up the per-session
906
777
  // batch-keyframe subdir. iOS-parity: cancel removes the
907
778
  // session's saved JPEGs because the operator explicitly
@@ -912,14 +783,10 @@ class IncrementalStitcher(
912
783
  captureSessionDir = null
913
784
  batchKeyframeMode = false
914
785
  batchKeyframePaths.clear()
915
- batchKeyframeFrameCounter = 0
916
- // Defer engine release + session-dir cleanup onto the work
917
- // queue so we don't race with an ingest that already passed
918
- // the null-check and is mid-execution on a captured local
919
- // reference.
786
+ // Defer the session-dir cleanup onto the work queue so we don't
787
+ // race with an ingest that already passed the null-check and is
788
+ // mid-execution on a captured local reference.
920
789
  workScope.launch {
921
- hybrid?.release()
922
- firstwins?.reset()
923
790
  sessionDirToCleanup?.deleteRecursively()
924
791
  }
925
792
  val map = Arguments.createMap()
@@ -1017,9 +884,9 @@ class IncrementalStitcher(
1017
884
  ) {
1018
885
  // ── V16 batch-keyframe: AR-driven path ─────────────────────
1019
886
  //
1020
- // Batch-keyframe mode runs WITHOUT a live engine (engine ==
1021
- // firstwinsEngine == null) — frames accumulate as keyframe
1022
- // paths and the cv::Stitcher pipeline runs at finalize time.
887
+ // Batch-keyframe mode runs WITHOUT a live engine (they were
888
+ // archived in the 2026-06 cleanup) — frames accumulate as
889
+ // keyframe paths and the cv::Stitcher pipeline runs at finalize.
1023
890
  //
1024
891
  // P3-F: this branch now calls into the shared-C++
1025
892
  // KeyframeGate (cpp/keyframe_gate.{hpp,cpp}, same algorithm
@@ -1190,101 +1057,6 @@ class IncrementalStitcher(
1190
1057
  return
1191
1058
  }
1192
1059
 
1193
- val hybrid = this.engine
1194
- val firstwins = this.firstwinsEngine
1195
- if (hybrid == null && firstwins == null) return
1196
- // 2026-05-21 (v0.3) — legacy live-engine path requires a JPEG
1197
- // path (hybrid/firstwins addFrameAtPath feeds the cv::Mat
1198
- // pipeline). The batch-keyframe path above lazily encodes
1199
- // only on accept and reaches `return` before this point, so
1200
- // we only get here when batchKeyframeMode == false. Caller
1201
- // (RNSARCameraView) was expected to supply legacyJpegPath in
1202
- // that case — defensively drop the frame if it didn't.
1203
- // F8.6 — prefer the pixel-data path when the caller supplied
1204
- // NV21 bytes (Frame Processor / refactored ARCore path),
1205
- // otherwise fall back to legacyJpegPath (un-migrated ARCore
1206
- // path). At least one of them must be present; drop the
1207
- // frame defensively otherwise.
1208
- val hasPixelData = nv21PixelData != null
1209
- && nv21PixelWidth > 0
1210
- && nv21PixelHeight > 0
1211
- val path = if (hasPixelData) null else legacyJpegPath ?: run {
1212
- android.util.Log.w(
1213
- "IncrementalStitcher",
1214
- "ingestFromARCameraView legacy: batchKeyframeMode=false " +
1215
- "but both legacyJpegPath and nv21PixelData are null — " +
1216
- "dropping frame. Caller must supply NV21 pixel data " +
1217
- "(preferred) or a JPEG path for the live engine path.",
1218
- )
1219
- return
1220
- }
1221
- // v0.10.0 audit #4A — extract the wrapped bytes ONCE on the
1222
- // producer thread before dispatching to workScope. This
1223
- // makes the transfer-of-ownership explicit + caught early:
1224
- // if a caller accidentally passes the same TransferredNV21
1225
- // instance to a sync consumer earlier, takeOnce() would
1226
- // have already thrown there. Capturing `pixelBytes` by
1227
- // value inside the coroutine sidesteps any chance of the
1228
- // wrapper being read from two threads.
1229
- val pixelBytes: ByteArray? = if (hasPixelData) nv21PixelData!!.takeOnce() else null
1230
- workScope.launch {
1231
- val state: WritableMap? = if (firstwins != null) {
1232
- val tele = if (hasPixelData) {
1233
- firstwins.addFramePixelData(
1234
- nv21 = pixelBytes!!,
1235
- nv21Width = nv21PixelWidth,
1236
- nv21Height = nv21PixelHeight,
1237
- qx = qx, qy = qy, qz = qz, qw = qw,
1238
- fx = fx, fy = fy, cx = cx, cy = cy,
1239
- imageWidth = imageWidth, imageHeight = imageHeight,
1240
- yaw = yaw, pitch = pitch,
1241
- fovHorizDegrees = fovHorizDegrees,
1242
- fovVertDegrees = fovVertDegrees,
1243
- trackingPoor = trackingPoor,
1244
- )
1245
- } else {
1246
- firstwins.addFrameAtPath(
1247
- path = path!!,
1248
- qx = qx, qy = qy, qz = qz, qw = qw,
1249
- fx = fx, fy = fy, cx = cx, cy = cy,
1250
- imageWidth = imageWidth, imageHeight = imageHeight,
1251
- yaw = yaw, pitch = pitch,
1252
- fovHorizDegrees = fovHorizDegrees,
1253
- fovVertDegrees = fovVertDegrees,
1254
- trackingPoor = trackingPoor,
1255
- )
1256
- }
1257
- firstwins.snapshotIfDue(tele)
1258
- } else {
1259
- val tele = if (hasPixelData) {
1260
- hybrid!!.addFramePixelData(
1261
- nv21 = pixelBytes!!,
1262
- nv21Width = nv21PixelWidth,
1263
- nv21Height = nv21PixelHeight,
1264
- qx = qx, qy = qy, qz = qz, qw = qw,
1265
- fx = fx, fy = fy, cx = cx, cy = cy,
1266
- imageWidth = imageWidth, imageHeight = imageHeight,
1267
- yaw = yaw, pitch = pitch,
1268
- fovHorizDegrees = fovHorizDegrees,
1269
- fovVertDegrees = fovVertDegrees,
1270
- trackingPoor = trackingPoor,
1271
- )
1272
- } else {
1273
- hybrid!!.addFrameAtPath(
1274
- path = path!!,
1275
- qx = qx, qy = qy, qz = qz, qw = qw,
1276
- fx = fx, fy = fy, cx = cx, cy = cy,
1277
- imageWidth = imageWidth, imageHeight = imageHeight,
1278
- yaw = yaw, pitch = pitch,
1279
- fovHorizDegrees = fovHorizDegrees,
1280
- fovVertDegrees = fovVertDegrees,
1281
- trackingPoor = trackingPoor,
1282
- )
1283
- }
1284
- hybrid.snapshotIfDue(tele)
1285
- }
1286
- emitState(state)
1287
- }
1288
1060
  }
1289
1061
 
1290
1062
  // ─── F8.4 — Frame Processor entry point ──────────────────────
@@ -1479,12 +1251,12 @@ class IncrementalStitcher(
1479
1251
 
1480
1252
  @ReactMethod
1481
1253
  fun getState(promise: Promise) {
1482
- val state = firstwinsEngine?.lastState ?: engine?.lastState
1483
- if (state == null) {
1484
- promise.resolve(null)
1485
- return
1486
- }
1487
- promise.resolve(state)
1254
+ // The live engines exposed a cached `lastState` snapshot; the
1255
+ // batch-keyframe path (the only engine now) drives state purely
1256
+ // through emitted IncrementalStateUpdate events, so there is no
1257
+ // poll-able snapshot to return. (Batch-keyframe captures already
1258
+ // returned null here — engine/firstwinsEngine were both null.)
1259
+ promise.resolve(null)
1488
1260
  }
1489
1261
 
1490
1262
  // ── V15.0e — AR plane detection bridge (iOS-parity) ──────────────
@@ -1816,124 +1588,6 @@ class IncrementalStitcher(
1816
1588
  }
1817
1589
  }
1818
1590
 
1819
- /**
1820
- * 2026-05-16 — realtime+batch fusion auto-trigger called from
1821
- * the hybrid-engine branch of `finalize()`. Fire-and-forget;
1822
- * the finalize() promise has ALREADY resolved with the live
1823
- * panorama before this is invoked.
1824
- *
1825
- * 1. Emits a state event with `isRefining = true` so the
1826
- * host renders a "Refining…" pill.
1827
- * 2. Runs `BatchStitcher.stitchSync(...)` on the supplied
1828
- * keyframe paths.
1829
- * 3. On success: emits a state event with `isRefining = false`
1830
- * AND `refinedPanoramaPath = <path>`.
1831
- * 4. On failure: emits a state event with `isRefining = false`
1832
- * and no refined path. Host keeps showing the live
1833
- * panorama; failure does not affect audit save.
1834
- *
1835
- * NO-OP when `framePaths.size < 2` or any path is missing on
1836
- * disk — matches the design doc's "if keyframes are NOT on
1837
- * disk, auto-trigger is a no-op" contract. Today's hybrid
1838
- * engine retains no per-frame JPEGs so this is the
1839
- * always-no-op path; the hook is wired in advance of a future
1840
- * keyframe-collector enhancement.
1841
- */
1842
- internal fun runHybridAutoRefine(
1843
- framePaths: List<String>,
1844
- refinedOutputPath: String,
1845
- captureOrientation: String,
1846
- warperType: String,
1847
- blenderType: String,
1848
- seamFinderType: String,
1849
- useInscribedRectCrop: Boolean,
1850
- ) {
1851
- if (framePaths.size < 2) {
1852
- android.util.Log.i(
1853
- "IncrementalStitcher",
1854
- "[refine.auto] skipped: framePaths.size=${framePaths.size} " +
1855
- "(hybrid engine retains no per-frame JPEGs)",
1856
- )
1857
- emitRefinementState(isRefining = false, refinedPanoramaPath = null)
1858
- return
1859
- }
1860
- for (p in framePaths) {
1861
- if (!File(p).exists()) {
1862
- android.util.Log.i(
1863
- "IncrementalStitcher",
1864
- "[refine.auto] skipped: missing keyframe $p",
1865
- )
1866
- emitRefinementState(isRefining = false, refinedPanoramaPath = null)
1867
- return
1868
- }
1869
- }
1870
- emitRefinementState(isRefining = true, refinedPanoramaPath = null)
1871
- refineScope.launch {
1872
- try {
1873
- val stitcher = BatchStitcher.bridgeInstance
1874
- ?: throw IllegalStateException(
1875
- "BatchStitcher.bridgeInstance is null at auto-refine time",
1876
- )
1877
- stitcher.stitchSync(
1878
- framePaths.toTypedArray(),
1879
- refinedOutputPath,
1880
- 90,
1881
- warperType,
1882
- blenderType,
1883
- seamFinderType,
1884
- captureOrientation,
1885
- useInscribedRectCrop,
1886
- stitchMode = "scans",
1887
- )
1888
- android.util.Log.i(
1889
- "IncrementalStitcher",
1890
- "[refine.auto] success path=$refinedOutputPath",
1891
- )
1892
- emitRefinementState(
1893
- isRefining = false,
1894
- refinedPanoramaPath = refinedOutputPath,
1895
- )
1896
- } catch (t: Throwable) {
1897
- android.util.Log.w(
1898
- "IncrementalStitcher",
1899
- "[refine.auto] refinement failed (live output kept): ${t.message}",
1900
- )
1901
- emitRefinementState(isRefining = false, refinedPanoramaPath = null)
1902
- }
1903
- }
1904
- }
1905
-
1906
- /**
1907
- * 2026-05-16 — emit a refinement-related state event. Reuses
1908
- * the same IncrementalStateUpdate channel the live
1909
- * engines emit on; JS reads `isRefining` and `refinedPanoramaPath`
1910
- * directly from the event payload (no schema change required on
1911
- * the JS dispatch side).
1912
- */
1913
- private fun emitRefinementState(
1914
- isRefining: Boolean,
1915
- refinedPanoramaPath: String?,
1916
- ) {
1917
- val state = Arguments.createMap().apply {
1918
- putNull("panoramaPath")
1919
- putInt("width", 0)
1920
- putInt("height", 0)
1921
- putInt("acceptedCount", 0)
1922
- putInt("outcome", 0) // AcceptedHigh
1923
- putDouble("confidence", 1.0)
1924
- putDouble("overlapPercent", -1.0)
1925
- putInt("processingMs", 0)
1926
- putBoolean("isLandscape", false)
1927
- putInt("paintedExtent", 0)
1928
- putInt("panExtent", 0)
1929
- putInt("keyframeMax", 0)
1930
- putBoolean("isRefining", isRefining)
1931
- if (refinedPanoramaPath != null) {
1932
- putString("refinedPanoramaPath", refinedPanoramaPath)
1933
- }
1934
- }
1935
- emitState(state)
1936
- }
1937
1591
 
1938
1592
  /**
1939
1593
  * v0.10.0 #15A — emit a refine-pipeline phase update on the same
@@ -1988,24 +1642,6 @@ class IncrementalStitcher(
1988
1642
  emitState(state)
1989
1643
  }
1990
1644
 
1991
- /**
1992
- * 2026-05-16 — given the live panorama path, derive a sibling
1993
- * path for the refined output. Same algorithm iOS uses:
1994
- * /…/<base>.jpg → /…/<base>-refined.jpg
1995
- */
1996
- private fun refinedPathFromLive(livePath: String): String {
1997
- val cleaned = stripFileScheme(livePath)
1998
- val file = File(cleaned)
1999
- val parent = file.parentFile ?: File(reactContext.cacheDir, "panoramas")
2000
- val name = file.name
2001
- val dot = name.lastIndexOf('.')
2002
- val refinedName = if (dot >= 0) {
2003
- "${name.substring(0, dot)}-refined${name.substring(dot)}"
2004
- } else {
2005
- "$name-refined"
2006
- }
2007
- return File(parent, refinedName).absolutePath
2008
- }
2009
1645
 
2010
1646
  /**
2011
1647
  * Poll the process' memory footprint in MB. Android parity for
@@ -2180,10 +1816,8 @@ class IncrementalStitcher(
2180
1816
  state.putInt("width", 0)
2181
1817
  state.putInt("height", 0)
2182
1818
  state.putInt("acceptedCount", keyframeCount)
2183
- // Outcome 0 = AcceptedHigh — matches the FrameOutcome enum
2184
- // ordinal that the live engines emit. Keeps the iOS
2185
- // IncrementalOutcome contract: batch-keyframe
2186
- // accepts all carry outcome=acceptedHigh.
1819
+ // Outcome 0 = AcceptedHigh. Keeps the iOS IncrementalOutcome
1820
+ // contract: batch-keyframe accepts all carry outcome=acceptedHigh.
2187
1821
  state.putInt("outcome", 0)
2188
1822
  state.putDouble("confidence", 1.0)
2189
1823
  val overlapPercent = if (newContentFraction >= 0.0) {
@@ -2378,628 +2012,12 @@ private fun ReadableMap.getBooleanOrDefault(key: String, default: Boolean): Bool
2378
2012
  if (hasKey(key) && !isNull(key)) getBoolean(key) else default
2379
2013
 
2380
2014
 
2381
- // ── Frame outcome mirrors iOS RLISFrameOutcome ────────────────────
2382
-
2383
- internal enum class FrameOutcome {
2384
- AcceptedHigh,
2385
- AcceptedMedium,
2386
- SkippedTooClose,
2387
- RejectedTooFar,
2388
- RejectedSceneUniform,
2389
- RejectedAlignmentLost,
2390
- SkippedTrackingPoor,
2391
- /** V12.11 Step D — operator panned BACKWARDS past the running
2392
- * max along the pan axis. Engine has SKIPPED the paste; host
2393
- * should auto-finalize. Rectilinear-only. */
2394
- RejectedReverseDirection,
2395
- }
2396
-
2397
-
2398
- internal data class FrameTelemetry(
2399
- val outcome: FrameOutcome,
2400
- val overlapPercent: Double,
2401
- val matchCount: Int,
2402
- val inlierRatio: Double,
2403
- val confidence: Double,
2404
- val processingMs: Double,
2405
- /** V12.12 — engine-detected orientation. Mirrors iOS'
2406
- * `RLISFrameTelemetry.isLandscape`. TRUE for landscape capture
2407
- * (vertical pan), FALSE for portrait (horizontal pan). Stays
2408
- * at the FIRST-FRAME determination thereafter. */
2409
- val isLandscape: Boolean = false,
2410
- )
2411
-
2412
-
2413
- internal data class StitcherSnapshot(
2414
- val panoramaPath: String,
2415
- val width: Int,
2416
- val height: Int,
2417
- val acceptedCount: Int,
2418
- )
2419
-
2420
-
2421
- /**
2422
- * Pure-OpenCV implementation of the incremental algorithm. No RN
2423
- * dependency — this class can be unit-tested with synthetic Mat
2424
- * inputs. Exact algorithmic mirror of iOS' OpenCVIncrementalStitcher.mm.
2425
- */
2426
- internal class IncrementalEngine(
2427
- val composeWidth: Int,
2428
- val composeHeight: Int,
2429
- val canvasWidth: Int,
2430
- val canvasHeight: Int,
2431
- val featherPx: Int,
2432
- val snapshotJpegQuality: Int,
2433
- val snapshotEveryNAccepts: Int,
2434
- /// 0/90/180/270 — rotation applied to each ingested frame before
2435
- /// any other processing. See iOS' equivalent for the full
2436
- /// rationale. JS computes from device orientation.
2437
- val frameRotationDegrees: Int,
2438
- ) {
2439
- private val canvas: Mat = Mat.zeros(canvasHeight, canvasWidth, CvType.CV_8UC3)
2440
- private val canvasMask: Mat = Mat.zeros(canvasHeight, canvasWidth, CvType.CV_8UC1)
2441
-
2442
- /// V7 pose-driven state — sensor-native compute path. Mirrors iOS.
2443
- private var firstRotationArkit: Mat = Mat()
2444
- private var kCompose: Mat = Mat()
2445
- private var tCanvas: Mat = Mat.eye(3, 3, CvType.CV_64F)
2446
- private val mArkitToCv: Mat = Mat(3, 3, CvType.CV_64F).apply {
2447
- // diag(1, -1, -1) — ARKit/ARCore (Y-up, -Z forward) → OpenCV.
2448
- setTo(Scalar(0.0))
2449
- put(0, 0, 1.0); put(1, 1, -1.0); put(2, 2, -1.0)
2450
- }
2451
-
2452
- private var lastAcceptedYaw: Double = 0.0
2453
- private var lastAcceptedPitch: Double = 0.0
2454
- private var hasFirstFrame: Boolean = false
2455
- private var acceptsSinceSnapshot: Int = 0
2456
- var acceptedCount: Int = 0
2457
- private set
2458
- private var snapshotSeq: Int = 0
2459
- var lastState: WritableMap? = null
2460
- private set
2461
-
2462
- /**
2463
- * Read the JPEG at `path`, downscale to compose-resolution, run
2464
- * the same algorithm as the iOS engine.
2465
- */
2466
- fun addFrameAtPath(
2467
- path: String,
2468
- qx: Double,
2469
- qy: Double,
2470
- qz: Double,
2471
- qw: Double,
2472
- fx: Double,
2473
- fy: Double,
2474
- cx: Double,
2475
- cy: Double,
2476
- imageWidth: Int,
2477
- imageHeight: Int,
2478
- yaw: Double,
2479
- pitch: Double,
2480
- fovHorizDegrees: Double,
2481
- fovVertDegrees: Double,
2482
- trackingPoor: Boolean,
2483
- ): FrameTelemetry {
2484
- val t0 = System.nanoTime()
2485
- if (trackingPoor) {
2486
- return FrameTelemetry(
2487
- FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
2488
- msSince(t0),
2489
- )
2490
- }
2491
-
2492
- val cleaned = stripFileScheme(path)
2493
- val srcRaw = Imgcodecs.imread(cleaned, Imgcodecs.IMREAD_COLOR)
2494
- if (srcRaw.empty()) {
2495
- return FrameTelemetry(
2496
- FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
2497
- msSince(t0),
2498
- )
2499
- }
2500
- // V7: NO input rotation. ARCore (and the JS gyro fallback)
2501
- // deliver sensor-native landscape frames; we keep them in
2502
- // that frame through the entire compute pipeline. Output
2503
- // rotation for display happens at snapshot/finalize time.
2504
- // See iOS' equivalent fix for the architectural rationale.
2505
- val frame = downsampleToCompose(srcRaw)
2506
- if (frame !== srcRaw) srcRaw.release()
2507
- return addFrameMat(
2508
- frame,
2509
- qx, qy, qz, qw,
2510
- fx, fy, cx, cy,
2511
- imageWidth, imageHeight,
2512
- yaw, pitch,
2513
- fovHorizDegrees, fovVertDegrees,
2514
- t0,
2515
- )
2516
- }
2517
-
2518
- /**
2519
- * F8.6 — pixel-data twin of [addFrameAtPath]. Accepts the
2520
- * camera frame as an NV21 byte buffer instead of a JPEG file
2521
- * path; skips the JPEG decode round-trip. See
2522
- * `IncrementalFirstwinsEngine.addFramePixelData` for the
2523
- * sibling implementation rationale.
2524
- */
2525
- fun addFramePixelData(
2526
- nv21: ByteArray,
2527
- nv21Width: Int,
2528
- nv21Height: Int,
2529
- qx: Double, qy: Double, qz: Double, qw: Double,
2530
- fx: Double, fy: Double, cx: Double, cy: Double,
2531
- imageWidth: Int, imageHeight: Int,
2532
- yaw: Double, pitch: Double,
2533
- fovHorizDegrees: Double, fovVertDegrees: Double,
2534
- trackingPoor: Boolean,
2535
- ): FrameTelemetry {
2536
- val t0 = System.nanoTime()
2537
- if (trackingPoor) {
2538
- return FrameTelemetry(
2539
- FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
2540
- msSince(t0),
2541
- )
2542
- }
2543
- // F8.6 IS-1 — length guard; see
2544
- // `IncrementalFirstwinsEngine.addFramePixelData` for the
2545
- // failure-mode rationale.
2546
- val expectedBytes = nv21Width * nv21Height * 3 / 2
2547
- require(nv21.size >= expectedBytes) {
2548
- "addFramePixelData: nv21 buffer too small " +
2549
- "(${nv21.size} bytes < $expectedBytes for " +
2550
- "${nv21Width}x${nv21Height})"
2551
- }
2552
- val yuv = Mat(nv21Height + nv21Height / 2, nv21Width, CvType.CV_8UC1)
2553
- yuv.put(0, 0, nv21)
2554
- val srcRaw = Mat()
2555
- Imgproc.cvtColor(yuv, srcRaw, Imgproc.COLOR_YUV2BGR_NV21)
2556
- yuv.release()
2557
- if (srcRaw.empty()) {
2558
- return FrameTelemetry(
2559
- FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
2560
- msSince(t0),
2561
- )
2562
- }
2563
- val frame = downsampleToCompose(srcRaw)
2564
- if (frame !== srcRaw) srcRaw.release()
2565
- return addFrameMat(
2566
- frame,
2567
- qx, qy, qz, qw,
2568
- fx, fy, cx, cy,
2569
- imageWidth, imageHeight,
2570
- yaw, pitch,
2571
- fovHorizDegrees, fovVertDegrees,
2572
- t0,
2573
- )
2574
- }
2575
-
2576
- /**
2577
- * F8.6 — the body extracted from [addFrameAtPath]. Takes a
2578
- * BGR `Mat` (already downsampled to compose dims) and runs the
2579
- * pose-driven homography paste pipeline. Behaviour is
2580
- * identical to the pre-F8.6 `addFrameAtPath` — the body is a
2581
- * verbatim move.
2582
- */
2583
- private fun addFrameMat(
2584
- frame: Mat,
2585
- qx: Double, qy: Double, qz: Double, qw: Double,
2586
- fx: Double, fy: Double, cx: Double, cy: Double,
2587
- imageWidth: Int, imageHeight: Int,
2588
- yaw: Double, pitch: Double,
2589
- fovHorizDegrees: Double, fovVertDegrees: Double,
2590
- t0: Long,
2591
- ): FrameTelemetry {
2592
- // Build R_new from quaternion.
2593
- val rNew = quaternionToRotationMat(qx, qy, qz, qw)
2594
-
2595
- if (!hasFirstFrame) {
2596
- firstRotationArkit = rNew.clone()
2597
- // V7: K is in COMPOSE pixel coordinates. Sensor intrinsics
2598
- // get scaled by the same uniform factor we used to downsample
2599
- // the frame, so K · ray → pixel produces the right pixel
2600
- // in compose space directly. No rotation chain needed.
2601
- val sx = frame.cols().toDouble() / maxOf(1, imageWidth)
2602
- val sy = frame.rows().toDouble() / maxOf(1, imageHeight)
2603
- val s = 0.5 * (sx + sy)
2604
- kCompose = Mat(3, 3, CvType.CV_64F).apply {
2605
- setTo(Scalar(0.0))
2606
- put(0, 0, fx * s); put(0, 2, cx * s)
2607
- put(1, 1, fy * s); put(1, 2, cy * s)
2608
- put(2, 2, 1.0)
2609
- }
2610
-
2611
- // Place first frame at canvas centre.
2612
- val ox = (canvas.cols() - frame.cols()) / 2
2613
- val oy = (canvas.rows() - frame.rows()) / 2
2614
- val roi = Rect(ox, oy, frame.cols(), frame.rows())
2615
- frame.copyTo(canvas.submat(roi))
2616
- canvasMask.submat(roi).setTo(Scalar(255.0))
2617
- tCanvas = Mat.eye(3, 3, CvType.CV_64F)
2618
- tCanvas.put(0, 2, ox.toDouble())
2619
- tCanvas.put(1, 2, oy.toDouble())
2620
-
2621
- lastAcceptedYaw = yaw
2622
- lastAcceptedPitch = pitch
2623
- hasFirstFrame = true
2624
- acceptedCount = 1
2625
- frame.release()
2626
- return FrameTelemetry(
2627
- FrameOutcome.AcceptedHigh, 0.0, 0, 0.0, 1.0, msSince(t0),
2628
- )
2629
- }
2630
-
2631
- val overlap = computeOverlapPct(
2632
- yaw - lastAcceptedYaw, pitch - lastAcceptedPitch,
2633
- fovHorizDegrees, fovVertDegrees,
2634
- )
2635
- if (overlap > MAX_OVERLAP_PCT) {
2636
- frame.release()
2637
- return FrameTelemetry(
2638
- FrameOutcome.SkippedTooClose, overlap, 0, 0.0, 0.0, msSince(t0),
2639
- )
2640
- }
2641
- if (overlap < MIN_OVERLAP_PCT) {
2642
- frame.release()
2643
- return FrameTelemetry(
2644
- FrameOutcome.RejectedTooFar, overlap, 0, 0.0, 0.0, msSince(t0),
2645
- )
2646
- }
2647
-
2648
- // V7 pose-driven homography (sensor-native compose space):
2649
- // R_rel_cv = M · R_first⁻¹ · R_new · M
2650
- // H_compose = K_compose · R_rel_cv · K_compose⁻¹
2651
- // H_canvas = T_canvas · H_compose
2652
- // No R2S/S chain — the v6 bug was applying input rotation
2653
- // and undoing it via the chain; v7 keeps everything in
2654
- // sensor-native compose space and rotates only at output.
2655
- val firstInv = Mat()
2656
- Core.transpose(firstRotationArkit, firstInv)
2657
- val tmp1 = Mat(); Core.gemm(mArkitToCv, firstInv, 1.0, Mat(), 0.0, tmp1)
2658
- val tmp2 = Mat(); Core.gemm(tmp1, rNew, 1.0, Mat(), 0.0, tmp2)
2659
- val rRelCv = Mat(); Core.gemm(tmp2, mArkitToCv, 1.0, Mat(), 0.0, rRelCv)
2660
- firstInv.release(); tmp1.release(); tmp2.release()
2661
-
2662
- val kInv = kCompose.inv()
2663
- val hcTmp = Mat(); Core.gemm(kCompose, rRelCv, 1.0, Mat(), 0.0, hcTmp)
2664
- val hCompose = Mat(); Core.gemm(hcTmp, kInv, 1.0, Mat(), 0.0, hCompose)
2665
- kInv.release(); hcTmp.release(); rRelCv.release(); rNew.release()
2666
-
2667
- val hCanvas = Mat(); Core.gemm(tCanvas, hCompose, 1.0, Mat(), 0.0, hCanvas)
2668
- hCompose.release()
2669
-
2670
- warpAndBlend(frame, hCanvas)
2671
- hCanvas.release()
2672
- frame.release()
2673
-
2674
- lastAcceptedYaw = yaw
2675
- lastAcceptedPitch = pitch
2676
- acceptedCount++
2677
-
2678
- // Confidence as in iOS — function of how centred the overlap
2679
- // is in the [10, 75]% acceptance window.
2680
- val midOverlap = 0.5 * (MIN_OVERLAP_PCT + MAX_OVERLAP_PCT)
2681
- val overlapDistance = kotlin.math.abs(overlap - midOverlap) /
2682
- (MAX_OVERLAP_PCT - midOverlap)
2683
- val confidence = maxOf(0.0, 1.0 - overlapDistance)
2684
- val outcome = if (confidence >= 0.6) FrameOutcome.AcceptedHigh
2685
- else FrameOutcome.AcceptedMedium
2686
-
2687
- return FrameTelemetry(
2688
- outcome, overlap, -1, -1.0, confidence, msSince(t0),
2689
- )
2690
- }
2691
-
2692
- /** Write a JPEG snapshot if accept-counter has hit the configured cadence. */
2693
- fun snapshotIfDue(tele: FrameTelemetry): WritableMap? {
2694
- val isAccept = tele.outcome == FrameOutcome.AcceptedHigh
2695
- || tele.outcome == FrameOutcome.AcceptedMedium
2696
- var snapshotPath: String? = null
2697
- var snapW = 0
2698
- var snapH = 0
2699
- if (isAccept) {
2700
- acceptsSinceSnapshot++
2701
- if (acceptsSinceSnapshot >= snapshotEveryNAccepts) {
2702
- acceptsSinceSnapshot = 0
2703
- snapshotSeq++
2704
- val slot = snapshotSeq % 4
2705
- val tmpPath = "${System.getProperty("java.io.tmpdir") ?: "/data/local/tmp"}" +
2706
- "/rlis-live-$slot.jpg"
2707
- // tightCrop = true for live snapshots: the canvas is
2708
- // 4800x2200, but most of it is empty until the pan
2709
- // covers it. Without a tight crop, every snapshot
2710
- // was a ~24 MB JPEG that RN's <Image> couldn't keep
2711
- // up with. Tight-cropped snapshots are 50–500 KB.
2712
- val snap = writeJpeg(tmpPath, snapshotJpegQuality, tightCrop = true)
2713
- if (snap != null) {
2714
- snapshotPath = snap.panoramaPath
2715
- snapW = snap.width
2716
- snapH = snap.height
2717
- }
2718
- }
2719
- }
2720
-
2721
- val map = Arguments.createMap().apply {
2722
- putInt("width", snapW)
2723
- putInt("height", snapH)
2724
- putInt("acceptedCount", acceptedCount)
2725
- putInt("outcome", tele.outcome.ordinal)
2726
- putDouble("confidence", tele.confidence)
2727
- putDouble("overlapPercent", tele.overlapPercent)
2728
- putDouble("processingMs", tele.processingMs)
2729
- if (snapshotPath != null) putString("panoramaPath", snapshotPath)
2730
- }
2731
- lastState = map
2732
- return map
2733
- }
2734
-
2735
- fun finalize(outputPath: String, quality: Int): StitcherSnapshot {
2736
- val cleaned = stripFileScheme(outputPath)
2737
- val snap = writeJpeg(cleaned, quality, tightCrop = true)
2738
- ?: throw IllegalStateException(
2739
- "No frames have been accepted yet, or write failed: $cleaned",
2740
- )
2741
- release()
2742
- return snap
2743
- }
2744
-
2745
- fun release() {
2746
- canvas.release()
2747
- canvasMask.release()
2748
- firstRotationArkit.release()
2749
- kCompose.release()
2750
- tCanvas.release()
2751
- mArkitToCv.release()
2752
- }
2753
-
2754
- // ── internal helpers ────────────────────────────────────────────
2755
-
2756
- private fun downsampleToCompose(src: Mat): Mat {
2757
- // Uniform scale that fits inside the compose-dim budget — the
2758
- // smaller of the two ratios wins so neither axis distorts.
2759
- val sw = src.cols().toDouble()
2760
- val sh = src.rows().toDouble()
2761
- var scale = minOf(composeWidth.toDouble() / sw, composeHeight.toDouble() / sh)
2762
- if (scale > 1.0) scale = 1.0 // never upscale
2763
- val outW = maxOf(1, (sw * scale).toInt())
2764
- val outH = maxOf(1, (sh * scale).toInt())
2765
- if (src.cols() == outW && src.rows() == outH) return src
2766
- val out = Mat()
2767
- Imgproc.resize(src, out, Size(outW.toDouble(), outH.toDouble()), 0.0, 0.0, Imgproc.INTER_AREA)
2768
- return out
2769
- }
2770
-
2771
- // `placeFirstFrame` was dropped in v6 — the first-frame logic is
2772
- // now inlined in `addFrameAtPath` so the engine can capture the
2773
- // reference pose + intrinsics in the same place it positions the
2774
- // frame on the canvas.
2775
-
2776
- private fun warpAndBlend(frame: Mat, worldH: Mat) {
2777
- val canvasSize = Size(canvasWidth.toDouble(), canvasHeight.toDouble())
2778
-
2779
- val warped = Mat()
2780
- Imgproc.warpPerspective(
2781
- frame, warped, worldH, canvasSize,
2782
- Imgproc.INTER_LINEAR, Core.BORDER_CONSTANT, Scalar(0.0, 0.0, 0.0),
2783
- )
2784
-
2785
- val frameOnesMask = Mat(frame.rows(), frame.cols(), CvType.CV_8UC1, Scalar(255.0))
2786
- val warpedMask = Mat()
2787
- Imgproc.warpPerspective(
2788
- frameOnesMask, warpedMask, worldH, canvasSize,
2789
- Imgproc.INTER_NEAREST, Core.BORDER_CONSTANT, Scalar(0.0),
2790
- )
2791
- frameOnesMask.release()
2792
-
2793
- // Hard midline seam (replaces v4 ratio-feather). Same fix
2794
- // as iOS v5: each output pixel comes from exactly one frame,
2795
- // so misalignment between frames can't produce ghosts. The
2796
- // seam is placed where each pixel is equidistant from both
2797
- // frames' outer edges (the "middle" of the overlap), then
2798
- // softened with a small Gaussian to hide the pixel-perfect
2799
- // cut.
2800
- val distNew = Mat()
2801
- Imgproc.distanceTransform(warpedMask, distNew, Imgproc.DIST_L2, 3)
2802
- val distCanvas = Mat()
2803
- Imgproc.distanceTransform(canvasMask, distCanvas, Imgproc.DIST_L2, 3)
2804
-
2805
- // alpha8: 255 where new is deeper, 0 where canvas is deeper.
2806
- val alpha8 = Mat()
2807
- Core.compare(distNew, distCanvas, alpha8, Core.CMP_GE)
2808
-
2809
- // First-touch regions need new frame to write unconditionally.
2810
- val noPriorMask = Mat()
2811
- Core.compare(canvasMask, Scalar(0.0), noPriorMask, Core.CMP_EQ)
2812
- alpha8.setTo(Scalar(255.0), noPriorMask)
2813
- noPriorMask.release()
2814
-
2815
- val alpha = Mat()
2816
- alpha8.convertTo(alpha, CvType.CV_32F, 1.0 / 255.0)
2817
- alpha8.release()
2818
- Imgproc.GaussianBlur(alpha, alpha, Size(7.0, 7.0), 0.0)
2819
- distNew.release(); distCanvas.release()
2820
-
2821
- val alphaChannels = mutableListOf(alpha, alpha, alpha)
2822
- val alpha3 = Mat()
2823
- Core.merge(alphaChannels, alpha3)
2824
- val invAlpha3 = Mat()
2825
- Core.subtract(Mat.ones(alpha3.size(), alpha3.type()).apply {
2826
- setTo(Scalar(1.0, 1.0, 1.0))
2827
- }, alpha3, invAlpha3)
2828
-
2829
- val warpedF = Mat(); warped.convertTo(warpedF, CvType.CV_32FC3)
2830
- val canvasF = Mat(); canvas.convertTo(canvasF, CvType.CV_32FC3)
2831
- val blendedF = Mat()
2832
- Core.multiply(warpedF, alpha3, warpedF)
2833
- Core.multiply(canvasF, invAlpha3, canvasF)
2834
- Core.add(warpedF, canvasF, blendedF)
2835
- warpedF.release(); canvasF.release()
2836
- alpha.release(); alpha3.release(); invAlpha3.release()
2837
-
2838
- val blended8 = Mat()
2839
- blendedF.convertTo(blended8, CvType.CV_8UC3)
2840
- blendedF.release()
2841
- // Only write where warpedMask is set; rest of canvas is unchanged.
2842
- blended8.copyTo(canvas, warpedMask)
2843
- blended8.release()
2844
-
2845
- Core.bitwise_or(canvasMask, warpedMask, canvasMask)
2846
- warpedMask.release()
2847
- warped.release()
2848
- }
2849
-
2850
- private fun writeJpeg(
2851
- outputPath: String,
2852
- quality: Int,
2853
- tightCrop: Boolean,
2854
- ): StitcherSnapshot? {
2855
- if (acceptedCount == 0) return null
2856
- var crop = Rect(0, 0, canvas.cols(), canvas.rows())
2857
- if (tightCrop) {
2858
- val nonZero = MatOfPoint2f()
2859
- // boundingRect on the mask matrix gives us the tight crop;
2860
- // OpenCV Java's API takes a Mat of points, but for an
2861
- // image mask we use Imgproc.boundingRect on a contour.
2862
- // Cheaper path: walk the mask once.
2863
- val contoured = Imgproc.boundingRect(MaskNonZeroContour(canvasMask))
2864
- if (contoured.width > 0 && contoured.height > 0) {
2865
- crop = contoured
2866
- }
2867
- nonZero.release()
2868
- }
2869
- val cropped = Mat(canvas, crop)
2870
- // V7.1 GRAVITY-DERIVED OUTPUT ROTATION. Mirrors iOS — see
2871
- // OpenCVIncrementalStitcher.mm for the full derivation. The
2872
- // rotation comes from the AR pose (which knows gravity) so
2873
- // we don't need a device-orientation hook (which was the
2874
- // source of the v7 "sideways for landscape" bug).
2875
- var rotationDeg = 0
2876
- if (hasFirstFrame && !firstRotationArkit.empty()) {
2877
- val gravWorld = Mat(3, 1, CvType.CV_64F).apply {
2878
- put(0, 0, 0.0); put(1, 0, -1.0); put(2, 0, 0.0)
2879
- }
2880
- val firstT = Mat()
2881
- Core.transpose(firstRotationArkit, firstT)
2882
- val gravArkit = Mat(); Core.gemm(firstT, gravWorld, 1.0, Mat(), 0.0, gravArkit)
2883
- val gravCv = Mat(); Core.gemm(mArkitToCv, gravArkit, 1.0, Mat(), 0.0, gravCv)
2884
- val gx = gravCv.get(0, 0)[0]
2885
- val gy = gravCv.get(1, 0)[0]
2886
- val angle = kotlin.math.atan2(gx, gy) * 180.0 / Math.PI
2887
- rotationDeg = (kotlin.math.round(angle / 90.0).toInt()) * 90
2888
- rotationDeg = ((rotationDeg % 360) + 360) % 360
2889
- gravWorld.release(); firstT.release(); gravArkit.release(); gravCv.release()
2890
- }
2891
- val out = when (rotationDeg) {
2892
- 90 -> Mat().also { Core.rotate(cropped, it, Core.ROTATE_90_CLOCKWISE) }
2893
- 180 -> Mat().also { Core.rotate(cropped, it, Core.ROTATE_180) }
2894
- 270 -> Mat().also { Core.rotate(cropped, it, Core.ROTATE_90_COUNTERCLOCKWISE) }
2895
- else -> cropped
2896
- }
2897
- val outW = out.cols()
2898
- val outH = out.rows()
2899
- val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, quality)
2900
- val ok = Imgcodecs.imwrite(outputPath, out, params)
2901
- if (out !== cropped) out.release()
2902
- cropped.release()
2903
- if (!ok) return null
2904
- return StitcherSnapshot(
2905
- panoramaPath = outputPath,
2906
- width = outW,
2907
- height = outH,
2908
- acceptedCount = acceptedCount,
2909
- )
2910
- }
2911
-
2912
- private fun msSince(t0Nanos: Long): Double =
2913
- (System.nanoTime() - t0Nanos) / 1_000_000.0
2914
-
2915
- companion object {
2916
- // v3 thresholds — relaxed match-count + inlier minimums for
2917
- // light-texture shelf scenes; tighter det range because the
2918
- // affine fit produces a much narrower legitimate scale band.
2919
- private const val MIN_OVERLAP_PCT = 10.0
2920
- private const val MAX_OVERLAP_PCT = 75.0
2921
- private const val MIN_MATCHES_ACCEPT = 10
2922
- private const val MIN_INLIER_RATIO_ACCEPT = 0.18
2923
- private const val HIGH_CONF_MATCHES = 60
2924
- private const val HIGH_CONF_INLIER_RATIO = 0.55
2925
- private const val ORB_MAX_FEATURES = 1000
2926
- private const val ORB_SCALE_FACTOR = 1.2f
2927
- private const val ORB_LEVELS = 8
2928
- private const val ORB_EDGE_THRESHOLD = 31
2929
- private const val LOWE_RATIO = 0.75f
2930
- private const val RANSAC_REPROJ_THRESH = 5.0
2931
- private const val HOM_DET_MIN = 0.7
2932
- private const val HOM_DET_MAX = 1.4
2933
- }
2934
- }
2935
-
2936
-
2937
- /// Helper for OpenCV's odd boundingRect signature on a binary mask.
2938
- /// Wraps the mask's non-zero pixel coords as a MatOfPoint that
2939
- /// `Imgproc.boundingRect` will accept.
2940
- private fun MaskNonZeroContour(mask: Mat): org.opencv.core.MatOfPoint {
2941
- val locations = Mat()
2942
- Core.findNonZero(mask, locations)
2943
- if (locations.empty()) {
2944
- locations.release()
2945
- return org.opencv.core.MatOfPoint()
2946
- }
2947
- val pts = mutableListOf<Point>()
2948
- // findNonZero returns CV_32SC2 with N rows, 1 col. Each entry is
2949
- // a (col, row) pair; build a point list for boundingRect.
2950
- val n = locations.rows()
2951
- val buf = IntArray(2)
2952
- for (i in 0 until n) {
2953
- locations.get(i, 0, buf)
2954
- pts.add(Point(buf[0].toDouble(), buf[1].toDouble()))
2955
- }
2956
- locations.release()
2957
- val out = org.opencv.core.MatOfPoint()
2958
- out.fromList(pts)
2959
- return out
2960
- }
2961
-
2962
-
2963
- // computeOverlapPct, stripFileScheme — same code as iOS's static helpers,
2964
- // transcribed to Kotlin.
2965
-
2966
- internal fun computeOverlapPct(
2967
- deltaYaw: Double,
2968
- deltaPitch: Double,
2969
- fovHorizDegrees: Double,
2970
- fovVertDegrees: Double,
2971
- ): Double {
2972
- val absYaw = kotlin.math.abs(deltaYaw)
2973
- val absPitch = kotlin.math.abs(deltaPitch)
2974
- var fovH = fovHorizDegrees * Math.PI / 180.0
2975
- var fovV = fovVertDegrees * Math.PI / 180.0
2976
- if (fovH <= 1e-6) fovH = 65.0 * Math.PI / 180.0
2977
- if (fovV <= 1e-6) fovV = 50.0 * Math.PI / 180.0
2978
- val overlap = if (absYaw >= absPitch) {
2979
- 1.0 - absYaw / fovH
2980
- } else {
2981
- 1.0 - absPitch / fovV
2982
- }
2983
- return overlap.coerceIn(0.0, 1.0) * 100.0
2984
- }
2015
+ // stripFileScheme same code as iOS's static helper, transcribed to Kotlin.
2985
2016
 
2986
2017
 
2987
2018
  internal fun stripFileScheme(path: String): String =
2988
2019
  if (path.startsWith("file://")) path.removePrefix("file://") else path
2989
2020
 
2990
2021
 
2991
- /// Quaternion → 3x3 rotation matrix, mirroring iOS `quaternionToRotationMat`.
2992
- internal fun quaternionToRotationMat(qx0: Double, qy0: Double, qz0: Double, qw0: Double): Mat {
2993
- var qx = qx0; var qy = qy0; var qz = qz0; var qw = qw0
2994
- val n = kotlin.math.sqrt(qx*qx + qy*qy + qz*qz + qw*qw)
2995
- if (n > 1e-9) { qx /= n; qy /= n; qz /= n; qw /= n }
2996
- val r = Mat(3, 3, CvType.CV_64F)
2997
- r.put(0, 0, 1 - 2*(qy*qy + qz*qz)); r.put(0, 1, 2*(qx*qy - qw*qz)); r.put(0, 2, 2*(qx*qz + qw*qy))
2998
- r.put(1, 0, 2*(qx*qy + qw*qz)); r.put(1, 1, 1 - 2*(qx*qx + qz*qz)); r.put(1, 2, 2*(qy*qz - qw*qx))
2999
- r.put(2, 0, 2*(qx*qz - qw*qy)); r.put(2, 1, 2*(qy*qz + qw*qx)); r.put(2, 2, 1 - 2*(qx*qx + qy*qy))
3000
- return r
3001
- }
3002
-
3003
-
3004
2022
  // `sensorRotationMatrix` was removed in V7 — the rotation chain it
3005
2023
  // powered is no longer in the homography path. See iOS' equivalent.