react-native-image-stitcher 0.14.2 → 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 +131 -0
- package/README.md +35 -0
- package/RNImageStitcher.podspec +8 -7
- 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/camera/Camera.d.ts +31 -16
- package/dist/camera/Camera.js +10 -2
- 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/camera/Camera.tsx +43 -22
- 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
|
@@ -5,13 +5,19 @@
|
|
|
5
5
|
// docs/site-content/design/2026-04-30-realtime-incremental-stitching.md.
|
|
6
6
|
//
|
|
7
7
|
// What this file does:
|
|
8
|
-
// -
|
|
8
|
+
// - Orchestrates the batch-keyframe capture pipeline: a pose/flow
|
|
9
|
+
// keyframe gate selects frames, an OpenCVKeyframeCollector saves
|
|
10
|
+
// them as JPEGs, and finalize() hands the set to
|
|
11
|
+
// `OpenCVStitcher.stitchFramePaths` for one-shot stitching.
|
|
9
12
|
// - Subscribes to `RNSARSession`'s per-frame ARFrame delivery
|
|
10
13
|
// - Converts ARKit pose → yaw/pitch + horizontal FoV
|
|
11
|
-
// - Dispatches addPixelBuffer onto a serial queue
|
|
12
14
|
// - Posts state updates as Notifications so the RN bridge can fan
|
|
13
15
|
// them out to JS as device events
|
|
14
16
|
//
|
|
17
|
+
// History: the live incremental engines (`OpenCVIncrementalStitcher`
|
|
18
|
+
// hybrid + `OpenCVFirstWinsCylindricalStitcher` slit-scan) were
|
|
19
|
+
// archived; only the batch-keyframe path remains.
|
|
20
|
+
//
|
|
15
21
|
// What this file deliberately does NOT do:
|
|
16
22
|
// - Touch OpenCV / cv::* — that's confined to the .mm impl behind
|
|
17
23
|
// the ObjC interface.
|
|
@@ -43,12 +49,10 @@ import simd
|
|
|
43
49
|
import UIKit
|
|
44
50
|
import os.log
|
|
45
51
|
|
|
46
|
-
/// Public outcome enum
|
|
47
|
-
///
|
|
48
|
-
///
|
|
49
|
-
///
|
|
50
|
-
/// Values 7+ are emitted from the Swift gate layer (KeyframeGate),
|
|
51
|
-
/// not from the native engine. Keep numeric values in lockstep with
|
|
52
|
+
/// Public outcome enum so JS callers can inspect what happened to
|
|
53
|
+
/// each frame. Values 0-6 historically mirrored the (now-archived)
|
|
54
|
+
/// live-engine outcome codes; values 7+ are emitted from the Swift
|
|
55
|
+
/// gate layer (KeyframeGate). Keep numeric values in lockstep with
|
|
52
56
|
/// `IncrementalOutcome` in incremental.ts.
|
|
53
57
|
@objc public enum IncrementalOutcome: Int {
|
|
54
58
|
case acceptedHigh = 0
|
|
@@ -84,10 +88,10 @@ public final class IncrementalStateObject: NSObject {
|
|
|
84
88
|
@objc public let confidence: Double
|
|
85
89
|
@objc public let overlapPercent: Double
|
|
86
90
|
@objc public let processingMs: Double
|
|
87
|
-
/// V12.12 —
|
|
88
|
-
/// from
|
|
89
|
-
///
|
|
90
|
-
/// + host).
|
|
91
|
+
/// V12.12 — detected physical orientation. In the batch-keyframe
|
|
92
|
+
/// path it's derived from the saved keyframe's pixel dimensions
|
|
93
|
+
/// (imageWidth >= imageHeight). See incremental.ts for the full
|
|
94
|
+
/// rationale (single source of truth across SDK + host).
|
|
91
95
|
@objc public let isLandscape: Bool
|
|
92
96
|
/// V12.14.9 — running painted extent along the pan axis, in
|
|
93
97
|
/// canvas pixels. Combined with `panExtent`, lets the JS band
|
|
@@ -196,11 +200,9 @@ struct FinalizePayload {
|
|
|
196
200
|
|
|
197
201
|
// ── Stitcher mode selection ─────────────────────────────────
|
|
198
202
|
/// True if this finalize is the V16 batch-keyframe pipeline.
|
|
203
|
+
/// Always true now that the live engines are archived; retained
|
|
204
|
+
/// so the finalize closure's branch structure stays explicit.
|
|
199
205
|
let inBatchKeyframeMode: Bool
|
|
200
|
-
/// Hybrid engine ref (V14/V15 path). nil if batch mode.
|
|
201
|
-
let hybrid: OpenCVIncrementalStitcher?
|
|
202
|
-
/// First-wins cylindrical engine ref (V13 path). nil if batch mode.
|
|
203
|
-
let slit: OpenCVFirstWinsCylindricalStitcher?
|
|
204
206
|
/// V16 keyframe collector — owns the per-session JPEG sidecar
|
|
205
207
|
/// directory the post-stitch result references.
|
|
206
208
|
let collector: OpenCVKeyframeCollector?
|
|
@@ -250,35 +252,6 @@ public final class IncrementalStitcher: NSObject {
|
|
|
250
252
|
|
|
251
253
|
@objc public static let shared = IncrementalStitcher()
|
|
252
254
|
|
|
253
|
-
/// Underlying OpenCV engine. Created on `start`, torn down on
|
|
254
|
-
/// `finalize`/`reset`. Holding it across captures would keep the
|
|
255
|
-
/// 24 MB canvas allocated in idle.
|
|
256
|
-
///
|
|
257
|
-
/// V10: two engine variants exist behind one Swift wrapper.
|
|
258
|
-
/// `hybridEngine` (Samsung-style, full-frame cylindrical + OF) is
|
|
259
|
-
/// the default. `firstwinsEngine` (Apple-style, per-strip painting)
|
|
260
|
-
/// is opt-in via the JS `engine: 'slitscan'` start option. Only
|
|
261
|
-
/// one is non-nil at a time.
|
|
262
|
-
private var hybridEngine: OpenCVIncrementalStitcher?
|
|
263
|
-
private var firstwinsEngine: OpenCVFirstWinsCylindricalStitcher?
|
|
264
|
-
|
|
265
|
-
/// V15.0b — true once we've forwarded the latched plane transform
|
|
266
|
-
/// from RNSARSession to the slit-scan engine. Reset on
|
|
267
|
-
/// every start() so the next capture re-propagates. We only
|
|
268
|
-
/// forward once per capture: the plane transform is latched
|
|
269
|
-
/// (RNSARSession ignores subsequent ARKit refinements),
|
|
270
|
-
/// so re-propagating each frame is wasted work.
|
|
271
|
-
private var havePropagatedPlane: Bool = false
|
|
272
|
-
|
|
273
|
-
/// Convenience: read the active engine's accepted count. Used by
|
|
274
|
-
/// the per-frame state event.
|
|
275
|
-
private var engineAcceptedCount: Int {
|
|
276
|
-
return hybridEngine?.acceptedCount ?? firstwinsEngine?.acceptedCount ?? 0
|
|
277
|
-
}
|
|
278
|
-
private var anyEngineActive: Bool {
|
|
279
|
-
return hybridEngine != nil || firstwinsEngine != nil
|
|
280
|
-
}
|
|
281
|
-
|
|
282
255
|
/// Serial queue for the heavy per-frame work. ARSession delegate
|
|
283
256
|
/// only dispatches a pre-allocated cv::Mat onto this queue — the
|
|
284
257
|
/// pixel buffer itself is consumed before return.
|
|
@@ -299,13 +272,13 @@ public final class IncrementalStitcher: NSObject {
|
|
|
299
272
|
qos: .userInitiated
|
|
300
273
|
)
|
|
301
274
|
|
|
302
|
-
/// 2026-05-16 —
|
|
303
|
-
///
|
|
304
|
-
///
|
|
305
|
-
///
|
|
306
|
-
///
|
|
307
|
-
///
|
|
308
|
-
///
|
|
275
|
+
/// 2026-05-16 — dedicated queue for the async refinement run
|
|
276
|
+
/// driven by the explicit JS `refinePanorama(...)` API. Kept
|
|
277
|
+
/// SEPARATE from `workQueue` so the next capture's start/
|
|
278
|
+
/// consumeFrame path isn't gated on a prior 2-5 s cv::Stitcher
|
|
279
|
+
/// run — the design doc explicitly calls out "operator can
|
|
280
|
+
/// continue browsing / starting another capture during
|
|
281
|
+
/// refinement".
|
|
309
282
|
///
|
|
310
283
|
/// Serial: at most one refinement runs at a time (the design's
|
|
311
284
|
/// "cancellation semantics if a new capture starts mid-refine"
|
|
@@ -393,16 +366,14 @@ public final class IncrementalStitcher: NSObject {
|
|
|
393
366
|
/// capture. See KeyframeGate.swift for the full rationale.
|
|
394
367
|
private let keyframeGate = KeyframeGate()
|
|
395
368
|
|
|
396
|
-
/// V16 Phase 1 —
|
|
397
|
-
///
|
|
398
|
-
///
|
|
399
|
-
///
|
|
400
|
-
///
|
|
401
|
-
///
|
|
402
|
-
///
|
|
403
|
-
///
|
|
404
|
-
/// defers all stitching until shutter release so the global-
|
|
405
|
-
/// stage quality wins (BA, multi-band) become available.
|
|
369
|
+
/// V16 Phase 1 — the batch-keyframe pipeline (the only surviving
|
|
370
|
+
/// engine mode): we accumulate the gate-accepted frames as on-disk
|
|
371
|
+
/// JPEGs + their poses, then on `finalize` hand them to
|
|
372
|
+
/// `OpenCVStitcher.stitchFramePaths` (the full feature-matched
|
|
373
|
+
/// BA + ExposureCompensator + GraphCutSeamFinder + MultiBandBlender
|
|
374
|
+
/// pipeline) for one-shot stitching. This defers all stitching
|
|
375
|
+
/// until shutter release so the global-stage quality wins (BA,
|
|
376
|
+
/// multi-band) become available.
|
|
406
377
|
private var batchKeyframeMode: Bool = false
|
|
407
378
|
private var keyframeCollector: OpenCVKeyframeCollector?
|
|
408
379
|
/// Poses recorded 1:1 with `keyframeCollector`'s saved JPEGs.
|
|
@@ -626,29 +597,6 @@ public final class IncrementalStitcher: NSObject {
|
|
|
626
597
|
]
|
|
627
598
|
}
|
|
628
599
|
|
|
629
|
-
/// 2026-05-16 — realtime+batch fusion (Option A) path derivation.
|
|
630
|
-
/// Given the live panorama path (which finalize() wrote inside
|
|
631
|
-
/// the app sandbox tmp or a host-supplied location), pick a path
|
|
632
|
-
/// for the refined output. Pattern:
|
|
633
|
-
///
|
|
634
|
-
/// /…/RNImageStitcherIncremental-<uuid>.jpg
|
|
635
|
-
/// → /…/RNImageStitcherIncremental-<uuid>-refined.jpg
|
|
636
|
-
///
|
|
637
|
-
/// Same directory keeps cleanup discoverable (delete both when
|
|
638
|
-
/// the audit is discarded). Different name avoids racing the
|
|
639
|
-
/// host UI that may still be reading the live file as the
|
|
640
|
-
/// refinement is writing.
|
|
641
|
-
fileprivate static func refinedPathFromLive(livePath: String) -> String {
|
|
642
|
-
let ns = livePath as NSString
|
|
643
|
-
let dir = ns.deletingLastPathComponent
|
|
644
|
-
let base = (ns.lastPathComponent as NSString).deletingPathExtension
|
|
645
|
-
let ext = (ns.lastPathComponent as NSString).pathExtension
|
|
646
|
-
let refinedName = ext.isEmpty
|
|
647
|
-
? "\(base)-refined"
|
|
648
|
-
: "\(base)-refined.\(ext)"
|
|
649
|
-
return (dir as NSString).appendingPathComponent(refinedName)
|
|
650
|
-
}
|
|
651
|
-
|
|
652
600
|
// ── Native orientation classifier ────────────────────────────────
|
|
653
601
|
//
|
|
654
602
|
// AR-STITCHING-TWO-MODES — see memory/ar-stitching-two-modes.md
|
|
@@ -781,44 +729,16 @@ public final class IncrementalStitcher: NSObject {
|
|
|
781
729
|
resolvedOrientation,
|
|
782
730
|
nativeResult.hadFrame ? Int32(1) : Int32(0),
|
|
783
731
|
engineMode)
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
// legacy 'firstwins' / 'firstwins-zoomed' / 'slitscan' modes.
|
|
792
|
-
let normalisedMode: String
|
|
793
|
-
switch engineMode {
|
|
794
|
-
case "hybrid": normalisedMode = "hybrid"
|
|
795
|
-
case "batch-keyframe":
|
|
796
|
-
// V16 Phase 1 — new mode. Skips the live incremental
|
|
797
|
-
// engines entirely; KeyframeGate accumulates accepted
|
|
798
|
-
// frames as JPEGs, on finalize OpenCVStitcher does the
|
|
799
|
-
// full-pipeline stitch.
|
|
800
|
-
normalisedMode = "batch-keyframe"
|
|
801
|
-
case "slitscan-rotate", "firstwins-rectilinear":
|
|
802
|
-
normalisedMode = "slitscan-rotate"
|
|
803
|
-
case "slitscan-both":
|
|
804
|
-
normalisedMode = "slitscan-both"
|
|
805
|
-
case "firstwins", "firstwins-zoomed", "slitscan":
|
|
806
|
-
NSLog("[V15-bridge] DEPRECATED engine '\(engineMode)' — using slitscan-both")
|
|
807
|
-
normalisedMode = "slitscan-both"
|
|
808
|
-
default:
|
|
809
|
-
NSLog("[V15-bridge] unknown engine '\(engineMode)' — using slitscan-both")
|
|
810
|
-
normalisedMode = "slitscan-both"
|
|
732
|
+
// Engine mode: only the batch-keyframe pipeline survives. The
|
|
733
|
+
// live incremental engines ('hybrid', 'slitscan-*', and the
|
|
734
|
+
// legacy 'firstwins*' aliases) were archived — any non-batch
|
|
735
|
+
// `engineMode` now falls back to batch-keyframe so existing JS
|
|
736
|
+
// callers keep working.
|
|
737
|
+
if engineMode != "batch-keyframe" {
|
|
738
|
+
NSLog("[bridge] DEPRECATED engine '\(engineMode)' — live engines archived, using batch-keyframe")
|
|
811
739
|
}
|
|
812
740
|
|
|
813
|
-
|
|
814
|
-
let useFirstwinsClass = normalisedMode.hasPrefix("slitscan")
|
|
815
|
-
|
|
816
|
-
// Build the V15 config: factory default for the mode, then apply
|
|
817
|
-
// JS-side overrides.
|
|
818
|
-
let config = RLISStitcherConfig(forMode: normalisedMode)
|
|
819
|
-
Self.applyConfigOverrides(configOverrides, to: config)
|
|
820
|
-
|
|
821
|
-
if useBatchKeyframe {
|
|
741
|
+
do {
|
|
822
742
|
// V16 Phase 1 — no live engine; spin up a keyframe
|
|
823
743
|
// collector that saves accepted frames to disk under
|
|
824
744
|
// Library/AppSupport/Captures/{uuid}/. On finalize
|
|
@@ -838,8 +758,6 @@ public final class IncrementalStitcher: NSObject {
|
|
|
838
758
|
// sensor orientation for the batch-keyframe path so the
|
|
839
759
|
// pose's intrinsics (which describe the unrotated
|
|
840
760
|
// 1920×1440 sensor) match the saved-image dimensions.
|
|
841
|
-
// The slit-scan and hybrid engines continue to receive
|
|
842
|
-
// `frameRotationDegrees` unchanged.
|
|
843
761
|
self.keyframeRotationDegrees = 0
|
|
844
762
|
// 2026-05-18 (Issue #1a fix) — keyframe EXIF Orientation
|
|
845
763
|
// is hardcoded to 6 ("rotate 90° CW for display") regardless
|
|
@@ -912,40 +830,10 @@ public final class IncrementalStitcher: NSObject {
|
|
|
912
830
|
// option value.
|
|
913
831
|
self.batchImuTranslationMetres = 0.0
|
|
914
832
|
self.batchKeyframeMode = true
|
|
915
|
-
self.hybridEngine = nil
|
|
916
|
-
self.firstwinsEngine = nil
|
|
917
833
|
os_log(.fault, log: Self.diagLog,
|
|
918
834
|
"[V16-batch-keyframe] start mode=batch-keyframe rotation=0 (was %d, forced to 0 to match pose intrinsics) sessionDir=%{public}@",
|
|
919
835
|
frameRotationDegrees,
|
|
920
836
|
self.keyframeCollector?.sessionDir ?? "(nil)")
|
|
921
|
-
} else if useFirstwinsClass {
|
|
922
|
-
// Slit-scan engine always uses rectilinear in V15
|
|
923
|
-
// (firstwins-cylindrical and firstwins-zoomed modes were
|
|
924
|
-
// removed; their behaviour is unused).
|
|
925
|
-
self.firstwinsEngine = OpenCVFirstWinsCylindricalStitcher(
|
|
926
|
-
composeWidth: composeWidth,
|
|
927
|
-
composeHeight: composeHeight,
|
|
928
|
-
canvasWidth: canvasWidth,
|
|
929
|
-
canvasHeight: canvasHeight,
|
|
930
|
-
featherPx: featherPx,
|
|
931
|
-
frameRotationDegrees: frameRotationDegrees,
|
|
932
|
-
useRectilinear: true
|
|
933
|
-
)
|
|
934
|
-
self.firstwinsEngine?.setConfig(config)
|
|
935
|
-
self.hybridEngine = nil
|
|
936
|
-
self.batchKeyframeMode = false
|
|
937
|
-
} else {
|
|
938
|
-
self.hybridEngine = OpenCVIncrementalStitcher(
|
|
939
|
-
composeWidth: composeWidth,
|
|
940
|
-
composeHeight: composeHeight,
|
|
941
|
-
canvasWidth: canvasWidth,
|
|
942
|
-
canvasHeight: canvasHeight,
|
|
943
|
-
featherPx: featherPx,
|
|
944
|
-
frameRotationDegrees: frameRotationDegrees
|
|
945
|
-
)
|
|
946
|
-
self.hybridEngine?.setConfig(config)
|
|
947
|
-
self.firstwinsEngine = nil
|
|
948
|
-
self.batchKeyframeMode = false
|
|
949
837
|
}
|
|
950
838
|
self.isRunning = true
|
|
951
839
|
// F8.3 — enable the Frame Processor plugin's producer-thread
|
|
@@ -958,8 +846,6 @@ public final class IncrementalStitcher: NSObject {
|
|
|
958
846
|
self.acceptsSinceSnapshot = 0
|
|
959
847
|
self.droppedBackpressure = 0
|
|
960
848
|
self.lastState = nil
|
|
961
|
-
// V15.0b — re-arm plane propagation for the new capture.
|
|
962
|
-
self.havePropagatedPlane = false
|
|
963
849
|
// V13.0c.1 — reset translation diagnostic state for the
|
|
964
850
|
// new capture. First-frame translation will be captured
|
|
965
851
|
// on the next consumeFrame call.
|
|
@@ -1044,6 +930,21 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1044
930
|
} else {
|
|
1045
931
|
self.keyframeGate.flowMaxTranslationCm = 0.0
|
|
1046
932
|
}
|
|
933
|
+
// Wall-clock keyframe-interval budget, in MILLISECONDS. When
|
|
934
|
+
// > 0, the gate force-accepts a frame once this much time has
|
|
935
|
+
// elapsed since the last accepted keyframe (applies to BOTH
|
|
936
|
+
// Pose and Flow strategies). Passed straight through — the JS
|
|
937
|
+
// value is already in ms (no cm→m style conversion). Clamp to
|
|
938
|
+
// ≥ 0 (the bridge/C++ re-clamps too). Default 2000 ms when the
|
|
939
|
+
// key is absent (NOT 0 — time-budget acceptance is on by
|
|
940
|
+
// default so a stalled scan still advances).
|
|
941
|
+
if let v = configOverrides["maxKeyframeIntervalMs"] as? Double {
|
|
942
|
+
self.keyframeGate.maxKeyframeIntervalMs = max(0.0, v)
|
|
943
|
+
} else if let v = configOverrides["maxKeyframeIntervalMs"] as? Int {
|
|
944
|
+
self.keyframeGate.maxKeyframeIntervalMs = max(0.0, Double(v))
|
|
945
|
+
} else {
|
|
946
|
+
self.keyframeGate.maxKeyframeIntervalMs = 2000.0
|
|
947
|
+
}
|
|
1047
948
|
// V16 — novelty aggregation percentile. Clamp at start to
|
|
1048
949
|
// [0.5, 0.99]; the bridge re-clamps but matching it here
|
|
1049
950
|
// means our state stays in-range for logging. Default 0.85
|
|
@@ -1067,11 +968,12 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1067
968
|
}
|
|
1068
969
|
self.keyframeGate.reset()
|
|
1069
970
|
os_log(.fault, log: Self.diagLog,
|
|
1070
|
-
"[V16-keyframe] start gate enabled=%d strategy=%{public}@ thr=%.2f max=%d flow(maxCorners=%d quality=%.3f minDist=%.1f maxTransCm=%.1f pctile=%.2f evalEveryN=%d)",
|
|
971
|
+
"[V16-keyframe] start gate enabled=%d strategy=%{public}@ thr=%.2f max=%d maxKfIntervalMs=%.0f flow(maxCorners=%d quality=%.3f minDist=%.1f maxTransCm=%.1f pctile=%.2f evalEveryN=%d)",
|
|
1071
972
|
self.keyframeGate.enabled ? 1 : 0,
|
|
1072
973
|
self.keyframeGate.strategy == .flow ? "flow" : "pose",
|
|
1073
974
|
self.keyframeGate.overlapThreshold,
|
|
1074
975
|
self.keyframeGate.maxCount,
|
|
976
|
+
self.keyframeGate.maxKeyframeIntervalMs,
|
|
1075
977
|
self.keyframeGate.flowMaxCorners,
|
|
1076
978
|
self.keyframeGate.flowQualityLevel,
|
|
1077
979
|
self.keyframeGate.flowMinDistance,
|
|
@@ -1119,115 +1021,10 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1119
1021
|
/// AR delegate could deliver several more frames — each one
|
|
1120
1022
|
/// passed consumeFrame's `isRunning == true` check and got
|
|
1121
1023
|
/// ingested into the canvas, producing visible "phantom" frames
|
|
1122
|
-
///
|
|
1123
|
-
///
|
|
1124
|
-
/// field. Unrecognised keys are ignored. Values out of range are
|
|
1125
|
-
/// clamped silently (e.g. kPanAxisFractionRect outside [0.05, 0.90]).
|
|
1126
|
-
private static func applyConfigOverrides(_ overrides: [String: Any],
|
|
1127
|
-
to config: RLISStitcherConfig) {
|
|
1128
|
-
if let v = overrides["kPanAxisFractionRect"] as? Double {
|
|
1129
|
-
config.kPanAxisFractionRect = max(0.05, min(0.90, v))
|
|
1130
|
-
}
|
|
1131
|
-
if let v = overrides["kMinAcceptDeltaPx"] as? Int {
|
|
1132
|
-
config.kMinAcceptDeltaPx = max(0, min(500, v))
|
|
1133
|
-
}
|
|
1134
|
-
if let v = overrides["enableTriangulation"] as? Bool {
|
|
1135
|
-
config.enableTriangulation = v
|
|
1136
|
-
}
|
|
1137
|
-
if let v = overrides["enableTriAccumulator"] as? Bool {
|
|
1138
|
-
config.enableTriAccumulator = v
|
|
1139
|
-
}
|
|
1140
|
-
if let v = overrides["enable1dNcc"] as? Bool {
|
|
1141
|
-
config.enable1dNcc = v
|
|
1142
|
-
}
|
|
1143
|
-
if let v = overrides["nccSearchRadius1d"] as? Int {
|
|
1144
|
-
config.nccSearchRadius1d = max(5, min(60, v))
|
|
1145
|
-
}
|
|
1146
|
-
if let v = overrides["enable2dNcc"] as? Bool {
|
|
1147
|
-
config.enable2dNcc = v
|
|
1148
|
-
}
|
|
1149
|
-
if let v = overrides["enableRansacHomography"] as? Bool {
|
|
1150
|
-
config.enableRansacHomography = v
|
|
1151
|
-
}
|
|
1152
|
-
if let v = overrides["paintMode"] as? String {
|
|
1153
|
-
switch v {
|
|
1154
|
-
case "FirstPaintedWins": config.paintMode = .firstPaintedWins
|
|
1155
|
-
case "FeatherBlend": config.paintMode = .featherBlend
|
|
1156
|
-
default: break
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
if let v = overrides["hybridProjection"] as? String {
|
|
1160
|
-
switch v {
|
|
1161
|
-
case "Cylindrical": config.hybridProjection = .cylindrical
|
|
1162
|
-
case "Planar": config.hybridProjection = .planar
|
|
1163
|
-
default: break
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
if let v = overrides["useDetectedPlane"] as? Bool {
|
|
1167
|
-
config.useDetectedPlane = v
|
|
1168
|
-
}
|
|
1169
|
-
if let v = overrides["sliverPosition"] as? String {
|
|
1170
|
-
switch v {
|
|
1171
|
-
case "Center": config.sliverPosition = .center
|
|
1172
|
-
case "Bottom": config.sliverPosition = .bottom
|
|
1173
|
-
case "Top": config.sliverPosition = .top
|
|
1174
|
-
default: break
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
if let v = overrides["firstFrameFullFrame"] as? Bool {
|
|
1178
|
-
config.firstFrameFullFrame = v
|
|
1179
|
-
}
|
|
1180
|
-
// V15.0d new overrides.
|
|
1181
|
-
if let v = overrides["planeSource"] as? String {
|
|
1182
|
-
switch v {
|
|
1183
|
-
case "Disabled": config.planeSource = .disabled
|
|
1184
|
-
case "ARKitDetected": config.planeSource = .arKitDetected
|
|
1185
|
-
case "Virtual": config.planeSource = .virtual
|
|
1186
|
-
default: break
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
if let v = overrides["virtualPlaneDepthMeters"] as? Double {
|
|
1190
|
-
config.virtualPlaneDepthMeters = max(0.3, min(5.0, v))
|
|
1191
|
-
}
|
|
1192
|
-
if let v = overrides["arkitPlaneAlignmentThreshold"] as? Double {
|
|
1193
|
-
config.arkitPlaneAlignmentThreshold = max(0.0, min(1.0, v))
|
|
1194
|
-
}
|
|
1195
|
-
if let v = overrides["planeProjectionStyle"] as? String {
|
|
1196
|
-
switch v {
|
|
1197
|
-
case "Trapezoidal": config.planeProjectionStyle = .trapezoidal
|
|
1198
|
-
case "Rectified": config.planeProjectionStyle = .rectified
|
|
1199
|
-
default: break
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
if let v = overrides["nccSearchMargin2d"] as? Int {
|
|
1203
|
-
config.nccSearchMargin2d = max(4, min(60, v))
|
|
1204
|
-
}
|
|
1205
|
-
if let v = overrides["nccConfidenceThreshold2d"] as? Double {
|
|
1206
|
-
config.nccConfidenceThreshold2d = max(0.30, min(0.99, v))
|
|
1207
|
-
}
|
|
1208
|
-
if let v = overrides["enableNcc2dEmaSmoothing"] as? Bool {
|
|
1209
|
-
config.enableNcc2dEmaSmoothing = v
|
|
1210
|
-
}
|
|
1211
|
-
if let v = overrides["ncc2dEmaAlpha"] as? Double {
|
|
1212
|
-
config.ncc2dEmaAlpha = max(0.05, min(0.95, v))
|
|
1213
|
-
}
|
|
1214
|
-
if let v = overrides["enableNcc2dPanAxisLock"] as? Bool {
|
|
1215
|
-
config.enableNcc2dPanAxisLock = v
|
|
1216
|
-
}
|
|
1217
|
-
if let v = overrides["ncc2dCrossAxisLockPx"] as? Int {
|
|
1218
|
-
config.ncc2dCrossAxisLockPx = max(0, min(30, v))
|
|
1219
|
-
}
|
|
1220
|
-
// Propagate the alignment threshold to the AR session so its
|
|
1221
|
-
// didAdd / didUpdate filter uses the operator-chosen value.
|
|
1222
|
-
// (planeAlignmentThreshold is a Float on the AR session.)
|
|
1223
|
-
RNSARSession.shared.planeAlignmentThreshold =
|
|
1224
|
-
Float(config.arkitPlaneAlignmentThreshold)
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
/// after the user thought they had released. The engine refs
|
|
1228
|
-
/// and isRunning flag are now flipped SYNCHRONOUSLY here so the
|
|
1024
|
+
/// after the user thought they had released. The batch-keyframe
|
|
1025
|
+
/// state and isRunning flag are flipped SYNCHRONOUSLY here so the
|
|
1229
1026
|
/// AR delegate's very next consumeFrame sees isRunning=false.
|
|
1230
|
-
/// The work-queue body just runs the
|
|
1027
|
+
/// The work-queue body just runs the one-shot stitch.
|
|
1231
1028
|
@objc public func finalize(
|
|
1232
1029
|
toPath outputPath: String,
|
|
1233
1030
|
jpegQuality: Int,
|
|
@@ -1247,8 +1044,6 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1247
1044
|
// collapses the race: any consumeFrame entered after this
|
|
1248
1045
|
// line sees isRunning=false at its very first guard.
|
|
1249
1046
|
stateLock.lock()
|
|
1250
|
-
let hybrid = self.hybridEngine
|
|
1251
|
-
let slit = self.firstwinsEngine
|
|
1252
1047
|
let inBatchKeyframeMode = self.batchKeyframeMode
|
|
1253
1048
|
let collector = self.keyframeCollector
|
|
1254
1049
|
let paths = self.keyframePaths
|
|
@@ -1306,8 +1101,6 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1306
1101
|
// features. Drop the closure-capture to avoid a compile
|
|
1307
1102
|
// warning; ARKit pose data is preserved on the ivar regardless.
|
|
1308
1103
|
_ = self.keyframePoses
|
|
1309
|
-
self.hybridEngine = nil
|
|
1310
|
-
self.firstwinsEngine = nil
|
|
1311
1104
|
self.batchKeyframeMode = false
|
|
1312
1105
|
self.keyframeCollector = nil
|
|
1313
1106
|
self.keyframePaths = []
|
|
@@ -1373,8 +1166,6 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1373
1166
|
cleaned: cleaned,
|
|
1374
1167
|
q: q,
|
|
1375
1168
|
inBatchKeyframeMode: inBatchKeyframeMode,
|
|
1376
|
-
hybrid: hybrid,
|
|
1377
|
-
slit: slit,
|
|
1378
1169
|
collector: collector,
|
|
1379
1170
|
paths: paths,
|
|
1380
1171
|
batchWarperType: batchWarperType,
|
|
@@ -1617,9 +1408,9 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1617
1408
|
// ARKit poses are still saved alongside each
|
|
1618
1409
|
// keyframe (`keyframePoses`) for future
|
|
1619
1410
|
// pose-driven investigation as a separate
|
|
1620
|
-
// workstream
|
|
1621
|
-
//
|
|
1622
|
-
// the hot path.
|
|
1411
|
+
// workstream, but the pose-driven stitch method
|
|
1412
|
+
// has since been archived; the feature-matched
|
|
1413
|
+
// path is the only one on the hot path.
|
|
1623
1414
|
// V16 Phase 1b.fix3 — pass the EXIF Orientation
|
|
1624
1415
|
// tag derived from `frameRotationDegrees`.
|
|
1625
1416
|
// V16 Phase 1b.fix8 (C2) — read knobs from
|
|
@@ -1736,81 +1527,11 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1736
1527
|
} catch let stitchErr as NSError {
|
|
1737
1528
|
completion(nil, stitchErr)
|
|
1738
1529
|
}
|
|
1739
|
-
} else if let hybrid = payload.hybrid {
|
|
1740
|
-
let snap = try hybrid.finalize(atPath: payload.cleaned, jpegQuality: payload.q)
|
|
1741
|
-
completion([
|
|
1742
|
-
"panoramaPath": snap.panoramaPath,
|
|
1743
|
-
"width": snap.width,
|
|
1744
|
-
"height": snap.height,
|
|
1745
|
-
"acceptedCount": snap.acceptedCount,
|
|
1746
|
-
"droppedBackpressure": payload.drops,
|
|
1747
|
-
], nil)
|
|
1748
|
-
// 2026-05-16 — realtime+batch fusion (Option A
|
|
1749
|
-
// "Replace on completion") hook. The live
|
|
1750
|
-
// panorama has been written and the JS finalize
|
|
1751
|
-
// promise has resolved; now fire-and-forget an
|
|
1752
|
-
// async refinement over the hybrid engine's
|
|
1753
|
-
// accepted keyframes.
|
|
1754
|
-
//
|
|
1755
|
-
// Constraints honoured here (per the design doc
|
|
1756
|
-
// and the prompt's "Constraints" list):
|
|
1757
|
-
// 1. Hybrid realtime engine is NOT modified —
|
|
1758
|
-
// `OpenCVIncrementalStitcher.mm` stays
|
|
1759
|
-
// untouched; we only consult the existing
|
|
1760
|
-
// keyframe-path ivar that finalize() already
|
|
1761
|
-
// snapshotted into `payload.paths`.
|
|
1762
|
-
// 2. NO-OP when keyframes are not on disk.
|
|
1763
|
-
// Today's hybrid engine does NOT save per-
|
|
1764
|
-
// frame JPEGs (only batch-keyframe mode does
|
|
1765
|
-
// via OpenCVKeyframeCollector), so
|
|
1766
|
-
// `payload.paths` is empty for the hybrid
|
|
1767
|
-
// branch. `runHybridAutoRefine` detects
|
|
1768
|
-
// that and emits `isRefining=false` without
|
|
1769
|
-
// running cv::Stitcher. When a future change
|
|
1770
|
-
// hooks the hybrid engine up to a keyframe
|
|
1771
|
-
// collector, the same code path lights up
|
|
1772
|
-
// automatically.
|
|
1773
|
-
// 3. Refinement is fire-and-forget — finalize's
|
|
1774
|
-
// promise has ALREADY been resolved above.
|
|
1775
|
-
//
|
|
1776
|
-
// Capture-list discipline (C2 invariant — see the
|
|
1777
|
-
// file-top markers). No `self.*` references allowed
|
|
1778
|
-
// here; we route the dispatch through the type
|
|
1779
|
-
// (IncrementalStitcher.shared) so the
|
|
1780
|
-
// closure captures only value-typed locals + the
|
|
1781
|
-
// class type itself. shared is a process-wide
|
|
1782
|
-
// singleton (initialised once at module load),
|
|
1783
|
-
// so this is lifecycle-safe.
|
|
1784
|
-
let refinedOut = Self.refinedPathFromLive(
|
|
1785
|
-
livePath: snap.panoramaPath
|
|
1786
|
-
)
|
|
1787
|
-
let pathsForRefine = payload.paths // empty for hybrid today
|
|
1788
|
-
let capOri = payload.captureOrientation
|
|
1789
|
-
let warper = payload.batchWarperType
|
|
1790
|
-
let blender = payload.batchBlenderType
|
|
1791
|
-
let seam = payload.batchSeamFinderType
|
|
1792
|
-
let inscribed = payload.batchEnableInscribedRectCrop
|
|
1793
|
-
IncrementalStitcher.shared.refineQueue.async {
|
|
1794
|
-
IncrementalStitcher.shared.runHybridAutoRefine(
|
|
1795
|
-
framePaths: pathsForRefine,
|
|
1796
|
-
refinedOutputPath: refinedOut,
|
|
1797
|
-
captureOrientation: capOri,
|
|
1798
|
-
warperType: warper,
|
|
1799
|
-
blenderType: blender,
|
|
1800
|
-
seamFinderType: seam,
|
|
1801
|
-
useInscribedRectCrop: inscribed
|
|
1802
|
-
)
|
|
1803
|
-
}
|
|
1804
|
-
} else if let slit = payload.slit {
|
|
1805
|
-
let snap = try slit.finalize(atPath: payload.cleaned, jpegQuality: payload.q)
|
|
1806
|
-
completion([
|
|
1807
|
-
"panoramaPath": snap.panoramaPath,
|
|
1808
|
-
"width": snap.width,
|
|
1809
|
-
"height": snap.height,
|
|
1810
|
-
"acceptedCount": snap.acceptedCount,
|
|
1811
|
-
"droppedBackpressure": payload.drops,
|
|
1812
|
-
], nil)
|
|
1813
1530
|
} else {
|
|
1531
|
+
// Defensive: batch-keyframe is the only pipeline,
|
|
1532
|
+
// so a non-batch finalize means no capture was
|
|
1533
|
+
// active (start() was never called or already torn
|
|
1534
|
+
// down).
|
|
1814
1535
|
completion(nil, NSError(
|
|
1815
1536
|
domain: "RNImageStitcherIncremental",
|
|
1816
1537
|
code: 9002,
|
|
@@ -1825,14 +1546,12 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1825
1546
|
// MARK: C2-INVARIANT-END
|
|
1826
1547
|
}
|
|
1827
1548
|
|
|
1828
|
-
/// 2026-05-16 —
|
|
1829
|
-
///
|
|
1830
|
-
///
|
|
1549
|
+
/// 2026-05-16 — refine entry point. Runs the shared C++ stitcher
|
|
1550
|
+
/// over the supplied keyframe JPEGs and writes a refined panorama
|
|
1551
|
+
/// to `outputPath`.
|
|
1831
1552
|
///
|
|
1832
|
-
/// Called by
|
|
1833
|
-
///
|
|
1834
|
-
/// 2. `runHybridAutoRefine(...)` below, the fire-and-forget hook
|
|
1835
|
-
/// from `finalize()` for the hybrid-engine path.
|
|
1553
|
+
/// Called by the bridge layer (explicit JS `refinePanorama(...)`
|
|
1554
|
+
/// API) to re-stitch a saved keyframe set at higher quality.
|
|
1836
1555
|
///
|
|
1837
1556
|
/// Threading: the work itself dispatches onto `refineQueue` (NOT
|
|
1838
1557
|
/// `workQueue`). That keeps the per-capture path completely
|
|
@@ -2019,140 +1738,6 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2019
1738
|
}
|
|
2020
1739
|
}
|
|
2021
1740
|
|
|
2022
|
-
/// 2026-05-16 — realtime+batch fusion (Option A) auto-trigger.
|
|
2023
|
-
/// Called from `finalize()` immediately after the hybrid engine
|
|
2024
|
-
/// wrote its live panorama; fire-and-forget from finalize()'s
|
|
2025
|
-
/// perspective so the JS-side finalize promise resolves with the
|
|
2026
|
-
/// live result first. Then this method:
|
|
2027
|
-
///
|
|
2028
|
-
/// 1. Emits a state event with `isRefining = true` so the host
|
|
2029
|
-
/// can render its "Refining…" pill.
|
|
2030
|
-
/// 2. Runs `refinePanorama(framePaths, refinedOutputPath, ...)`.
|
|
2031
|
-
/// 3. On success: emits a state event with `isRefining = false`
|
|
2032
|
-
/// AND `refinedPanoramaPath = <path>` so the host swaps in
|
|
2033
|
-
/// the higher-quality output.
|
|
2034
|
-
/// 4. On failure: emits a state event with `isRefining = false`
|
|
2035
|
-
/// AND NO refined path. Host keeps showing the live
|
|
2036
|
-
/// panorama; the design doc's "Couldn't refine" toast UX is
|
|
2037
|
-
/// a follow-up.
|
|
2038
|
-
///
|
|
2039
|
-
/// No-op when `framePaths.count < 2` or any framePath is missing
|
|
2040
|
-
/// on disk. Hybrid-engine captures DO NOT today save per-frame
|
|
2041
|
-
/// JPEGs, so this method's most common call site (from finalize's
|
|
2042
|
-
/// hybrid branch) currently produces a no-op + isRefining=false
|
|
2043
|
-
/// emit — which is intentional (the design doc says "if
|
|
2044
|
-
/// keyframes are NOT on disk, the auto-trigger is a no-op").
|
|
2045
|
-
private func runHybridAutoRefine(
|
|
2046
|
-
framePaths: [String],
|
|
2047
|
-
refinedOutputPath: String,
|
|
2048
|
-
captureOrientation: String,
|
|
2049
|
-
warperType: String,
|
|
2050
|
-
blenderType: String,
|
|
2051
|
-
seamFinderType: String,
|
|
2052
|
-
useInscribedRectCrop: Bool
|
|
2053
|
-
) {
|
|
2054
|
-
if framePaths.count < 2 {
|
|
2055
|
-
os_log(.info, log: Self.diagLog,
|
|
2056
|
-
"[refine.auto] skipped: framePaths.count=%d (< 2 — hybrid engine retains no per-frame JPEGs)",
|
|
2057
|
-
framePaths.count)
|
|
2058
|
-
// Emit isRefining=false so any host that pre-seeded a
|
|
2059
|
-
// pill on finalize doesn't get stuck.
|
|
2060
|
-
self.emitRefinementState(isRefining: false, refinedPanoramaPath: nil)
|
|
2061
|
-
return
|
|
2062
|
-
}
|
|
2063
|
-
// Pre-flight existence check so we degrade gracefully when
|
|
2064
|
-
// a JPEG was unlinked between finalize and the dispatch
|
|
2065
|
-
// landing on refineQueue.
|
|
2066
|
-
let fm = FileManager.default
|
|
2067
|
-
for p in framePaths {
|
|
2068
|
-
let cleaned = p.hasPrefix("file://") ? String(p.dropFirst(7)) : p
|
|
2069
|
-
if !fm.fileExists(atPath: cleaned) {
|
|
2070
|
-
os_log(.info, log: Self.diagLog,
|
|
2071
|
-
"[refine.auto] skipped: missing keyframe %{public}@",
|
|
2072
|
-
cleaned)
|
|
2073
|
-
self.emitRefinementState(isRefining: false, refinedPanoramaPath: nil)
|
|
2074
|
-
return
|
|
2075
|
-
}
|
|
2076
|
-
}
|
|
2077
|
-
// Signal the pill on before the stitcher work begins. The
|
|
2078
|
-
// emit goes through the same notification channel as every
|
|
2079
|
-
// other state update; JS sees it asynchronously, which is
|
|
2080
|
-
// fine — operator UX wants the pill within a few hundred ms,
|
|
2081
|
-
// not synchronously with finalize's promise resolution.
|
|
2082
|
-
self.emitRefinementState(isRefining: true, refinedPanoramaPath: nil)
|
|
2083
|
-
let config: [String: Any] = [
|
|
2084
|
-
"warperType": warperType,
|
|
2085
|
-
"blenderType": blenderType,
|
|
2086
|
-
"seamFinderType": seamFinderType,
|
|
2087
|
-
"captureOrientation": captureOrientation,
|
|
2088
|
-
"useInscribedRectCrop": useInscribedRectCrop,
|
|
2089
|
-
"jpegQuality": 90,
|
|
2090
|
-
]
|
|
2091
|
-
self.refinePanorama(
|
|
2092
|
-
framePaths: framePaths,
|
|
2093
|
-
outputPath: refinedOutputPath,
|
|
2094
|
-
config: config
|
|
2095
|
-
) { [weak self] result, error in
|
|
2096
|
-
guard let self = self else { return }
|
|
2097
|
-
if let error = error {
|
|
2098
|
-
os_log(.fault, log: Self.diagLog,
|
|
2099
|
-
"[refine.auto] refinement failed: %{public}@ — leaving live output in place",
|
|
2100
|
-
error.localizedDescription)
|
|
2101
|
-
self.emitRefinementState(isRefining: false, refinedPanoramaPath: nil)
|
|
2102
|
-
return
|
|
2103
|
-
}
|
|
2104
|
-
let path = (result?["panoramaPath"] as? String) ?? refinedOutputPath
|
|
2105
|
-
os_log(.fault, log: Self.diagLog,
|
|
2106
|
-
"[refine.auto] success path=%{public}@",
|
|
2107
|
-
path)
|
|
2108
|
-
self.emitRefinementState(isRefining: false, refinedPanoramaPath: path)
|
|
2109
|
-
}
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
|
-
/// 2026-05-16 — emit a minimal state event carrying only the
|
|
2113
|
-
/// refinement-related fields. Mirrors the existing
|
|
2114
|
-
/// `emitBatchKeyframeAcceptedState` pattern: build a fresh
|
|
2115
|
-
/// IncrementalStateObject, then add the new optional fields
|
|
2116
|
-
/// directly to the userInfo dict so JS (which reads from the
|
|
2117
|
-
/// raw event payload) picks them up without a schema change in
|
|
2118
|
-
/// the Obj-C class.
|
|
2119
|
-
private func emitRefinementState(
|
|
2120
|
-
isRefining: Bool,
|
|
2121
|
-
refinedPanoramaPath: String?
|
|
2122
|
-
) {
|
|
2123
|
-
// Preserve the most-recent panoramaPath / dims / accepted
|
|
2124
|
-
// count so the JS subscriber's sticky-snapshot merge keeps
|
|
2125
|
-
// showing the live preview between the finalize and the
|
|
2126
|
-
// refined swap. All other fields default to "no-op" values.
|
|
2127
|
-
stateLock.lock()
|
|
2128
|
-
let prev = self.lastState
|
|
2129
|
-
stateLock.unlock()
|
|
2130
|
-
let state = IncrementalStateObject(
|
|
2131
|
-
panoramaPath: prev?.panoramaPath,
|
|
2132
|
-
width: prev?.width ?? 0,
|
|
2133
|
-
height: prev?.height ?? 0,
|
|
2134
|
-
acceptedCount: prev?.acceptedCount ?? 0,
|
|
2135
|
-
outcome: prev?.outcome ?? .acceptedHigh,
|
|
2136
|
-
confidence: prev?.confidence ?? 1.0,
|
|
2137
|
-
overlapPercent: prev?.overlapPercent ?? -1.0,
|
|
2138
|
-
processingMs: 0,
|
|
2139
|
-
isLandscape: prev?.isLandscape ?? false,
|
|
2140
|
-
paintedExtent: prev?.paintedExtent ?? 0,
|
|
2141
|
-
panExtent: prev?.panExtent ?? 0,
|
|
2142
|
-
keyframeMax: prev?.keyframeMax ?? 0
|
|
2143
|
-
)
|
|
2144
|
-
var dict = state.asDictionary()
|
|
2145
|
-
dict["isRefining"] = isRefining
|
|
2146
|
-
if let p = refinedPanoramaPath {
|
|
2147
|
-
dict["refinedPanoramaPath"] = p
|
|
2148
|
-
}
|
|
2149
|
-
NotificationCenter.default.post(
|
|
2150
|
-
name: .retailensIncrementalStateUpdate,
|
|
2151
|
-
object: nil,
|
|
2152
|
-
userInfo: dict
|
|
2153
|
-
)
|
|
2154
|
-
}
|
|
2155
|
-
|
|
2156
1741
|
/// v0.10.0 #15A — emit a refine-pipeline phase update on the same
|
|
2157
1742
|
/// `IncrementalStateUpdate` channel that carries `isRefining` /
|
|
2158
1743
|
/// `refinedPanoramaPath`. Five `stage` values fire across the
|
|
@@ -2172,10 +1757,9 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2172
1757
|
///
|
|
2173
1758
|
/// Reuses the existing channel (rather than introducing a new
|
|
2174
1759
|
/// device-event name) so the JS subscriber doesn't need to wire
|
|
2175
|
-
/// a second listener. The payload
|
|
2176
|
-
/// `
|
|
2177
|
-
///
|
|
2178
|
-
/// JS side keeps working untouched.
|
|
1760
|
+
/// a second listener. The payload preserves the lastState fields
|
|
1761
|
+
/// so the `isRefining` / `refinedPanoramaPath` sticky-merge logic
|
|
1762
|
+
/// on the JS side keeps working untouched.
|
|
2179
1763
|
private func emitRefineProgress(
|
|
2180
1764
|
stage: String,
|
|
2181
1765
|
fraction: Double,
|
|
@@ -2228,11 +1812,7 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2228
1812
|
// FIRST so any in-flight consumeFrame bails at its first
|
|
2229
1813
|
// guard. Then detach the AR consumer.
|
|
2230
1814
|
stateLock.lock()
|
|
2231
|
-
let hybrid = self.hybridEngine
|
|
2232
|
-
let slit = self.firstwinsEngine
|
|
2233
1815
|
let collector = self.keyframeCollector
|
|
2234
|
-
self.hybridEngine = nil
|
|
2235
|
-
self.firstwinsEngine = nil
|
|
2236
1816
|
self.keyframeCollector = nil
|
|
2237
1817
|
self.batchKeyframeMode = false
|
|
2238
1818
|
self.keyframePaths = []
|
|
@@ -2250,14 +1830,12 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2250
1830
|
self.keyframeGate.reset()
|
|
2251
1831
|
stateLock.unlock()
|
|
2252
1832
|
RNSARSession.shared.incrementalConsumer = nil
|
|
2253
|
-
//
|
|
2254
|
-
// ingest that's still
|
|
2255
|
-
//
|
|
2256
|
-
//
|
|
2257
|
-
//
|
|
1833
|
+
// Clean up on the work queue so we don't race with an in-flight
|
|
1834
|
+
// ingest that's still saving a keyframe. Cancel removes the
|
|
1835
|
+
// collector's session directory — the operator explicitly
|
|
1836
|
+
// aborted, so the saved JPEGs aren't worth keeping for
|
|
1837
|
+
// re-processing.
|
|
2258
1838
|
workQueue.async {
|
|
2259
|
-
hybrid?.reset()
|
|
2260
|
-
slit?.reset()
|
|
2261
1839
|
collector?.cleanup()
|
|
2262
1840
|
}
|
|
2263
1841
|
}
|
|
@@ -2467,7 +2045,10 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2467
2045
|
let acceptedCount: Int
|
|
2468
2046
|
stateLock.lock()
|
|
2469
2047
|
prev = self.lastState
|
|
2470
|
-
|
|
2048
|
+
// Batch-keyframe is the only running mode: the accepted count is
|
|
2049
|
+
// the gate's running keyframe tally (the live engines that used
|
|
2050
|
+
// to back `engineAcceptedCount` have been archived).
|
|
2051
|
+
acceptedCount = self.keyframeGate.acceptedCount
|
|
2471
2052
|
stateLock.unlock()
|
|
2472
2053
|
let overlapPercent = (decision.newContentFraction >= 0)
|
|
2473
2054
|
? (1.0 - decision.newContentFraction) * 100.0
|
|
@@ -2516,8 +2097,6 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2516
2097
|
// start/stop in flight — drop this frame.
|
|
2517
2098
|
return
|
|
2518
2099
|
}
|
|
2519
|
-
let hybrid = self.hybridEngine
|
|
2520
|
-
let slit = self.firstwinsEngine
|
|
2521
2100
|
let isRunning = self.isRunning
|
|
2522
2101
|
// V16 Phase 1 — capture batch-keyframe state under the lock so
|
|
2523
2102
|
// the work-queue closure (or the synchronous reject below)
|
|
@@ -2605,7 +2184,7 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2605
2184
|
let cadenceFires = ((self.consumeFrameCounter - 1) % evalCadence == 0)
|
|
2606
2185
|
let gateActive =
|
|
2607
2186
|
isRunning
|
|
2608
|
-
&&
|
|
2187
|
+
&& inBatchKeyframeMode
|
|
2609
2188
|
&& self.keyframeGate.enabled
|
|
2610
2189
|
let shouldEvaluateGate = gateActive && cadenceFires
|
|
2611
2190
|
// True iff the gate is active for this capture but we're
|
|
@@ -2649,10 +2228,9 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2649
2228
|
// text; accept-path decisions emit via the keyframeAccepted
|
|
2650
2229
|
// path and the JS state subscriber.
|
|
2651
2230
|
|
|
2652
|
-
// V16 Phase 1 — batch-keyframe is
|
|
2653
|
-
// (no engine pointer
|
|
2654
|
-
guard isRunning,
|
|
2655
|
-
(hybrid != nil || slit != nil || inBatchKeyframeMode)
|
|
2231
|
+
// V16 Phase 1 — batch-keyframe is the only running mode now
|
|
2232
|
+
// (no engine pointer; the collector and gate are active).
|
|
2233
|
+
guard isRunning, inBatchKeyframeMode
|
|
2656
2234
|
else { return }
|
|
2657
2235
|
|
|
2658
2236
|
// Surface the gate's reject decision (if any) outside the lock.
|
|
@@ -2664,34 +2242,11 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2664
2242
|
return
|
|
2665
2243
|
}
|
|
2666
2244
|
|
|
2667
|
-
//
|
|
2668
|
-
//
|
|
2669
|
-
//
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
iz: Float(pose.qz), r: Float(pose.qw)
|
|
2673
|
-
)
|
|
2674
|
-
let (yaw, pitch) = Self.yawPitch(from: q)
|
|
2675
|
-
|
|
2676
|
-
// Both FoVs from physical camera intrinsics. Passing the
|
|
2677
|
-
// PHYSICAL vertical FoV (vs deriving it from compose aspect
|
|
2678
|
-
// inside the engine) is what fixes the v1/v2 "only left-to-
|
|
2679
|
-
// right portrait pan responds" bug — the engine's overlap
|
|
2680
|
-
// gate compared world-pitch against a compose-aspect-derived
|
|
2681
|
-
// vertical FoV that didn't match the actual camera, so most
|
|
2682
|
-
// top-to-bottom pans fell outside the 30-70% window.
|
|
2683
|
-
let fovHRad = 2.0 * atan(Double(pose.imageWidth) / (2.0 * pose.fx))
|
|
2684
|
-
let fovVRad = 2.0 * atan(Double(pose.imageHeight) / (2.0 * pose.fy))
|
|
2685
|
-
let fovHDeg = fovHRad * 180.0 / .pi
|
|
2686
|
-
let fovVDeg = fovVRad * 180.0 / .pi
|
|
2687
|
-
|
|
2688
|
-
let trackingPoor = (pose.trackingState != .tracking)
|
|
2689
|
-
|
|
2690
|
-
// V11 Gap #27: dispatch the heavy pipeline (engine.ingest +
|
|
2691
|
-
// optional snapshot) to the work queue. Earlier versions
|
|
2692
|
-
// ran the full ~70 ms accept inside the AR delegate thread,
|
|
2693
|
-
// blocking ARKit's 16 ms inter-frame budget and causing
|
|
2694
|
-
// ~4-5 frames to be dropped during each accept.
|
|
2245
|
+
// V11 Gap #27: dispatch the heavy keyframe-save work to the
|
|
2246
|
+
// work queue. Earlier versions ran the full ~70 ms accept
|
|
2247
|
+
// inside the AR delegate thread, blocking ARKit's 16 ms
|
|
2248
|
+
// inter-frame budget and causing ~4-5 frames to be dropped
|
|
2249
|
+
// during each accept.
|
|
2695
2250
|
//
|
|
2696
2251
|
// Backpressure: if the work queue is already busy with a
|
|
2697
2252
|
// previous frame, drop this one (don't queue up — that'd
|
|
@@ -2721,8 +2276,8 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2721
2276
|
// CVPixelBufferCreate + memcpy gives us a fully-owned copy
|
|
2722
2277
|
// that ARC alone governs. ~1-2 ms cost on iPhone 16 Pro
|
|
2723
2278
|
// (10 MB memcpy at memory bandwidth ~10 GB/s). Fixes the
|
|
2724
|
-
// crash for
|
|
2725
|
-
//
|
|
2279
|
+
// crash for the batch-keyframe save path, which dispatches
|
|
2280
|
+
// via consumeFrame.
|
|
2726
2281
|
guard let pbCopy = Self.deepCopyPixelBuffer(pixelBuffer) else {
|
|
2727
2282
|
// Allocation failure — drop the frame. Extremely rare;
|
|
2728
2283
|
// would only happen under genuine OOM.
|
|
@@ -2794,126 +2349,8 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2794
2349
|
"[V16-batch-keyframe] saveKeyframe failed: %{public}@",
|
|
2795
2350
|
err.localizedDescription)
|
|
2796
2351
|
}
|
|
2797
|
-
return
|
|
2798
|
-
}
|
|
2799
|
-
|
|
2800
|
-
// V15.0b — if a vertical plane has just been detected and
|
|
2801
|
-
// we haven't propagated it to the slit-scan engine yet,
|
|
2802
|
-
// do so now. Propagated only once per latched plane;
|
|
2803
|
-
// RNSARSession resets on stop().
|
|
2804
|
-
if !self.havePropagatedPlane,
|
|
2805
|
-
let plane = RNSARSession.shared.planeTransformFlat() {
|
|
2806
|
-
slit?.setPlaneTransformFlat(plane)
|
|
2807
|
-
self.havePropagatedPlane = true
|
|
2808
|
-
// V15.0c.4 — fault log so we can see the propagation
|
|
2809
|
-
// moment without rate-limit drops.
|
|
2810
|
-
os_log(.fault, log: Self.diagLog,
|
|
2811
|
-
"[V15.0b-plane] bridge propagated plane to slit-scan engine (one-shot per capture)")
|
|
2812
|
-
}
|
|
2813
|
-
|
|
2814
|
-
let telemetry: RLISFrameTelemetry
|
|
2815
|
-
if let hybrid = hybrid {
|
|
2816
|
-
telemetry = hybrid.ingest(
|
|
2817
|
-
pixelBuffer: pbCopy, qx: pose.qx, qy: pose.qy, qz: pose.qz, qw: pose.qw,
|
|
2818
|
-
tx: pose.tx, ty: pose.ty, tz: pose.tz,
|
|
2819
|
-
fx: pose.fx, fy: pose.fy, cx: pose.cx, cy: pose.cy,
|
|
2820
|
-
imageWidth: pose.imageWidth, imageHeight: pose.imageHeight,
|
|
2821
|
-
yaw: yaw, pitch: pitch,
|
|
2822
|
-
fovHorizDegrees: fovHDeg, fovVertDegrees: fovVDeg,
|
|
2823
|
-
trackingPoor: trackingPoor
|
|
2824
|
-
)
|
|
2825
|
-
} else if let slit = slit {
|
|
2826
|
-
// V13.0e — slit-scan engine consumes tx/ty/tz for
|
|
2827
|
-
// ORB-triangulation-based depth estimation and per-frame
|
|
2828
|
-
// translation parallax correction. Hybrid passes them
|
|
2829
|
-
// for API symmetry; only the slit engine uses them.
|
|
2830
|
-
telemetry = slit.ingest(
|
|
2831
|
-
pixelBuffer: pbCopy, qx: pose.qx, qy: pose.qy, qz: pose.qz, qw: pose.qw,
|
|
2832
|
-
tx: pose.tx, ty: pose.ty, tz: pose.tz,
|
|
2833
|
-
fx: pose.fx, fy: pose.fy, cx: pose.cx, cy: pose.cy,
|
|
2834
|
-
imageWidth: pose.imageWidth, imageHeight: pose.imageHeight,
|
|
2835
|
-
yaw: yaw, pitch: pitch,
|
|
2836
|
-
fovHorizDegrees: fovHDeg, fovVertDegrees: fovVDeg,
|
|
2837
|
-
trackingPoor: trackingPoor
|
|
2838
|
-
)
|
|
2839
|
-
} else {
|
|
2840
|
-
return
|
|
2841
|
-
}
|
|
2842
|
-
|
|
2843
|
-
self.processIngestResult(
|
|
2844
|
-
telemetry: telemetry, hybrid: hybrid, slit: slit)
|
|
2845
|
-
}
|
|
2846
|
-
}
|
|
2847
|
-
|
|
2848
|
-
/// Pulled out of consumeFrame so the work-queue closure stays
|
|
2849
|
-
/// readable. Same flow: build state, optionally snapshot, post
|
|
2850
|
-
/// notification.
|
|
2851
|
-
private func processIngestResult(
|
|
2852
|
-
telemetry: RLISFrameTelemetry,
|
|
2853
|
-
hybrid: OpenCVIncrementalStitcher?,
|
|
2854
|
-
slit: OpenCVFirstWinsCylindricalStitcher?
|
|
2855
|
-
) {
|
|
2856
|
-
var snapshotPath: String?
|
|
2857
|
-
var snapW = 0, snapH = 0
|
|
2858
|
-
let outcome = IncrementalOutcome(rawValue: telemetry.outcome.rawValue)
|
|
2859
|
-
?? .skippedTrackingPoor
|
|
2860
|
-
|
|
2861
|
-
let isAccept = (telemetry.outcome == .acceptedHigh ||
|
|
2862
|
-
telemetry.outcome == .acceptedMedium)
|
|
2863
|
-
|
|
2864
|
-
if isAccept {
|
|
2865
|
-
self.acceptsSinceSnapshot += 1
|
|
2866
|
-
if self.acceptsSinceSnapshot >= self.snapshotEveryNAccepts {
|
|
2867
|
-
self.acceptsSinceSnapshot = 0
|
|
2868
|
-
do {
|
|
2869
|
-
let snap: RLISSnapshot
|
|
2870
|
-
if let hybrid = hybrid {
|
|
2871
|
-
snap = try hybrid.snapshot(
|
|
2872
|
-
withJpegQuality: self.snapshotJpegQuality)
|
|
2873
|
-
} else {
|
|
2874
|
-
snap = try slit!.snapshot(
|
|
2875
|
-
withJpegQuality: self.snapshotJpegQuality)
|
|
2876
|
-
}
|
|
2877
|
-
snapshotPath = snap.panoramaPath
|
|
2878
|
-
snapW = snap.width
|
|
2879
|
-
snapH = snap.height
|
|
2880
|
-
} catch {
|
|
2881
|
-
// Silently dropping a snapshot is fine — next
|
|
2882
|
-
// accept will retry.
|
|
2883
|
-
}
|
|
2884
2352
|
}
|
|
2885
2353
|
}
|
|
2886
|
-
|
|
2887
|
-
// V16 — pass the gate's max keyframe count when the gate is
|
|
2888
|
-
// active so JS can render "Keyframes: n/max". Zero signals
|
|
2889
|
-
// "gate disabled" to the JS pill.
|
|
2890
|
-
let kfMax = self.keyframeGate.enabled ? self.keyframeGate.maxCount : 0
|
|
2891
|
-
let state = IncrementalStateObject(
|
|
2892
|
-
panoramaPath: snapshotPath,
|
|
2893
|
-
width: snapW,
|
|
2894
|
-
height: snapH,
|
|
2895
|
-
acceptedCount: hybrid?.acceptedCount ?? slit?.acceptedCount ?? 0,
|
|
2896
|
-
outcome: outcome,
|
|
2897
|
-
confidence: telemetry.confidence,
|
|
2898
|
-
overlapPercent: telemetry.overlapPercent,
|
|
2899
|
-
processingMs: telemetry.processingMs,
|
|
2900
|
-
isLandscape: telemetry.isLandscape,
|
|
2901
|
-
paintedExtent: telemetry.paintedExtent,
|
|
2902
|
-
panExtent: telemetry.panExtent,
|
|
2903
|
-
keyframeMax: kfMax
|
|
2904
|
-
)
|
|
2905
|
-
stateLock.lock()
|
|
2906
|
-
self.lastState = state
|
|
2907
|
-
stateLock.unlock()
|
|
2908
|
-
|
|
2909
|
-
// Emit always — JS may want to drive UX on rejects too.
|
|
2910
|
-
// NotificationCenter is thread-agnostic; the bridge converts
|
|
2911
|
-
// it to a main-thread RN event.
|
|
2912
|
-
NotificationCenter.default.post(
|
|
2913
|
-
name: .retailensIncrementalStateUpdate,
|
|
2914
|
-
object: nil,
|
|
2915
|
-
userInfo: state.asDictionary()
|
|
2916
|
-
)
|
|
2917
2354
|
}
|
|
2918
2355
|
|
|
2919
2356
|
// ── Debug log file ──────────────────────────────────────────────
|
|
@@ -2947,21 +2384,6 @@ public final class IncrementalStitcher: NSObject {
|
|
|
2947
2384
|
|
|
2948
2385
|
// ── Helpers ─────────────────────────────────────────────────────
|
|
2949
2386
|
|
|
2950
|
-
/// Extract yaw (rotation about world Y) and pitch (rotation about
|
|
2951
|
-
/// camera X) from an ARKit camera quaternion. Numerically stable
|
|
2952
|
-
/// for camera orientations the user holds in practice — straight
|
|
2953
|
-
/// up/down is gimbal-locked but a shelf-audit user is never there.
|
|
2954
|
-
private static func yawPitch(from q: simd_quatf) -> (Double, Double) {
|
|
2955
|
-
// Apply the quaternion to ARKit's camera-forward vector
|
|
2956
|
-
// (-Z in camera frame) to get the camera-forward in world.
|
|
2957
|
-
// Yaw is the angle of the projection onto the X-Z plane;
|
|
2958
|
-
// pitch is the elevation angle.
|
|
2959
|
-
let forward = simd_act(q, simd_float3(0, 0, -1))
|
|
2960
|
-
let yaw = Double(atan2(forward.x, -forward.z))
|
|
2961
|
-
let pitch = Double(asin(forward.y))
|
|
2962
|
-
return (yaw, pitch)
|
|
2963
|
-
}
|
|
2964
|
-
|
|
2965
2387
|
/// 2026-05-22 (audit F2) — stitchMode auto-resolver. Port of
|
|
2966
2388
|
/// Android's `resolveStitchModeAuto` (IncrementalStitcher.kt:1727).
|
|
2967
2389
|
/// Picks PANORAMA vs SCANS based on the magnitude ratio of
|