react-native-image-stitcher 0.1.2 → 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.
- package/CHANGELOG.md +110 -1
- package/README.md +0 -9
- package/android/src/main/cpp/keyframe_gate_jni.cpp +1 -1
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2 -2
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- package/dist/camera/Camera.js +5 -1
- package/dist/camera/useDeviceOrientation.d.ts +26 -23
- package/dist/camera/useDeviceOrientation.js +64 -77
- package/dist/index.js +3 -0
- package/dist/sensors/useIMUTranslationGate.d.ts +18 -46
- package/dist/sensors/useIMUTranslationGate.js +115 -211
- package/dist/stitching/stitchFrames.d.ts +1 -1
- package/dist/stitching/stitchFrames.js +1 -1
- package/ios/Package.swift +1 -1
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +4 -4
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +1 -1
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +1 -1
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +1 -1
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +1 -1
- package/package.json +1 -3
- package/src/camera/Camera.tsx +5 -1
- package/src/camera/useDeviceOrientation.ts +73 -77
- package/src/index.ts +3 -0
- package/src/sensors/useIMUTranslationGate.ts +145 -284
- package/src/stitching/stitchFrames.ts +1 -1
|
@@ -1,235 +1,139 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
//
|
|
4
|
-
// useIMUTranslationGate
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// `
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// (b) A user-facing warning banner ("Rotate the camera instead of
|
|
29
|
-
// moving it sideways" — see AuditCaptureScreen).
|
|
30
|
-
//
|
|
31
|
-
// AuditCaptureScreen wires it to both.
|
|
4
|
+
// useIMUTranslationGate — JS-side IMU translation tracker that fires
|
|
5
|
+
// a callback when integrated lateral displacement on the device-X
|
|
6
|
+
// axis exceeds a budget. Drives `<Camera>`'s non-AR keyframe-
|
|
7
|
+
// acceptance path: every time the gate fires, the host calls the
|
|
8
|
+
// C++ engine's `markNextFrameAsLastKeyframe()` so the trailing frame
|
|
9
|
+
// lands as a keyframe regardless of what the flow-novelty algorithm
|
|
10
|
+
// alone would decide.
|
|
11
|
+
//
|
|
12
|
+
// V0.2 history note
|
|
13
|
+
// ─────────────────
|
|
14
|
+
// 0.1.x used `expo-sensors`' `DeviceMotion.acceleration`, which
|
|
15
|
+
// returned gravity-subtracted linear acceleration via CoreMotion's
|
|
16
|
+
// native fusion (iOS) / Android's `TYPE_LINEAR_ACCELERATION` sensor
|
|
17
|
+
// (Android) — both significantly less noisy than raw accel + JS-side
|
|
18
|
+
// gravity subtraction. v0.2 drops the Expo modules dependency
|
|
19
|
+
// (see CHANGELOG / docs/host-app-integration.md), so the gate is now
|
|
20
|
+
// implemented on `react-native-sensors`' raw `accelerometer` with a
|
|
21
|
+
// JS-side IIR low-pass to estimate the gravity vector. The IIR
|
|
22
|
+
// version is noisier — expect a few extra cm of apparent drift on a
|
|
23
|
+
// stationary phone over several seconds — but the budget threshold
|
|
24
|
+
// (~8 cm at default `flowMaxTranslationCm = 8`) and the anchor
|
|
25
|
+
// resets (every accepted keyframe + recording start) keep the
|
|
26
|
+
// per-interval drift window short enough that the budget still
|
|
27
|
+
// meaningfully discriminates real translation from noise.
|
|
32
28
|
//
|
|
33
29
|
// Why device-X (the shorter side)
|
|
34
30
|
// ───────────────────────────────
|
|
35
|
-
//
|
|
36
31
|
// We track motion ALONG the pan axis (the direction the operator is
|
|
37
32
|
// supposed to be rotating-through but might be translating-through
|
|
38
|
-
// instead)
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// The pan axis maps to device-X in BOTH supported orientations
|
|
43
|
-
// (per memory/ar-stitching-two-modes.md):
|
|
44
|
-
//
|
|
45
|
-
// Portrait + horizontal pan: device-X = user-left/right = pan axis.
|
|
33
|
+
// instead). In BOTH supported pan modes the pan axis maps to
|
|
34
|
+
// device-X:
|
|
35
|
+
// Portrait + horizontal pan: device-X = user-left/right.
|
|
46
36
|
// Landscape + vertical pan: device-X has rotated 90° into the
|
|
47
|
-
// user's up/down direction
|
|
48
|
-
//
|
|
49
|
-
// The lateral axis of the phone (its short side) always aligns with
|
|
50
|
-
// the pan direction in either supported mode, so a single-axis
|
|
51
|
-
// tracker works without needing to know which orientation we're in.
|
|
37
|
+
// user's up/down direction.
|
|
38
|
+
// So a single-axis tracker works without knowing the orientation.
|
|
52
39
|
//
|
|
53
40
|
// Drift mitigation
|
|
54
41
|
// ────────────────
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
42
|
+
// 1. IIR low-pass on the raw X accel estimates the gravity offset.
|
|
43
|
+
// Subtracting that gives linear-acceleration-on-X. Alpha = 0.9
|
|
44
|
+
// at the default 50 Hz sample rate → ~200 ms gravity tracking
|
|
45
|
+
// time constant. Slow enough that hand motion (>1 Hz) gets
|
|
46
|
+
// through; fast enough to converge after device rotations within
|
|
47
|
+
// ~1 second.
|
|
48
|
+
// 2. Per-sample velocity damping at 5 % so a constant noise-floor
|
|
49
|
+
// offset decays to ~1 % of its initial value in 2 s. This caps
|
|
50
|
+
// apparent drift for a stationary phone.
|
|
51
|
+
// 3. Anchor reset on recording start AND every accepted keyframe
|
|
52
|
+
// (callers do this) — bounds the integration window to typically
|
|
53
|
+
// 0.3-2 s, well inside the regime where IIR-estimated linear
|
|
54
|
+
// accel is usable.
|
|
55
|
+
//
|
|
56
|
+
// Platform unit handling
|
|
57
|
+
// ──────────────────────
|
|
58
|
+
// `react-native-sensors`' accelerometer reports:
|
|
59
|
+
// iOS: values in G's (multiples of 9.81 m/s²), via CoreMotion.
|
|
60
|
+
// Android: values in m/s², via Sensor.TYPE_ACCELEROMETER.
|
|
61
|
+
// We scale iOS by `G_TO_MPS2` so the integration math stays in
|
|
62
|
+
// standard m/s², m/s, m units. Sign convention doesn't matter for
|
|
63
|
+
// the gate because the gravity offset is estimated and subtracted
|
|
64
|
+
// per-axis; what's left is the platform-agnostic linear acceleration.
|
|
77
65
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
78
66
|
exports.useIMUTranslationGate = useIMUTranslationGate;
|
|
79
67
|
const react_1 = require("react");
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
68
|
+
const react_native_1 = require("react-native");
|
|
69
|
+
const react_native_sensors_1 = require("react-native-sensors");
|
|
70
|
+
const DEFAULT_BUDGET_METERS = 0.40;
|
|
71
|
+
const DEFAULT_SAMPLE_INTERVAL_MS = 20;
|
|
72
|
+
/// Per-sample multiplicative damping on the velocity integrator.
|
|
73
|
+
/// 5 % at 50 Hz → constant offset decays to ~1 % in 2 s. Bounds
|
|
74
|
+
/// the apparent-drift window for a stationary phone.
|
|
75
|
+
const VELOCITY_DAMPING_PER_SAMPLE = 0.05;
|
|
76
|
+
/// IIR low-pass coefficient for the gravity estimate. At 50 Hz
|
|
77
|
+
/// this gives ~200 ms time constant. Higher = slower gravity
|
|
78
|
+
/// tracking (more lag during device rotation, less hand-motion
|
|
79
|
+
/// bleed into the gravity estimate); lower = faster.
|
|
80
|
+
const GRAVITY_IIR_ALPHA = 0.9;
|
|
81
|
+
/// 1 G in m/s². Standard gravity per CGPM 1901 (good to all the
|
|
82
|
+
/// digits anyone cares about for this application).
|
|
83
|
+
const G_TO_MPS2 = 9.81;
|
|
84
|
+
function useIMUTranslationGate({ enabled, budgetMeters = DEFAULT_BUDGET_METERS, sampleIntervalMs = DEFAULT_SAMPLE_INTERVAL_MS, onBudgetExceeded, }) {
|
|
85
|
+
// All running-integrator state lives in a single ref so the
|
|
86
|
+
// subscription callback can update it without forcing a re-render
|
|
87
|
+
// every frame (50 Hz worth of re-renders would tank performance).
|
|
88
|
+
const stateRef = (0, react_1.useRef)({
|
|
89
|
+
posX: 0,
|
|
90
|
+
velX: 0,
|
|
91
|
+
/// NaN sentinel for "uninitialised"; first sample seeds it.
|
|
92
|
+
gravityX: NaN,
|
|
93
|
+
fired: false,
|
|
94
|
+
});
|
|
95
|
+
// Latest onBudgetExceeded callback in a ref so callers can pass
|
|
96
|
+
// an inline closure that captures fresh state without us re-
|
|
97
|
+
// subscribing the sensor (which would reset the integrators).
|
|
98
|
+
const onExceededRef = (0, react_1.useRef)(onBudgetExceeded);
|
|
99
|
+
onExceededRef.current = onBudgetExceeded;
|
|
100
|
+
const resetAnchor = (0, react_1.useCallback)(() => {
|
|
101
|
+
const s = stateRef.current;
|
|
102
|
+
s.posX = 0;
|
|
103
|
+
s.velX = 0;
|
|
104
|
+
s.fired = false;
|
|
105
|
+
// s.gravityX is intentionally preserved — see header.
|
|
106
|
+
}, []);
|
|
109
107
|
(0, react_1.useEffect)(() => {
|
|
110
|
-
if (debug) {
|
|
111
|
-
// eslint-disable-next-line no-console
|
|
112
|
-
console.log(`[IMUTransGate] effect re-run: enabled=${enabled} `
|
|
113
|
-
+ `budget=${budgetMeters.toFixed(2)}m sampleIntervalMs=${sampleIntervalMs}`);
|
|
114
|
-
}
|
|
115
108
|
if (!enabled)
|
|
116
109
|
return;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
budgetCrossed.current = false;
|
|
129
|
-
sampleCount.current = 0;
|
|
130
|
-
anchorMs.current = Date.now();
|
|
131
|
-
// 2026-05-18 (Issue #3 diagnostics) — track whether we've ever
|
|
132
|
-
// received a non-null `acceleration` for this subscription. If
|
|
133
|
-
// the user reports "no logs" we can correlate with these
|
|
134
|
-
// start-of-subscription and first-real-data markers.
|
|
135
|
-
let everGotData = false;
|
|
136
|
-
let nullSampleCount = 0;
|
|
137
|
-
if (debug) {
|
|
138
|
-
// eslint-disable-next-line no-console
|
|
139
|
-
console.log('[IMUTransGate] subscribing to DeviceMotion');
|
|
140
|
-
}
|
|
141
|
-
const sub = expo_sensors_1.DeviceMotion.addListener((m) => {
|
|
142
|
-
const a = m.acceleration; // gravity-subtracted (m/s²)
|
|
143
|
-
if (!a) {
|
|
144
|
-
nullSampleCount += 1;
|
|
145
|
-
if (debug && nullSampleCount === 1) {
|
|
146
|
-
// eslint-disable-next-line no-console
|
|
147
|
-
console.log('[IMUTransGate] first sample: acceleration=null '
|
|
148
|
-
+ '(CoreMotion warming up; will retry on next sample)');
|
|
149
|
-
}
|
|
150
|
-
if (debug && nullSampleCount > 0 && nullSampleCount % 100 === 0) {
|
|
151
|
-
// eslint-disable-next-line no-console
|
|
152
|
-
console.log(`[IMUTransGate] STILL receiving null acceleration after `
|
|
153
|
-
+ `${nullSampleCount} samples — sensor source may be broken`);
|
|
154
|
-
}
|
|
155
|
-
return; // can be null briefly on cold start
|
|
156
|
-
}
|
|
157
|
-
if (debug && !everGotData) {
|
|
158
|
-
everGotData = true;
|
|
159
|
-
// eslint-disable-next-line no-console
|
|
160
|
-
console.log(`[IMUTransGate] first real sample: ax=${a.x.toFixed(3)} `
|
|
161
|
-
+ `ay=${a.y.toFixed(3)} az=${a.z.toFixed(3)} m/s² `
|
|
162
|
-
+ `(after ${nullSampleCount} null sample(s))`);
|
|
163
|
-
}
|
|
164
|
-
const now = Date.now();
|
|
165
|
-
if (lastMs.current === 0) {
|
|
166
|
-
lastMs.current = now;
|
|
110
|
+
(0, react_native_sensors_1.setUpdateIntervalForType)(react_native_sensors_1.SensorTypes.accelerometer, sampleIntervalMs);
|
|
111
|
+
const scale = react_native_1.Platform.OS === 'ios' ? G_TO_MPS2 : 1;
|
|
112
|
+
const dt = sampleIntervalMs / 1000.0;
|
|
113
|
+
const sub = react_native_sensors_1.accelerometer.subscribe(({ x }) => {
|
|
114
|
+
const ax = x * scale; // device-X acceleration in m/s²
|
|
115
|
+
const s = stateRef.current;
|
|
116
|
+
// First sample: seed gravity from this reading. Assumes the
|
|
117
|
+
// phone is roughly stationary at recording start — true in
|
|
118
|
+
// practice because the operator just tap-and-held the shutter.
|
|
119
|
+
if (Number.isNaN(s.gravityX)) {
|
|
120
|
+
s.gravityX = ax;
|
|
167
121
|
return;
|
|
168
122
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// Single
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const mag = Math.abs(posX.current);
|
|
180
|
-
// 2026-05-18 (Issue #4 investigation) — debug-gated diagnostic
|
|
181
|
-
// log. Throttled to every 20th sample (~400 ms at 50 Hz) so
|
|
182
|
-
// the log isn't a firehose. When this runs and we still see
|
|
183
|
-
// posX hovering at < 5 cm during a real translation, the
|
|
184
|
-
// sensor source isn't capturing what we think it is.
|
|
185
|
-
sampleCount.current += 1;
|
|
186
|
-
if (debug && sampleCount.current % 20 === 0) {
|
|
187
|
-
const secs = (now - anchorMs.current) / 1000.0;
|
|
188
|
-
// eslint-disable-next-line no-console
|
|
189
|
-
console.log(`[IMUTransGate] t+${secs.toFixed(2)}s `
|
|
190
|
-
+ `ax=${a.x.toFixed(3)}m/s² `
|
|
191
|
-
+ `velX=${velX.current.toFixed(4)}m/s `
|
|
192
|
-
+ `posX=${posX.current.toFixed(4)}m `
|
|
193
|
-
+ `(|mag|=${mag.toFixed(4)}m, budget=${budgetMeters.toFixed(2)}m, crossed=${budgetCrossed.current})`);
|
|
194
|
-
}
|
|
195
|
-
// Budget crossing — fire exactly once per crossing (the
|
|
196
|
-
// `budgetCrossed` flag clears on `resetAnchor`).
|
|
197
|
-
if (!budgetCrossed.current && mag >= budgetMeters) {
|
|
198
|
-
budgetCrossed.current = true;
|
|
199
|
-
if (debug) {
|
|
200
|
-
// eslint-disable-next-line no-console
|
|
201
|
-
console.log(`[IMUTransGate] BUDGET CROSSED at posX=${posX.current.toFixed(4)}m `
|
|
202
|
-
+ `(budget=${budgetMeters.toFixed(2)}m)`);
|
|
203
|
-
}
|
|
204
|
-
onBudgetExceededRef.current();
|
|
123
|
+
// IIR low-pass to track the gravity component on device-X.
|
|
124
|
+
s.gravityX = GRAVITY_IIR_ALPHA * s.gravityX + (1 - GRAVITY_IIR_ALPHA) * ax;
|
|
125
|
+
// Linear acceleration on X = raw - gravity estimate.
|
|
126
|
+
const linX = ax - s.gravityX;
|
|
127
|
+
// Single integration with per-sample velocity damping.
|
|
128
|
+
s.velX = (s.velX + linX * dt) * (1 - VELOCITY_DAMPING_PER_SAMPLE);
|
|
129
|
+
s.posX += s.velX * dt;
|
|
130
|
+
if (!s.fired && Math.abs(s.posX) > budgetMeters) {
|
|
131
|
+
s.fired = true;
|
|
132
|
+
onExceededRef.current();
|
|
205
133
|
}
|
|
206
134
|
});
|
|
207
|
-
return () =>
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
console.log(`[IMUTransGate] unsubscribing (everGotData=${everGotData}, `
|
|
211
|
-
+ `nullSamples=${nullSampleCount}, realSamples=${sampleCount.current})`);
|
|
212
|
-
}
|
|
213
|
-
sub.remove();
|
|
214
|
-
};
|
|
215
|
-
}, [enabled, budgetMeters, sampleIntervalMs, debug]);
|
|
216
|
-
// 2026-05-18 (Issue B meta-bug fix): wrap the returned functions in
|
|
217
|
-
// useCallback so the hook's return value is REFERENTIALLY STABLE
|
|
218
|
-
// across renders. Consumer code (AuditCaptureScreen) puts `imuGate`
|
|
219
|
-
// in a useEffect dep array; without stability that effect re-runs
|
|
220
|
-
// every render, wiping out the prevAcceptedCount delta-tracker
|
|
221
|
-
// (which is what caused the resetAnchor-too-often bug we just
|
|
222
|
-
// diagnosed). These functions only touch refs, so empty-deps
|
|
223
|
-
// useCallback is safe — no stale-closure risk.
|
|
224
|
-
const resetAnchor = (0, react_1.useCallback)(() => {
|
|
225
|
-
velX.current = 0;
|
|
226
|
-
posX.current = 0;
|
|
227
|
-
lastMs.current = 0;
|
|
228
|
-
budgetCrossed.current = false;
|
|
229
|
-
sampleCount.current = 0;
|
|
230
|
-
anchorMs.current = Date.now();
|
|
231
|
-
}, []);
|
|
232
|
-
const getCurrentTranslationM = (0, react_1.useCallback)(() => Math.abs(posX.current), []);
|
|
233
|
-
return { resetAnchor, getCurrentTranslationM };
|
|
135
|
+
return () => sub.unsubscribe();
|
|
136
|
+
}, [enabled, budgetMeters, sampleIntervalMs]);
|
|
137
|
+
return { resetAnchor };
|
|
234
138
|
}
|
|
235
139
|
//# sourceMappingURL=useIMUTranslationGate.js.map
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - iOS: Swift native module that vendors upstream OpenCV's iOS
|
|
6
6
|
* framework and calls `cv::Stitcher::SCANS` mode (designed for
|
|
7
7
|
* translational shelf captures). Lives in
|
|
8
|
-
* `
|
|
8
|
+
* `react-native-image-stitcher/ios/Sources/RNImageStitcher/`.
|
|
9
9
|
* - Android: deferred to Phase 3 — same OpenCV surface, different
|
|
10
10
|
* build (NDK + Gradle). Until that lands, Android calls hit the
|
|
11
11
|
* `StitchNotImplementedError` path below.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - iOS: Swift native module that vendors upstream OpenCV's iOS
|
|
8
8
|
* framework and calls `cv::Stitcher::SCANS` mode (designed for
|
|
9
9
|
* translational shelf captures). Lives in
|
|
10
|
-
* `
|
|
10
|
+
* `react-native-image-stitcher/ios/Sources/RNImageStitcher/`.
|
|
11
11
|
* - Android: deferred to Phase 3 — same OpenCV surface, different
|
|
12
12
|
* build (NDK + Gradle). Until that lands, Android calls hit the
|
|
13
13
|
* `StitchNotImplementedError` path below.
|
package/ios/Package.swift
CHANGED
|
@@ -289,7 +289,7 @@ public final class IncrementalStitcher: NSObject {
|
|
|
289
289
|
/// fix is non-trivial; deferred until pose-driven stitch work
|
|
290
290
|
/// lands (which will rework the queue topology anyway).
|
|
291
291
|
private let workQueue = DispatchQueue(
|
|
292
|
-
label: "
|
|
292
|
+
label: "io.imagestitcher.incremental.stitcher",
|
|
293
293
|
qos: .userInitiated
|
|
294
294
|
)
|
|
295
295
|
|
|
@@ -306,7 +306,7 @@ public final class IncrementalStitcher: NSObject {
|
|
|
306
306
|
/// is out of scope for this MVP — see prompt's "deliberately out
|
|
307
307
|
/// of scope" list).
|
|
308
308
|
private let refineQueue = DispatchQueue(
|
|
309
|
-
label: "
|
|
309
|
+
label: "io.imagestitcher.incremental.refine",
|
|
310
310
|
qos: .utility
|
|
311
311
|
)
|
|
312
312
|
|
|
@@ -1136,7 +1136,7 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1136
1136
|
// Why this matters (RCA from Sentry crashes 2026-05-09
|
|
1137
1137
|
// 21:59-22:03, all 3 .ips traces):
|
|
1138
1138
|
// EXC_BAD_ACCESS at objc_retain+16, frame 1 = closure #1
|
|
1139
|
-
// in finalize+2648, queue =
|
|
1139
|
+
// in finalize+2648, queue = io.imagestitcher.incremental.
|
|
1140
1140
|
// stitcher. +2648 lands inside the os_log call that
|
|
1141
1141
|
// bridges self.batchWarperType → NSString via
|
|
1142
1142
|
// swift_bridgeObjectRetain → objc_retain. The retain
|
|
@@ -1269,7 +1269,7 @@ public final class IncrementalStitcher: NSObject {
|
|
|
1269
1269
|
// under stateLock, closing the visible torn-pointer race.
|
|
1270
1270
|
// Three Sentry traces post-fix4 still showed the same crash
|
|
1271
1271
|
// signature (frame 1 = closure #1 in finalize+N, queue =
|
|
1272
|
-
//
|
|
1272
|
+
// io.imagestitcher.incremental.stitcher), which per the
|
|
1273
1273
|
// systematic-debugging skill (3+ fixes failed on the same
|
|
1274
1274
|
// symptom = wrong architecture) means the workQueue.async
|
|
1275
1275
|
// pattern itself is the problem, not any specific captured
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// This file used to BE the algorithm (~545 lines of Swift simd math).
|
|
6
6
|
// As of P3-B of the Android-iOS parity work, the algorithm lives in
|
|
7
|
-
//
|
|
7
|
+
// react-native-image-stitcher/cpp/keyframe_gate.{hpp,cpp} and is shared with
|
|
8
8
|
// the Android side via JNI. This Swift class is now a thin facade
|
|
9
9
|
// that:
|
|
10
10
|
//
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
//
|
|
3
3
|
// KeyframeGateBridge.h — Obj-C++ wrapper exposing the shared C++
|
|
4
|
-
// KeyframeGate (in
|
|
4
|
+
// KeyframeGate (in react-native-image-stitcher/cpp/) to Swift.
|
|
5
5
|
//
|
|
6
6
|
// Why this exists:
|
|
7
7
|
// The pose-driven keyframe-selection algorithm is the single most
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// Single source of truth for the reason-code → string mapping. These
|
|
19
19
|
// strings MUST stay 1:1 with the labels emitted by the original
|
|
20
20
|
// KeyframeGate.swift (and read by the JS telemetry layer in
|
|
21
|
-
//
|
|
21
|
+
// react-native-image-stitcher/src/stitching/incremental.ts). Drift will
|
|
22
22
|
// silently break the JS UI's pill text.
|
|
23
23
|
static NSString *kReasonStringFor(retailens::KeyframeGateDecisionReason r) {
|
|
24
24
|
using R = retailens::KeyframeGateDecisionReason;
|
|
@@ -150,7 +150,7 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
|
|
|
150
150
|
/// recordings. Phase 5 stitching will query by timestamp.
|
|
151
151
|
private var poseLog: [(TimeInterval, RNSARFramePose)] = []
|
|
152
152
|
private let poseLogQueue = DispatchQueue(
|
|
153
|
-
label: "
|
|
153
|
+
label: "io.imagestitcher.arsession.poselog",
|
|
154
154
|
attributes: .concurrent
|
|
155
155
|
)
|
|
156
156
|
private static let MAX_POSE_LOG = 600 // ~10 s @ 60Hz
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-image-stitcher",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -53,7 +53,6 @@
|
|
|
53
53
|
"homepage": "https://github.com/bhargavkanda/react-native-image-stitcher#readme",
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@types/react": "^19.0.0",
|
|
56
|
-
"expo-sensors": "^14.0.0",
|
|
57
56
|
"react": "^19.0.0",
|
|
58
57
|
"react-native": "^0.84.0",
|
|
59
58
|
"react-native-safe-area-context": "^4.0.0",
|
|
@@ -67,7 +66,6 @@
|
|
|
67
66
|
"react-native": ">=0.72.0",
|
|
68
67
|
"react-native-vision-camera": ">=4.7.0",
|
|
69
68
|
"react-native-sensors": ">=7.0.0",
|
|
70
|
-
"expo-sensors": ">=14.0.0",
|
|
71
69
|
"react-native-safe-area-context": ">=4.0.0"
|
|
72
70
|
}
|
|
73
71
|
}
|
package/src/camera/Camera.tsx
CHANGED
|
@@ -661,7 +661,11 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
661
661
|
return () => { cancelled = true; };
|
|
662
662
|
}, [isAR, lens]);
|
|
663
663
|
|
|
664
|
-
// IMU translation gate — only in non-AR mode.
|
|
664
|
+
// IMU translation gate — only engaged in non-AR mode. Fires when
|
|
665
|
+
// the operator's lateral hand motion exceeds the budget, telling
|
|
666
|
+
// the C++ engine to force-accept the next frame. This is what
|
|
667
|
+
// keeps non-AR captures producing keyframes at all (the flow-
|
|
668
|
+
// novelty algorithm alone is too strict in practice).
|
|
665
669
|
const imuGate = useIMUTranslationGate({
|
|
666
670
|
enabled:
|
|
667
671
|
isNonAR
|