react-native-image-stitcher 0.1.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 +96 -0
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +189 -0
- package/RNImageStitcher.podspec +76 -0
- package/android/build.gradle +224 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/cpp/CMakeLists.txt +124 -0
- package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
- package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
- package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
- package/cpp/ar_frame_pose.h +63 -0
- package/cpp/keyframe_gate.cpp +927 -0
- package/cpp/keyframe_gate.hpp +240 -0
- package/cpp/stitcher.cpp +2207 -0
- package/cpp/stitcher.hpp +275 -0
- package/dist/ar/useARSession.d.ts +102 -0
- package/dist/ar/useARSession.js +133 -0
- package/dist/camera/ARCameraView.d.ts +93 -0
- package/dist/camera/ARCameraView.js +170 -0
- package/dist/camera/Camera.d.ts +134 -0
- package/dist/camera/Camera.js +688 -0
- package/dist/camera/CameraShutter.d.ts +80 -0
- package/dist/camera/CameraShutter.js +237 -0
- package/dist/camera/CameraView.d.ts +65 -0
- package/dist/camera/CameraView.js +117 -0
- package/dist/camera/CaptureControlsBar.d.ts +87 -0
- package/dist/camera/CaptureControlsBar.js +82 -0
- package/dist/camera/CaptureHeader.d.ts +62 -0
- package/dist/camera/CaptureHeader.js +81 -0
- package/dist/camera/CapturePreview.d.ts +70 -0
- package/dist/camera/CapturePreview.js +188 -0
- package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
- package/dist/camera/CaptureStatusOverlay.js +326 -0
- package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
- package/dist/camera/CaptureThumbnailStrip.js +177 -0
- package/dist/camera/IncrementalPanGuide.d.ts +83 -0
- package/dist/camera/IncrementalPanGuide.js +267 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
- package/dist/camera/PanoramaBandOverlay.js +399 -0
- package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
- package/dist/camera/PanoramaConfirmModal.js +128 -0
- package/dist/camera/PanoramaGuidance.d.ts +79 -0
- package/dist/camera/PanoramaGuidance.js +246 -0
- package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
- package/dist/camera/PanoramaSettingsModal.js +611 -0
- package/dist/camera/ViewportCropOverlay.d.ts +46 -0
- package/dist/camera/ViewportCropOverlay.js +67 -0
- package/dist/camera/useCapture.d.ts +111 -0
- package/dist/camera/useCapture.js +160 -0
- package/dist/camera/useDeviceOrientation.d.ts +48 -0
- package/dist/camera/useDeviceOrientation.js +131 -0
- package/dist/camera/useVideoCapture.d.ts +79 -0
- package/dist/camera/useVideoCapture.js +151 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +39 -0
- package/dist/quality/normaliseOrientation.d.ts +36 -0
- package/dist/quality/normaliseOrientation.js +62 -0
- package/dist/quality/runQualityCheck.d.ts +41 -0
- package/dist/quality/runQualityCheck.js +98 -0
- package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
- package/dist/sensors/useIMUTranslationGate.js +235 -0
- package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
- package/dist/stitching/IncrementalStitcherView.js +157 -0
- package/dist/stitching/incremental.d.ts +930 -0
- package/dist/stitching/incremental.js +133 -0
- package/dist/stitching/stitchFrames.d.ts +55 -0
- package/dist/stitching/stitchFrames.js +56 -0
- package/dist/stitching/stitchVideo.d.ts +119 -0
- package/dist/stitching/stitchVideo.js +57 -0
- package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
- package/dist/stitching/useIncrementalJSDriver.js +199 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
- package/dist/stitching/useIncrementalStitcher.js +172 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +15 -0
- package/ios/Package.swift +72 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
- package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
- package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
- package/package.json +73 -0
- package/react-native.config.js +34 -0
- package/scripts/opencv-version.txt +1 -0
- package/scripts/postinstall-fetch-binaries.js +286 -0
- package/src/ar/useARSession.ts +210 -0
- package/src/camera/.gitkeep +0 -0
- package/src/camera/ARCameraView.tsx +256 -0
- package/src/camera/Camera.tsx +1053 -0
- package/src/camera/CameraShutter.tsx +292 -0
- package/src/camera/CameraView.tsx +157 -0
- package/src/camera/CaptureControlsBar.tsx +204 -0
- package/src/camera/CaptureHeader.tsx +184 -0
- package/src/camera/CapturePreview.tsx +318 -0
- package/src/camera/CaptureStatusOverlay.tsx +391 -0
- package/src/camera/CaptureThumbnailStrip.tsx +277 -0
- package/src/camera/IncrementalPanGuide.tsx +328 -0
- package/src/camera/PanoramaBandOverlay.tsx +498 -0
- package/src/camera/PanoramaConfirmModal.tsx +206 -0
- package/src/camera/PanoramaGuidance.tsx +327 -0
- package/src/camera/PanoramaSettingsModal.tsx +1357 -0
- package/src/camera/ViewportCropOverlay.tsx +81 -0
- package/src/camera/useCapture.ts +279 -0
- package/src/camera/useDeviceOrientation.ts +140 -0
- package/src/camera/useVideoCapture.ts +236 -0
- package/src/index.ts +53 -0
- package/src/quality/.gitkeep +0 -0
- package/src/quality/normaliseOrientation.ts +79 -0
- package/src/quality/runQualityCheck.ts +131 -0
- package/src/sensors/useIMUTranslationGate.ts +347 -0
- package/src/stitching/.gitkeep +0 -0
- package/src/stitching/IncrementalStitcherView.tsx +198 -0
- package/src/stitching/incremental.ts +1021 -0
- package/src/stitching/stitchFrames.ts +88 -0
- package/src/stitching/stitchVideo.ts +153 -0
- package/src/stitching/useIncrementalJSDriver.ts +273 -0
- package/src/stitching/useIncrementalStitcher.ts +252 -0
- package/src/types.ts +78 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// useIMUTranslationGate.ts — JS-side IMU translation tracker for the
|
|
4
|
+
// non-AR translation-warning banner + (optional) gate force-accept.
|
|
5
|
+
//
|
|
6
|
+
// 2026-05-17 (Issue #4-A v3): rewritten on top of `expo-sensors`
|
|
7
|
+
// `DeviceMotion` (which returns gravity-subtracted linear
|
|
8
|
+
// acceleration via Apple's CoreMotion fusion on iOS and Android's
|
|
9
|
+
// `TYPE_LINEAR_ACCELERATION` sensor on Android — both significantly
|
|
10
|
+
// less noisy than raw accel + JS-side IIR gravity subtraction).
|
|
11
|
+
// Tracks a SINGLE device-frame axis (device-X — the phone's lateral /
|
|
12
|
+
// short side) rather than the 3D translation magnitude.
|
|
13
|
+
//
|
|
14
|
+
// Why exists
|
|
15
|
+
// ──────────
|
|
16
|
+
//
|
|
17
|
+
// In non-AR mode the SDK has no ARSession pose stream, so the shared
|
|
18
|
+
// C++ `KeyframeGate`'s translation-budget feature stays at zero and
|
|
19
|
+
// never trips. This hook fills the gap on the JS side and emits a
|
|
20
|
+
// budget-crossed callback the host can wire to either:
|
|
21
|
+
//
|
|
22
|
+
// (a) `markNextFrameAsLastKeyframe()` — tell the gate "force-accept
|
|
23
|
+
// the next frame regardless of overlap", so the trailing-edge
|
|
24
|
+
// frame still lands when the operator translates instead of
|
|
25
|
+
// rotates.
|
|
26
|
+
//
|
|
27
|
+
// (b) A user-facing warning banner ("Rotate the camera instead of
|
|
28
|
+
// moving it sideways" — see AuditCaptureScreen).
|
|
29
|
+
//
|
|
30
|
+
// AuditCaptureScreen wires it to both.
|
|
31
|
+
//
|
|
32
|
+
// Why device-X (the shorter side)
|
|
33
|
+
// ───────────────────────────────
|
|
34
|
+
//
|
|
35
|
+
// We track motion ALONG the pan axis (the direction the operator is
|
|
36
|
+
// supposed to be rotating-through but might be translating-through
|
|
37
|
+
// instead) because translation orthogonal to the pan axis is
|
|
38
|
+
// acceptable — vertical translation while panning horizontally in
|
|
39
|
+
// portrait, for example, doesn't cause horizontal parallax.
|
|
40
|
+
//
|
|
41
|
+
// The pan axis maps to device-X in BOTH supported orientations
|
|
42
|
+
// (per memory/ar-stitching-two-modes.md):
|
|
43
|
+
//
|
|
44
|
+
// Portrait + horizontal pan: device-X = user-left/right = pan axis.
|
|
45
|
+
// Landscape + vertical pan: device-X has rotated 90° into the
|
|
46
|
+
// user's up/down direction = pan axis.
|
|
47
|
+
//
|
|
48
|
+
// The lateral axis of the phone (its short side) always aligns with
|
|
49
|
+
// the pan direction in either supported mode, so a single-axis
|
|
50
|
+
// tracker works without needing to know which orientation we're in.
|
|
51
|
+
//
|
|
52
|
+
// Drift mitigation
|
|
53
|
+
// ────────────────
|
|
54
|
+
//
|
|
55
|
+
// `DeviceMotion.acceleration` (gravity removed in native code via
|
|
56
|
+
// IMU fusion) has a noise floor roughly 30-50 % lower than what the
|
|
57
|
+
// previous raw-accel + JS IIR pipeline produced. Single-axis math
|
|
58
|
+
// further reduces apparent drift by ≈√3 vs the prior 3D magnitude.
|
|
59
|
+
// Together they should keep the typical "stationary phone" reading
|
|
60
|
+
// below ~5-10 cm even after several seconds.
|
|
61
|
+
//
|
|
62
|
+
// Anchor resets happen at (a) recording start (via the host calling
|
|
63
|
+
// `resetAnchor()` from handleHoldStart) and (b) every accepted
|
|
64
|
+
// keyframe — these bound the per-interval drift window to typically
|
|
65
|
+
// 0.3-2 s.
|
|
66
|
+
//
|
|
67
|
+
// What we no longer do
|
|
68
|
+
// ────────────────────
|
|
69
|
+
//
|
|
70
|
+
// - JS-side 1-pole IIR for gravity subtraction (native API gives
|
|
71
|
+
// gravity-subtracted accel directly).
|
|
72
|
+
// - 3D vector magnitude (now single device-X axis).
|
|
73
|
+
// - Velocity damping (kept as a safety net at 5%/sample so a
|
|
74
|
+
// persistent noise-floor offset doesn't slowly drift the axis —
|
|
75
|
+
// low cost, high robustness).
|
|
76
|
+
|
|
77
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
78
|
+
import { DeviceMotion } from 'expo-sensors';
|
|
79
|
+
import type { DeviceMotionMeasurement } from 'expo-sensors';
|
|
80
|
+
|
|
81
|
+
// expo-sensors doesn't re-export Subscription from its index, but
|
|
82
|
+
// `addListener` returns one — use the inferred return type so we
|
|
83
|
+
// don't have to chase the right deep-import path.
|
|
84
|
+
type DeviceMotionSubscription = ReturnType<typeof DeviceMotion.addListener>;
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
export interface UseIMUTranslationGateOptions {
|
|
88
|
+
/**
|
|
89
|
+
* Whether the gate is engaged. Pass `false` to skip the subscription
|
|
90
|
+
* entirely — useful when the host is in AR mode (where the gate
|
|
91
|
+
* gets pose-derived translation natively). Hot-toggleable;
|
|
92
|
+
* subscribing/unsubscribing is cheap.
|
|
93
|
+
*/
|
|
94
|
+
enabled: boolean;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Translation budget in METRES along the device-X (pan) axis.
|
|
98
|
+
* When the integrated displacement magnitude exceeds this since
|
|
99
|
+
* the last accept, the hook fires `onBudgetExceeded`. Default
|
|
100
|
+
* 0.40 m / 40 cm (80 % of the 50 cm default
|
|
101
|
+
* `flowMaxTranslationCm`). Caller typically passes
|
|
102
|
+
* `panoramaSettings.flowMaxTranslationCm * 0.8 / 100`.
|
|
103
|
+
*/
|
|
104
|
+
budgetMeters?: number;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Update interval in MILLISECONDS for the DeviceMotion sensor.
|
|
108
|
+
* Default 20 ms ≈ 50 Hz. Lower (faster sampling) = more accurate
|
|
109
|
+
* integration; higher = lower CPU + battery. Matches the previous
|
|
110
|
+
* raw-accel cadence so reset/integrate behaviour stays comparable.
|
|
111
|
+
*/
|
|
112
|
+
sampleIntervalMs?: number;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fired exactly once per "budget crossing" — i.e., when the
|
|
116
|
+
* running translation along device-X crosses `budgetMeters` from
|
|
117
|
+
* below. The host is responsible for both (a) calling
|
|
118
|
+
* `IncrementalStitcher.markNextFrameAsLastKeyframe()` and
|
|
119
|
+
* (b) invoking the returned `resetAnchor()` once the next
|
|
120
|
+
* keyframe actually accepts, so the integrator restarts from zero.
|
|
121
|
+
*/
|
|
122
|
+
onBudgetExceeded: () => void;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 2026-05-18 (Issue #4 investigation) — when true, log every Nth
|
|
126
|
+
* accelerometer sample (default N=20 ≈ 400 ms at 50 Hz) showing
|
|
127
|
+
* the current `acceleration.x`, accumulated `posX`, and time
|
|
128
|
+
* since anchor reset. Helps diagnose drift behaviour vs real
|
|
129
|
+
* translation magnitude in field testing. Defaults to false —
|
|
130
|
+
* production captures stay quiet.
|
|
131
|
+
*/
|
|
132
|
+
debug?: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
export interface UseIMUTranslationGateReturn {
|
|
137
|
+
/**
|
|
138
|
+
* Reset the running translation to zero. Call this at recording
|
|
139
|
+
* start AND after each confirmed keyframe accept — the typical
|
|
140
|
+
* wiring is to subscribe to `IncrementalStateUpdate` and
|
|
141
|
+
* call `resetAnchor()` from inside the listener AND from the host's
|
|
142
|
+
* `handleHoldStart`.
|
|
143
|
+
*/
|
|
144
|
+
resetAnchor: () => void;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Read the current running displacement along device-X in METRES.
|
|
148
|
+
* Returns the absolute value (sign is uninteresting — either left
|
|
149
|
+
* or right counts the same toward the budget).
|
|
150
|
+
* Useful for the on-screen debug HUD ("translation since last
|
|
151
|
+
* accept: 0.07 m"). Not exposed via state — host polls if needed.
|
|
152
|
+
*/
|
|
153
|
+
getCurrentTranslationM: () => number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* IMU-based translation tracker — single-axis (device-X / pan axis),
|
|
159
|
+
* fused IMU via `expo-sensors` `DeviceMotion`. See file header for
|
|
160
|
+
* algorithm + rationale. No platform-specific code; the underlying
|
|
161
|
+
* native fusion is platform-aware (CoreMotion on iOS, fused
|
|
162
|
+
* `TYPE_LINEAR_ACCELERATION` on Android).
|
|
163
|
+
*/
|
|
164
|
+
export function useIMUTranslationGate(
|
|
165
|
+
options: UseIMUTranslationGateOptions,
|
|
166
|
+
): UseIMUTranslationGateReturn {
|
|
167
|
+
const {
|
|
168
|
+
enabled,
|
|
169
|
+
budgetMeters = 0.40,
|
|
170
|
+
sampleIntervalMs = 20,
|
|
171
|
+
onBudgetExceeded,
|
|
172
|
+
debug = false,
|
|
173
|
+
} = options;
|
|
174
|
+
|
|
175
|
+
// Integrator state, kept in refs so the listener can write without
|
|
176
|
+
// re-creating its closure on every render.
|
|
177
|
+
// ─ velX : velocity along device-X (m/s)
|
|
178
|
+
// ─ posX : position along device-X (m)
|
|
179
|
+
// ─ lastMs: epoch ms of the previous sample (for dt)
|
|
180
|
+
// ─ budgetCrossed: debounce flag — clears on resetAnchor
|
|
181
|
+
// ─ sampleCount: rolling counter for debug log throttle
|
|
182
|
+
// ─ anchorMs: timestamp of the most recent resetAnchor (or first
|
|
183
|
+
// sample) — gives "time since anchor" in debug output
|
|
184
|
+
const velX = useRef<number>(0);
|
|
185
|
+
const posX = useRef<number>(0);
|
|
186
|
+
const lastMs = useRef<number>(0);
|
|
187
|
+
const budgetCrossed = useRef<boolean>(false);
|
|
188
|
+
const sampleCount = useRef<number>(0);
|
|
189
|
+
const anchorMs = useRef<number>(0);
|
|
190
|
+
|
|
191
|
+
// Keep the callback in a ref so we don't tear down + re-subscribe
|
|
192
|
+
// on every prop change. React idiom for stable callback identity.
|
|
193
|
+
const onBudgetExceededRef = useRef(onBudgetExceeded);
|
|
194
|
+
useEffect(() => { onBudgetExceededRef.current = onBudgetExceeded; },
|
|
195
|
+
[onBudgetExceeded]);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (debug) {
|
|
199
|
+
// eslint-disable-next-line no-console
|
|
200
|
+
console.log(
|
|
201
|
+
`[IMUTransGate] effect re-run: enabled=${enabled} `
|
|
202
|
+
+ `budget=${budgetMeters.toFixed(2)}m sampleIntervalMs=${sampleIntervalMs}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (!enabled) return;
|
|
206
|
+
|
|
207
|
+
// Lock in the DeviceMotion update rate. Other expo-sensors
|
|
208
|
+
// consumers in the SDK can override later; the LAST setter wins
|
|
209
|
+
// per Expo's docs, which is fine because our budget logic
|
|
210
|
+
// tolerates a wide range of cadences.
|
|
211
|
+
DeviceMotion.setUpdateInterval(sampleIntervalMs);
|
|
212
|
+
|
|
213
|
+
// Reset state on (re-)engage so the first measurement after
|
|
214
|
+
// enabled-toggles-true doesn't carry stale velocity from a
|
|
215
|
+
// previous capture session.
|
|
216
|
+
velX.current = 0;
|
|
217
|
+
posX.current = 0;
|
|
218
|
+
lastMs.current = 0;
|
|
219
|
+
budgetCrossed.current = false;
|
|
220
|
+
sampleCount.current = 0;
|
|
221
|
+
anchorMs.current = Date.now();
|
|
222
|
+
|
|
223
|
+
// 2026-05-18 (Issue #3 diagnostics) — track whether we've ever
|
|
224
|
+
// received a non-null `acceleration` for this subscription. If
|
|
225
|
+
// the user reports "no logs" we can correlate with these
|
|
226
|
+
// start-of-subscription and first-real-data markers.
|
|
227
|
+
let everGotData = false;
|
|
228
|
+
let nullSampleCount = 0;
|
|
229
|
+
if (debug) {
|
|
230
|
+
// eslint-disable-next-line no-console
|
|
231
|
+
console.log('[IMUTransGate] subscribing to DeviceMotion');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const sub: DeviceMotionSubscription = DeviceMotion.addListener((m: DeviceMotionMeasurement) => {
|
|
235
|
+
const a = m.acceleration; // gravity-subtracted (m/s²)
|
|
236
|
+
if (!a) {
|
|
237
|
+
nullSampleCount += 1;
|
|
238
|
+
if (debug && nullSampleCount === 1) {
|
|
239
|
+
// eslint-disable-next-line no-console
|
|
240
|
+
console.log(
|
|
241
|
+
'[IMUTransGate] first sample: acceleration=null '
|
|
242
|
+
+ '(CoreMotion warming up; will retry on next sample)',
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (debug && nullSampleCount > 0 && nullSampleCount % 100 === 0) {
|
|
246
|
+
// eslint-disable-next-line no-console
|
|
247
|
+
console.log(
|
|
248
|
+
`[IMUTransGate] STILL receiving null acceleration after `
|
|
249
|
+
+ `${nullSampleCount} samples — sensor source may be broken`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return; // can be null briefly on cold start
|
|
253
|
+
}
|
|
254
|
+
if (debug && !everGotData) {
|
|
255
|
+
everGotData = true;
|
|
256
|
+
// eslint-disable-next-line no-console
|
|
257
|
+
console.log(
|
|
258
|
+
`[IMUTransGate] first real sample: ax=${a.x.toFixed(3)} `
|
|
259
|
+
+ `ay=${a.y.toFixed(3)} az=${a.z.toFixed(3)} m/s² `
|
|
260
|
+
+ `(after ${nullSampleCount} null sample(s))`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
if (lastMs.current === 0) {
|
|
265
|
+
lastMs.current = now;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const dt = Math.max(0, Math.min(0.1, (now - lastMs.current) / 1000.0));
|
|
269
|
+
lastMs.current = now;
|
|
270
|
+
if (dt === 0) return;
|
|
271
|
+
|
|
272
|
+
// Single-axis integration along device-X (lateral / pan axis).
|
|
273
|
+
// See file header for why device-X is the right axis in both
|
|
274
|
+
// portrait and landscape captures.
|
|
275
|
+
velX.current += a.x * dt;
|
|
276
|
+
velX.current *= 0.95; // 5%/sample damping — see file header
|
|
277
|
+
posX.current += velX.current * dt;
|
|
278
|
+
|
|
279
|
+
const mag = Math.abs(posX.current);
|
|
280
|
+
|
|
281
|
+
// 2026-05-18 (Issue #4 investigation) — debug-gated diagnostic
|
|
282
|
+
// log. Throttled to every 20th sample (~400 ms at 50 Hz) so
|
|
283
|
+
// the log isn't a firehose. When this runs and we still see
|
|
284
|
+
// posX hovering at < 5 cm during a real translation, the
|
|
285
|
+
// sensor source isn't capturing what we think it is.
|
|
286
|
+
sampleCount.current += 1;
|
|
287
|
+
if (debug && sampleCount.current % 20 === 0) {
|
|
288
|
+
const secs = (now - anchorMs.current) / 1000.0;
|
|
289
|
+
// eslint-disable-next-line no-console
|
|
290
|
+
console.log(
|
|
291
|
+
`[IMUTransGate] t+${secs.toFixed(2)}s `
|
|
292
|
+
+ `ax=${a.x.toFixed(3)}m/s² `
|
|
293
|
+
+ `velX=${velX.current.toFixed(4)}m/s `
|
|
294
|
+
+ `posX=${posX.current.toFixed(4)}m `
|
|
295
|
+
+ `(|mag|=${mag.toFixed(4)}m, budget=${budgetMeters.toFixed(2)}m, crossed=${budgetCrossed.current})`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Budget crossing — fire exactly once per crossing (the
|
|
300
|
+
// `budgetCrossed` flag clears on `resetAnchor`).
|
|
301
|
+
if (!budgetCrossed.current && mag >= budgetMeters) {
|
|
302
|
+
budgetCrossed.current = true;
|
|
303
|
+
if (debug) {
|
|
304
|
+
// eslint-disable-next-line no-console
|
|
305
|
+
console.log(
|
|
306
|
+
`[IMUTransGate] BUDGET CROSSED at posX=${posX.current.toFixed(4)}m `
|
|
307
|
+
+ `(budget=${budgetMeters.toFixed(2)}m)`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
onBudgetExceededRef.current();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
return () => {
|
|
315
|
+
if (debug) {
|
|
316
|
+
// eslint-disable-next-line no-console
|
|
317
|
+
console.log(
|
|
318
|
+
`[IMUTransGate] unsubscribing (everGotData=${everGotData}, `
|
|
319
|
+
+ `nullSamples=${nullSampleCount}, realSamples=${sampleCount.current})`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
sub.remove();
|
|
323
|
+
};
|
|
324
|
+
}, [enabled, budgetMeters, sampleIntervalMs, debug]);
|
|
325
|
+
|
|
326
|
+
// 2026-05-18 (Issue B meta-bug fix): wrap the returned functions in
|
|
327
|
+
// useCallback so the hook's return value is REFERENTIALLY STABLE
|
|
328
|
+
// across renders. Consumer code (AuditCaptureScreen) puts `imuGate`
|
|
329
|
+
// in a useEffect dep array; without stability that effect re-runs
|
|
330
|
+
// every render, wiping out the prevAcceptedCount delta-tracker
|
|
331
|
+
// (which is what caused the resetAnchor-too-often bug we just
|
|
332
|
+
// diagnosed). These functions only touch refs, so empty-deps
|
|
333
|
+
// useCallback is safe — no stale-closure risk.
|
|
334
|
+
const resetAnchor = useCallback(() => {
|
|
335
|
+
velX.current = 0;
|
|
336
|
+
posX.current = 0;
|
|
337
|
+
lastMs.current = 0;
|
|
338
|
+
budgetCrossed.current = false;
|
|
339
|
+
sampleCount.current = 0;
|
|
340
|
+
anchorMs.current = Date.now();
|
|
341
|
+
}, []);
|
|
342
|
+
const getCurrentTranslationM = useCallback(
|
|
343
|
+
() => Math.abs(posX.current),
|
|
344
|
+
[],
|
|
345
|
+
);
|
|
346
|
+
return { resetAnchor, getCurrentTranslationM };
|
|
347
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* IncrementalStitcherView — live preview component for the panorama
|
|
4
|
+
* engine. Renders the latest snapshot JPEG written by the native
|
|
5
|
+
* side, with confidence + hint overlays.
|
|
6
|
+
*
|
|
7
|
+
* Why <Image> + cache-bust query string instead of a custom native
|
|
8
|
+
* view: per the design doc's open question, the JPEG-write approach
|
|
9
|
+
* is V1; if perf measurements show we're hitting RN's image cache
|
|
10
|
+
* too hard, swap in an `Animated.Image` or a native UIView with
|
|
11
|
+
* an in-memory bitmap. Until then, the simple path keeps the
|
|
12
|
+
* cross-platform surface tiny.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React, { useMemo } from 'react';
|
|
16
|
+
import {
|
|
17
|
+
ActivityIndicator,
|
|
18
|
+
Image,
|
|
19
|
+
StyleSheet,
|
|
20
|
+
Text,
|
|
21
|
+
View,
|
|
22
|
+
type ViewStyle,
|
|
23
|
+
} from 'react-native';
|
|
24
|
+
import type { IncrementalState } from './incremental';
|
|
25
|
+
import type { IncrementalHint } from './useIncrementalStitcher';
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
export interface IncrementalStitcherViewProps {
|
|
29
|
+
/** Latest engine state — typically `useIncrementalStitcher().state`. */
|
|
30
|
+
state: IncrementalState | null;
|
|
31
|
+
/**
|
|
32
|
+
* Active hint to surface as a banner overlay. Pass
|
|
33
|
+
* `useIncrementalStitcher().hint` directly; the view picks the
|
|
34
|
+
* right wording.
|
|
35
|
+
*/
|
|
36
|
+
hint: IncrementalHint;
|
|
37
|
+
/**
|
|
38
|
+
* Confidence ring colour driver — typically
|
|
39
|
+
* `useIncrementalStitcher().confidenceLevel`.
|
|
40
|
+
*/
|
|
41
|
+
confidenceLevel?: 'high' | 'medium' | null;
|
|
42
|
+
/** Outer container style (size, position). Required: the view
|
|
43
|
+
* has no intrinsic size since the panorama dimensions vary. */
|
|
44
|
+
style?: ViewStyle;
|
|
45
|
+
/**
|
|
46
|
+
* Optional override for the spinner shown before the first frame
|
|
47
|
+
* is accepted. Default is a subtle "Pan to begin" caption.
|
|
48
|
+
*/
|
|
49
|
+
emptyText?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
function hintMessage(hint: IncrementalHint): string | null {
|
|
54
|
+
switch (hint) {
|
|
55
|
+
case 'slow-down': return 'Slow down — alignment lost';
|
|
56
|
+
case 'scene-uniform': return 'Pan to a textured area';
|
|
57
|
+
case 'alignment-lost': return 'Slow down — re-acquiring alignment';
|
|
58
|
+
case 'tracking-poor': return 'Hold steady — AR re-acquiring';
|
|
59
|
+
default: return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
export function IncrementalStitcherView({
|
|
65
|
+
state,
|
|
66
|
+
hint,
|
|
67
|
+
confidenceLevel,
|
|
68
|
+
style,
|
|
69
|
+
emptyText = 'Pan to begin capturing',
|
|
70
|
+
}: IncrementalStitcherViewProps): React.JSX.Element {
|
|
71
|
+
// Cache-bust the panorama URI. The native side rotates through
|
|
72
|
+
// 4 filenames so the path itself changes between snapshots, plus
|
|
73
|
+
// we tag with acceptedCount as belt-and-suspenders since RN's
|
|
74
|
+
// image cache on iOS sometimes ignores file:// query strings.
|
|
75
|
+
const imageUri = useMemo(() => {
|
|
76
|
+
if (!state?.panoramaPath) return null;
|
|
77
|
+
return `file://${state.panoramaPath}?v=${state.acceptedCount}`;
|
|
78
|
+
}, [state?.panoramaPath, state?.acceptedCount]);
|
|
79
|
+
|
|
80
|
+
// Use the panorama's NATURAL aspect ratio so the strip widens as
|
|
81
|
+
// the user pans across. Falls back to 4:3 (a single frame's
|
|
82
|
+
// shape) before any snapshot has been written. Without this the
|
|
83
|
+
// PiP was forced into a 3:1 letterbox, cropping the actual
|
|
84
|
+
// panorama to a thin slice across the middle.
|
|
85
|
+
const naturalAspect = state?.width && state?.height && state.height > 0
|
|
86
|
+
? state.width / state.height
|
|
87
|
+
: 4 / 3;
|
|
88
|
+
|
|
89
|
+
const ringColor = confidenceLevel === 'high'
|
|
90
|
+
? '#1aaf5d'
|
|
91
|
+
: confidenceLevel === 'medium'
|
|
92
|
+
? '#e6b800'
|
|
93
|
+
: 'rgba(255,255,255,0.35)';
|
|
94
|
+
|
|
95
|
+
const message = hintMessage(hint);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<View style={[styles.container, { aspectRatio: naturalAspect }, style]}>
|
|
99
|
+
{imageUri ? (
|
|
100
|
+
// `contain` so the FULL panorama is visible inside the
|
|
101
|
+
// strip, not cropped to a slice. Background fills the
|
|
102
|
+
// letterbox edges. Key={acceptedCount} forces RN to
|
|
103
|
+
// remount the Image component each accept — the surest
|
|
104
|
+
// way to defeat the native image cache on file:// URIs.
|
|
105
|
+
<Image
|
|
106
|
+
key={state?.acceptedCount ?? 0}
|
|
107
|
+
source={{ uri: imageUri }}
|
|
108
|
+
style={StyleSheet.absoluteFill}
|
|
109
|
+
resizeMode="contain"
|
|
110
|
+
fadeDuration={0}
|
|
111
|
+
/>
|
|
112
|
+
) : (
|
|
113
|
+
<View style={styles.empty}>
|
|
114
|
+
<ActivityIndicator color="#fff" />
|
|
115
|
+
<Text style={styles.emptyText}>{emptyText}</Text>
|
|
116
|
+
</View>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{/* Confidence ring — subtle border that picks up colour for
|
|
120
|
+
medium-confidence accepts. Always visible (white-translucent
|
|
121
|
+
when no confidence signal) so the operator can see exactly
|
|
122
|
+
where the live preview is on screen. */}
|
|
123
|
+
<View
|
|
124
|
+
pointerEvents="none"
|
|
125
|
+
style={[styles.ring, { borderColor: ringColor }]}
|
|
126
|
+
/>
|
|
127
|
+
|
|
128
|
+
{message ? (
|
|
129
|
+
<View pointerEvents="none" style={styles.hintBanner}>
|
|
130
|
+
<Text style={styles.hintText}>{message}</Text>
|
|
131
|
+
</View>
|
|
132
|
+
) : null}
|
|
133
|
+
|
|
134
|
+
{state ? (
|
|
135
|
+
<View pointerEvents="none" style={styles.counterPill}>
|
|
136
|
+
<Text style={styles.counterText}>
|
|
137
|
+
{state.acceptedCount} frame{state.acceptedCount === 1 ? '' : 's'}
|
|
138
|
+
</Text>
|
|
139
|
+
</View>
|
|
140
|
+
) : null}
|
|
141
|
+
</View>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
const styles = StyleSheet.create({
|
|
147
|
+
container: {
|
|
148
|
+
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
149
|
+
overflow: 'hidden',
|
|
150
|
+
borderRadius: 8,
|
|
151
|
+
},
|
|
152
|
+
empty: {
|
|
153
|
+
...StyleSheet.absoluteFillObject,
|
|
154
|
+
alignItems: 'center',
|
|
155
|
+
justifyContent: 'center',
|
|
156
|
+
gap: 6,
|
|
157
|
+
},
|
|
158
|
+
emptyText: {
|
|
159
|
+
color: '#fff',
|
|
160
|
+
fontSize: 12,
|
|
161
|
+
opacity: 0.85,
|
|
162
|
+
},
|
|
163
|
+
ring: {
|
|
164
|
+
...StyleSheet.absoluteFillObject,
|
|
165
|
+
borderRadius: 8,
|
|
166
|
+
borderWidth: 2,
|
|
167
|
+
},
|
|
168
|
+
hintBanner: {
|
|
169
|
+
position: 'absolute',
|
|
170
|
+
left: 8,
|
|
171
|
+
right: 8,
|
|
172
|
+
bottom: 8,
|
|
173
|
+
paddingVertical: 4,
|
|
174
|
+
paddingHorizontal: 8,
|
|
175
|
+
backgroundColor: 'rgba(220, 53, 69, 0.92)',
|
|
176
|
+
borderRadius: 6,
|
|
177
|
+
},
|
|
178
|
+
hintText: {
|
|
179
|
+
color: '#fff',
|
|
180
|
+
fontSize: 11,
|
|
181
|
+
textAlign: 'center',
|
|
182
|
+
fontWeight: '500',
|
|
183
|
+
},
|
|
184
|
+
counterPill: {
|
|
185
|
+
position: 'absolute',
|
|
186
|
+
top: 6,
|
|
187
|
+
right: 6,
|
|
188
|
+
paddingVertical: 2,
|
|
189
|
+
paddingHorizontal: 8,
|
|
190
|
+
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
|
191
|
+
borderRadius: 10,
|
|
192
|
+
},
|
|
193
|
+
counterText: {
|
|
194
|
+
color: '#fff',
|
|
195
|
+
fontSize: 10,
|
|
196
|
+
fontWeight: '500',
|
|
197
|
+
},
|
|
198
|
+
});
|