react-native-image-stitcher 0.1.3 → 0.2.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.
@@ -1,113 +1,97 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
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.
3
+ // useIMUTranslationGate — JS-side IMU translation tracker that fires
4
+ // a callback when integrated lateral displacement on the device-X
5
+ // axis exceeds a budget. Drives `<Camera>`'s non-AR keyframe-
6
+ // acceptance path: every time the gate fires, the host calls the
7
+ // C++ engine's `markNextFrameAsLastKeyframe()` so the trailing frame
8
+ // lands as a keyframe regardless of what the flow-novelty algorithm
9
+ // alone would decide.
10
+ //
11
+ // V0.2 history note
12
+ // ─────────────────
13
+ // 0.1.x used `expo-sensors`' `DeviceMotion.acceleration`, which
14
+ // returned gravity-subtracted linear acceleration via CoreMotion's
15
+ // native fusion (iOS) / Android's `TYPE_LINEAR_ACCELERATION` sensor
16
+ // (Android) — both significantly less noisy than raw accel + JS-side
17
+ // gravity subtraction. v0.2 drops the Expo modules dependency
18
+ // (see CHANGELOG / docs/host-app-integration.md), so the gate is now
19
+ // implemented on `react-native-sensors`' raw `accelerometer` with a
20
+ // JS-side IIR low-pass to estimate the gravity vector. The IIR
21
+ // version is noisier — expect a few extra cm of apparent drift on a
22
+ // stationary phone over several seconds but the budget threshold
23
+ // (~8 cm at default `flowMaxTranslationCm = 8`) and the anchor
24
+ // resets (every accepted keyframe + recording start) keep the
25
+ // per-interval drift window short enough that the budget still
26
+ // meaningfully discriminates real translation from noise.
31
27
  //
32
28
  // Why device-X (the shorter side)
33
29
  // ───────────────────────────────
34
- //
35
30
  // We track motion ALONG the pan axis (the direction the operator is
36
31
  // 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.
32
+ // instead). In BOTH supported pan modes the pan axis maps to
33
+ // device-X:
34
+ // Portrait + horizontal pan: device-X = user-left/right.
45
35
  // 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.
36
+ // user's up/down direction.
37
+ // So a single-axis tracker works without knowing the orientation.
51
38
  //
52
39
  // Drift mitigation
53
40
  // ────────────────
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
- // keyframethese 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).
41
+ // 1. IIR low-pass on the raw X accel estimates the gravity offset.
42
+ // Subtracting that gives linear-acceleration-on-X. Alpha = 0.9
43
+ // at the default 50 Hz sample rate ~200 ms gravity tracking
44
+ // time constant. Slow enough that hand motion (>1 Hz) gets
45
+ // through; fast enough to converge after device rotations within
46
+ // ~1 second.
47
+ // 2. Per-sample velocity damping at 5 % so a constant noise-floor
48
+ // offset decays to ~1 % of its initial value in 2 s. This caps
49
+ // apparent drift for a stationary phone.
50
+ // 3. Anchor reset on recording start AND every accepted keyframe
51
+ // (callers do this) bounds the integration window to typically
52
+ // 0.3-2 s, well inside the regime where IIR-estimated linear
53
+ // accel is usable.
54
+ //
55
+ // Platform unit handling
56
+ // ──────────────────────
57
+ // `react-native-sensors`' accelerometer reports:
58
+ // iOS: values in G's (multiples of 9.81 m/s²), via CoreMotion.
59
+ // Android: values in m/s², via Sensor.TYPE_ACCELEROMETER.
60
+ // We scale iOS by `G_TO_MPS2` so the integration math stays in
61
+ // standard m/s², m/s, m units. Sign convention doesn't matter for
62
+ // the gate because the gravity offset is estimated and subtracted
63
+ // per-axis; what's left is the platform-agnostic linear acceleration.
76
64
 
77
65
  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>;
66
+ import { Platform } from 'react-native';
67
+ import {
68
+ accelerometer,
69
+ setUpdateIntervalForType,
70
+ SensorTypes,
71
+ } from 'react-native-sensors';
72
+ import type { Subscription } from 'rxjs';
85
73
 
86
74
 
87
75
  export interface UseIMUTranslationGateOptions {
88
76
  /**
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.
77
+ * Whether the gate is engaged. Pass `false` to skip the
78
+ * subscription entirely — useful when the host is in AR mode
79
+ * (where the gate gets pose-derived translation natively).
80
+ * Hot-toggleable; subscribing/unsubscribing is cheap.
93
81
  */
94
82
  enabled: boolean;
95
83
 
96
84
  /**
97
85
  * 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`.
86
+ * Default 0.40 m / 40 cm. Callers in `<Camera>` typically pass
87
+ * `panoramaSettings.flowMaxTranslationCm / 100.0` (default 8 cm).
103
88
  */
104
89
  budgetMeters?: number;
105
90
 
106
91
  /**
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.
92
+ * Update interval in MILLISECONDS for the accelerometer.
93
+ * Default 20 ms ≈ 50 Hz. Lower = more accurate integration;
94
+ * higher = lower CPU + battery.
111
95
  */
112
96
  sampleIntervalMs?: number;
113
97
 
@@ -115,233 +99,110 @@ export interface UseIMUTranslationGateOptions {
115
99
  * Fired exactly once per "budget crossing" — i.e., when the
116
100
  * running translation along device-X crosses `budgetMeters` from
117
101
  * 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.
102
+ * `IncrementalStitcher.markNextFrameAsLastKeyframe()` to force-
103
+ * accept the next frame, and (b) invoking the returned
104
+ * `resetAnchor()` once that next keyframe actually accepts, so
105
+ * the integrator restarts from zero.
121
106
  */
122
107
  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
108
  }
134
109
 
135
110
 
136
111
  export interface UseIMUTranslationGateReturn {
137
112
  /**
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`.
113
+ * Reset the position + velocity integrators to zero AND clear the
114
+ * "already fired" latch so `onBudgetExceeded` can fire again.
115
+ * The gravity IIR estimate is intentionally preserved it
116
+ * benefits from continuous history across anchors.
143
117
  */
144
118
  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
119
  }
155
120
 
156
121
 
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);
122
+ const DEFAULT_BUDGET_METERS = 0.40;
123
+ const DEFAULT_SAMPLE_INTERVAL_MS = 20;
124
+ /// Per-sample multiplicative damping on the velocity integrator.
125
+ /// 5 % at 50 Hz constant offset decays to ~1 % in 2 s. Bounds
126
+ /// the apparent-drift window for a stationary phone.
127
+ const VELOCITY_DAMPING_PER_SAMPLE = 0.05;
128
+ /// IIR low-pass coefficient for the gravity estimate. At 50 Hz
129
+ /// this gives ~200 ms time constant. Higher = slower gravity
130
+ /// tracking (more lag during device rotation, less hand-motion
131
+ /// bleed into the gravity estimate); lower = faster.
132
+ const GRAVITY_IIR_ALPHA = 0.9;
133
+ /// 1 G in m/s². Standard gravity per CGPM 1901 (good to all the
134
+ /// digits anyone cares about for this application).
135
+ const G_TO_MPS2 = 9.81;
136
+
137
+
138
+ export function useIMUTranslationGate({
139
+ enabled,
140
+ budgetMeters = DEFAULT_BUDGET_METERS,
141
+ sampleIntervalMs = DEFAULT_SAMPLE_INTERVAL_MS,
142
+ onBudgetExceeded,
143
+ }: UseIMUTranslationGateOptions): UseIMUTranslationGateReturn {
144
+ // All running-integrator state lives in a single ref so the
145
+ // subscription callback can update it without forcing a re-render
146
+ // every frame (50 Hz worth of re-renders would tank performance).
147
+ const stateRef = useRef({
148
+ posX: 0,
149
+ velX: 0,
150
+ /// NaN sentinel for "uninitialised"; first sample seeds it.
151
+ gravityX: NaN,
152
+ fired: false,
153
+ });
154
+
155
+ // Latest onBudgetExceeded callback in a ref so callers can pass
156
+ // an inline closure that captures fresh state without us re-
157
+ // subscribing the sensor (which would reset the integrators).
158
+ const onExceededRef = useRef(onBudgetExceeded);
159
+ onExceededRef.current = onBudgetExceeded;
190
160
 
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]);
161
+ const resetAnchor = useCallback(() => {
162
+ const s = stateRef.current;
163
+ s.posX = 0;
164
+ s.velX = 0;
165
+ s.fired = false;
166
+ // s.gravityX is intentionally preserved — see header.
167
+ }, []);
196
168
 
197
169
  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
170
  if (!enabled) return;
206
171
 
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);
172
+ setUpdateIntervalForType(SensorTypes.accelerometer, sampleIntervalMs);
173
+ const scale = Platform.OS === 'ios' ? G_TO_MPS2 : 1;
174
+ const dt = sampleIntervalMs / 1000.0;
212
175
 
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();
176
+ const sub: Subscription = accelerometer.subscribe(({ x }) => {
177
+ const ax = x * scale; // device-X acceleration in m/s²
178
+ const s = stateRef.current;
222
179
 
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;
180
+ // First sample: seed gravity from this reading. Assumes the
181
+ // phone is roughly stationary at recording start — true in
182
+ // practice because the operator just tap-and-held the shutter.
183
+ if (Number.isNaN(s.gravityX)) {
184
+ s.gravityX = ax;
266
185
  return;
267
186
  }
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
187
 
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;
188
+ // IIR low-pass to track the gravity component on device-X.
189
+ s.gravityX = GRAVITY_IIR_ALPHA * s.gravityX + (1 - GRAVITY_IIR_ALPHA) * ax;
278
190
 
279
- const mag = Math.abs(posX.current);
191
+ // Linear acceleration on X = raw - gravity estimate.
192
+ const linX = ax - s.gravityX;
280
193
 
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
- }
194
+ // Single integration with per-sample velocity damping.
195
+ s.velX = (s.velX + linX * dt) * (1 - VELOCITY_DAMPING_PER_SAMPLE);
196
+ s.posX += s.velX * dt;
298
197
 
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();
198
+ if (!s.fired && Math.abs(s.posX) > budgetMeters) {
199
+ s.fired = true;
200
+ onExceededRef.current();
311
201
  }
312
202
  });
313
203
 
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]);
204
+ return () => sub.unsubscribe();
205
+ }, [enabled, budgetMeters, sampleIntervalMs]);
325
206
 
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 };
207
+ return { resetAnchor };
347
208
  }