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.
- package/CHANGELOG.md +160 -0
- package/README.md +35 -0
- package/RNImageStitcher.podspec +8 -1
- package/android/build.gradle +0 -16
- package/android/src/main/cpp/CMakeLists.txt +2 -63
- package/android/src/main/cpp/image_stitcher_jni.cpp +14 -0
- package/android/src/main/cpp/keyframe_gate_jni.cpp +13 -0
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +285 -3
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +180 -1162
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +29 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +0 -4
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +13 -64
- package/cpp/keyframe_gate.cpp +82 -23
- package/cpp/keyframe_gate.hpp +31 -2
- package/cpp/stitcher.cpp +208 -28
- package/cpp/tests/CMakeLists.txt +18 -12
- package/cpp/tests/keyframe_timebudget_test.cpp +65 -0
- package/cpp/tests/warp_guard_test.cpp +48 -0
- package/cpp/warp_guard.hpp +41 -0
- package/dist/ar/useARSession.d.ts +9 -0
- package/dist/ar/useARSession.js +24 -2
- package/dist/camera/Camera.d.ts +31 -16
- package/dist/camera/Camera.js +27 -4
- package/dist/camera/CaptureStitchStatsToast.d.ts +15 -2
- package/dist/camera/CaptureStitchStatsToast.js +27 -7
- package/dist/camera/PanoramaSettings.d.ts +10 -223
- package/dist/camera/PanoramaSettings.js +6 -28
- package/dist/camera/PanoramaSettingsBridge.d.ts +1 -24
- package/dist/camera/PanoramaSettingsBridge.js +3 -102
- package/dist/camera/PanoramaSettingsModal.js +7 -1
- package/dist/camera/buildPanoramaInitialSettings.d.ts +11 -0
- package/dist/camera/buildPanoramaInitialSettings.js +4 -0
- package/dist/camera/cameraErrorMessages.d.ts +32 -0
- package/dist/camera/cameraErrorMessages.js +53 -0
- package/dist/camera/selectCaptureDevice.d.ts +5 -1
- package/dist/camera/selectCaptureDevice.js +22 -2
- package/dist/camera/useCapture.js +38 -0
- package/dist/index.d.ts +5 -8
- package/dist/index.js +11 -34
- package/dist/stitching/incremental.d.ts +1 -117
- package/dist/stitching/stitchVideo.d.ts +0 -35
- package/dist/types.d.ts +0 -87
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +96 -674
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +9 -12
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +14 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +7 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +6 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +3 -3
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +28 -60
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +180 -921
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +10 -35
- package/ios/Sources/RNImageStitcher/Stitcher.swift +84 -35
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +13 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +132 -5
- package/package.json +3 -2
- package/src/ar/useARSession.ts +35 -5
- package/src/camera/Camera.tsx +63 -24
- package/src/camera/CaptureStitchStatsToast.tsx +58 -14
- package/src/camera/PanoramaSettings.ts +16 -289
- package/src/camera/PanoramaSettingsBridge.ts +3 -114
- package/src/camera/PanoramaSettingsModal.tsx +14 -1
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +3 -188
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +41 -0
- package/src/camera/__tests__/cameraErrorMessages.test.ts +76 -0
- package/src/camera/__tests__/selectCaptureDevice.test.ts +33 -0
- package/src/camera/buildPanoramaInitialSettings.ts +17 -0
- package/src/camera/cameraErrorMessages.ts +84 -0
- package/src/camera/selectCaptureDevice.ts +28 -3
- package/src/camera/useCapture.ts +44 -1
- package/src/index.ts +11 -40
- package/src/stitching/incremental.ts +3 -140
- package/src/stitching/stitchVideo.ts +0 -26
- package/src/types.ts +0 -95
- package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +0 -227
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +0 -1081
- package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +0 -103
- package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +0 -256
- package/cpp/stitcher_frame_jsi.cpp +0 -214
- package/cpp/stitcher_frame_jsi.hpp +0 -108
- package/cpp/stitcher_proxy_jsi.cpp +0 -109
- package/cpp/stitcher_proxy_jsi.hpp +0 -46
- package/cpp/stitcher_worklet_dispatch.cpp +0 -103
- package/cpp/stitcher_worklet_dispatch.hpp +0 -71
- package/cpp/stitcher_worklet_registry.cpp +0 -91
- package/cpp/stitcher_worklet_registry.hpp +0 -146
- package/cpp/tests/stitcher_worklet_registry_test.cpp +0 -195
- package/dist/stitching/IncrementalStitcherView.d.ts +0 -41
- package/dist/stitching/IncrementalStitcherView.js +0 -157
- package/dist/stitching/StitcherWorkletRegistry.d.ts +0 -117
- package/dist/stitching/StitcherWorkletRegistry.js +0 -78
- package/dist/stitching/ensureStitcherProxyInstalled.d.ts +0 -8
- package/dist/stitching/ensureStitcherProxyInstalled.js +0 -81
- package/dist/stitching/useFrameProcessor.d.ts +0 -119
- package/dist/stitching/useFrameProcessor.js +0 -196
- package/dist/stitching/useFrameStream.d.ts +0 -34
- package/dist/stitching/useFrameStream.js +0 -234
- package/dist/stitching/useThrottledFrameProcessor.d.ts +0 -33
- package/dist/stitching/useThrottledFrameProcessor.js +0 -132
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +0 -474
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +0 -1328
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +0 -103
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +0 -3285
- package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +0 -128
- package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +0 -313
- package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +0 -185
- package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
- package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
- package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +0 -42
- package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +0 -160
- package/src/stitching/IncrementalStitcherView.tsx +0 -198
- package/src/stitching/StitcherWorkletRegistry.ts +0 -156
- package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +0 -176
- package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +0 -94
- package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +0 -178
- package/src/stitching/ensureStitcherProxyInstalled.ts +0 -141
- package/src/stitching/useFrameProcessor.ts +0 -226
- package/src/stitching/useFrameStream.ts +0 -271
- 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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
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
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
// engine
|
|
349
|
-
// cv::Stitcher (via the JNI shim) at finalize.
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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 0 — time-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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
|
895
|
-
//
|
|
896
|
-
//
|
|
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
|
-
|
|
916
|
-
//
|
|
917
|
-
//
|
|
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 (
|
|
1021
|
-
//
|
|
1022
|
-
// paths and the cv::Stitcher pipeline runs at finalize
|
|
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
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
promise.resolve(
|
|
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
|
|
2184
|
-
//
|
|
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
|
-
//
|
|
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.
|