react-native-image-stitcher 0.2.1 → 0.4.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +511 -1
  2. package/README.md +1 -1
  3. package/android/src/main/cpp/keyframe_gate_jni.cpp +138 -0
  4. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +412 -40
  5. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +128 -0
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +87 -45
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +46 -4
  8. package/cpp/stitcher.cpp +101 -1
  9. package/cpp/stitcher.hpp +8 -0
  10. package/dist/camera/Camera.d.ts +9 -0
  11. package/dist/camera/Camera.js +165 -43
  12. package/dist/camera/CaptureDebugOverlay.d.ts +45 -0
  13. package/dist/camera/CaptureDebugOverlay.js +146 -0
  14. package/dist/camera/CaptureKeyframePill.d.ts +28 -0
  15. package/dist/camera/CaptureKeyframePill.js +60 -0
  16. package/dist/camera/CaptureMemoryPill.d.ts +28 -0
  17. package/dist/camera/CaptureMemoryPill.js +109 -0
  18. package/dist/camera/CaptureOrientationPill.d.ts +22 -0
  19. package/dist/camera/CaptureOrientationPill.js +44 -0
  20. package/dist/camera/CaptureStitchStatsToast.d.ts +45 -0
  21. package/dist/camera/CaptureStitchStatsToast.js +133 -0
  22. package/dist/camera/PanoramaSettings.d.ts +478 -0
  23. package/dist/camera/PanoramaSettings.js +120 -0
  24. package/dist/camera/PanoramaSettingsBridge.d.ts +84 -0
  25. package/dist/camera/PanoramaSettingsBridge.js +208 -0
  26. package/dist/camera/PanoramaSettingsModal.d.ts +50 -298
  27. package/dist/camera/PanoramaSettingsModal.js +189 -354
  28. package/dist/camera/buildPanoramaInitialSettings.d.ts +70 -0
  29. package/dist/camera/buildPanoramaInitialSettings.js +97 -0
  30. package/dist/camera/lowMemDevice.d.ts +24 -0
  31. package/dist/camera/lowMemDevice.js +69 -0
  32. package/dist/index.d.ts +16 -2
  33. package/dist/index.js +37 -2
  34. package/dist/sensors/useIMUTranslationGate.d.ts +26 -0
  35. package/dist/sensors/useIMUTranslationGate.js +83 -1
  36. package/dist/stitching/incremental.d.ts +25 -0
  37. package/dist/stitching/useIncrementalStitcher.d.ts +12 -1
  38. package/dist/stitching/useIncrementalStitcher.js +7 -1
  39. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +321 -7
  40. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +8 -0
  41. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +12 -0
  42. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +13 -0
  43. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +15 -0
  44. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +1 -0
  45. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +17 -4
  46. package/ios/Sources/RNImageStitcher/Stitcher.swift +6 -1
  47. package/package.json +6 -2
  48. package/src/camera/Camera.tsx +220 -54
  49. package/src/camera/CaptureDebugOverlay.tsx +180 -0
  50. package/src/camera/CaptureKeyframePill.tsx +77 -0
  51. package/src/camera/CaptureMemoryPill.tsx +96 -0
  52. package/src/camera/CaptureOrientationPill.tsx +57 -0
  53. package/src/camera/CaptureStitchStatsToast.tsx +155 -0
  54. package/src/camera/PanoramaSettings.ts +605 -0
  55. package/src/camera/PanoramaSettingsBridge.ts +238 -0
  56. package/src/camera/PanoramaSettingsModal.tsx +296 -988
  57. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +375 -0
  58. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +119 -0
  59. package/src/camera/__tests__/lowMemDevice.test.ts +52 -0
  60. package/src/camera/buildPanoramaInitialSettings.ts +139 -0
  61. package/src/camera/lowMemDevice.ts +71 -0
  62. package/src/index.ts +61 -3
  63. package/src/sensors/useIMUTranslationGate.ts +112 -1
  64. package/src/stitching/incremental.ts +25 -0
  65. package/src/stitching/useIncrementalStitcher.ts +18 -0
@@ -215,6 +215,12 @@ struct FinalizePayload {
215
215
  let batchBlenderType: String
216
216
  let batchSeamFinderType: String
217
217
  let batchEnableInscribedRectCrop: Bool
218
+ /// 2026-05-22 (audit F2) — resolved stitcher mode for this finalize
219
+ /// pass. String form ("panorama" / "scans") matches Android's
220
+ /// JNI string and the Obj-C++ method signature. 'auto' is
221
+ /// resolved upstream by `resolveStitchModeAuto` before this snapshot
222
+ /// is captured; this field never carries 'auto'.
223
+ let batchStitchModeResolved: String
218
224
  let keyframeExifOrientation: Int
219
225
  /// AR-STITCHING-TWO-MODES (memory/ar-stitching-two-modes.md):
220
226
  /// capture-time hold orientation for the bake-rotation pass.
@@ -409,6 +415,27 @@ public final class IncrementalStitcher: NSObject {
409
415
  // the inscribed-rect + morph-close + col-projection pipeline
410
416
  // runs.
411
417
  private var batchEnableInscribedRectCrop: Bool = false
418
+ /// 2026-05-22 (audit F2) — cv::Stitcher pipeline mode for the
419
+ /// batch finalize. Mirrors Android's `batchStitchMode` (kt:187).
420
+ /// Valid values: 'auto' / 'panorama' / 'scans'. 'auto' is
421
+ /// resolved at finalize time via [resolveStitchModeAuto] using
422
+ /// the translation/rotation magnitudes between first + last
423
+ /// accepted keyframe poses. Default 'auto'.
424
+ private var batchStitchMode: String = "auto"
425
+ /// 2026-05-22 (audit F2) — first + last accepted keyframe poses
426
+ /// in the current batch capture. 7 doubles each: [tx, ty, tz,
427
+ /// qx, qy, qz, qw]. Both nil until at least one keyframe has
428
+ /// been accepted. Reset on every start(). Used only by the
429
+ /// auto-resolver; the keyframe-gate's own pose tracking lives
430
+ /// separately in cpp/keyframe_gate.cpp.
431
+ private var batchFirstAcceptedPose: [Double]? = nil
432
+ private var batchLastAcceptedPose: [Double]? = nil
433
+ /// 2026-05-22 (audit F2b) — JS-measured cumulative IMU translation
434
+ /// magnitude in METRES, set by the bridge at finalize time. Used
435
+ /// by the auto-resolver as a fallback translation signal in non-
436
+ /// AR mode (where pose-derived tx/ty/tz is always 0). Set to 0
437
+ /// at start() and overwritten at finalize() entry.
438
+ private var batchImuTranslationMetres: Double = 0.0
412
439
  /// AR-STITCHING-TWO-MODES — see memory/ar-stitching-two-modes.md
413
440
  ///
414
441
  /// Physical phone orientation at start() time, sourced from the
@@ -452,6 +479,18 @@ public final class IncrementalStitcher: NSObject {
452
479
  prev, orientation)
453
480
  }
454
481
 
482
+ /// 2026-05-22 (audit F2b) — JS calls this at finalize() entry to
483
+ /// hand over the cumulative IMU translation magnitude in METRES.
484
+ /// Stored in batchImuTranslationMetres for the auto-resolver to
485
+ /// consume in non-AR mode (where pose-derived translation is 0).
486
+ /// 0.0 is the back-compat default (resolver falls back to pose
487
+ /// translation; PANORAMA when both are 0).
488
+ @objc public func updateImuTranslationMetres(_ metres: Double) {
489
+ stateLock.lock()
490
+ self.batchImuTranslationMetres = max(0.0, metres)
491
+ stateLock.unlock()
492
+ }
493
+
455
494
  /// 2026-05-18 (Iss 3) — return the current capture's keyframe
456
495
  /// session directory, or nil if no capture is in flight / engine
457
496
  /// isn't using a per-session keyframe collector.
@@ -823,6 +862,23 @@ public final class IncrementalStitcher: NSObject {
823
862
  // to FALSE if not provided by JS.
824
863
  self.batchEnableInscribedRectCrop =
825
864
  (configOverrides["enableMaxInscribedRectCrop"] as? Bool) ?? false
865
+ // 2026-05-22 (audit F2) — read stitchMode from JS. Pre-
866
+ // audit, iOS hardcoded Panorama at OpenCVStitcher.mm:436
867
+ // regardless of the JS setting. Now mirrors Android's
868
+ // batchStitchMode + auto-resolver heuristic. Validate
869
+ // against the closed set; unknown values fall back to 'auto'.
870
+ let rawMode = (configOverrides["stitchMode"] as? String) ?? "auto"
871
+ self.batchStitchMode =
872
+ (["auto", "panorama", "scans"].contains(rawMode))
873
+ ? rawMode : "auto"
874
+ // Reset accumulated-pose state for the new capture so
875
+ // finalize() picks a fresh mode.
876
+ self.batchFirstAcceptedPose = nil
877
+ self.batchLastAcceptedPose = nil
878
+ // 2026-05-22 (audit F2b) — reset IMU translation snapshot
879
+ // too. Updated at finalize() entry from JS-supplied
880
+ // option value.
881
+ self.batchImuTranslationMetres = 0.0
826
882
  self.batchKeyframeMode = true
827
883
  self.hybridEngine = nil
828
884
  self.firstwinsEngine = nil
@@ -887,6 +943,21 @@ public final class IncrementalStitcher: NSObject {
887
943
  (frameMode == "pose-based" || frameMode == "flow-based")
888
944
  self.keyframeGate.strategy =
889
945
  (frameMode == "flow-based") ? .flow : .pose
946
+ // 2026-05-22 (audit F1b) — non-AR-mode opt-out for the angular-
947
+ // delta fallback. iOS parity with Android IncrementalStitcher.kt:461.
948
+ // captureSource = 'non-ar' means the host is using vision-camera
949
+ // (no ARKit pose) — disable the gate's angular fallback so it
950
+ // doesn't compute on gyro-drift-driven garbage pose. Without
951
+ // this opt-out, gyro drift accumulates past the overlap
952
+ // threshold even on a stationary camera → near-identical
953
+ // frames get accepted → cv::Stitcher's camera-param estimator
954
+ // goes degenerate → "warpRoi too large (9581×12332) — estimator
955
+ // produced degenerate camera params" crash on finalize.
956
+ // Pre-audit iOS had this bug because the Swift facade had no
957
+ // disableAngularFallback property at all — the C++ setter
958
+ // existed but nothing on iOS ever called it.
959
+ let captureSource = (configOverrides["captureSource"] as? String) ?? "ar"
960
+ self.keyframeGate.disableAngularFallback = (captureSource == "non-ar")
890
961
  if let v = configOverrides["keyframeOverlapThreshold"] as? Double {
891
962
  self.keyframeGate.overlapThreshold = max(0.10, min(0.80, v))
892
963
  } else {
@@ -1216,6 +1287,27 @@ public final class IncrementalStitcher: NSObject {
1216
1287
  ? String(outputPath.dropFirst(7))
1217
1288
  : outputPath
1218
1289
  let q = max(1, min(100, jpegQuality))
1290
+ // 2026-05-22 (audit F2) — resolve 'auto' stitchMode now, while
1291
+ // we still have access to first/last pose ivars. The resolver
1292
+ // mirrors Android's resolveStitchModeAuto (IncrementalStitcher.kt:1727):
1293
+ // translation/rotation magnitude ratio between first + last
1294
+ // accepted keyframe poses → SCANS (translation-heavy) or
1295
+ // PANORAMA (rotation-heavy). Non-auto values pass through.
1296
+ let stitchModeResolved: String
1297
+ switch batchStitchMode {
1298
+ case "panorama": stitchModeResolved = "panorama"
1299
+ case "scans": stitchModeResolved = "scans"
1300
+ default: stitchModeResolved = resolveStitchModeAuto(
1301
+ first: batchFirstAcceptedPose,
1302
+ last: batchLastAcceptedPose,
1303
+ imuTranslationMetres: batchImuTranslationMetres
1304
+ )
1305
+ }
1306
+ os_log(.fault, log: Self.diagLog,
1307
+ "[V16-batch-keyframe.stitchMode] configured=%{public}@ resolved=%{public}@ paths=%d imuT=%.3fm",
1308
+ batchStitchMode, stitchModeResolved, Int32(paths.count),
1309
+ batchImuTranslationMetres)
1310
+
1219
1311
  let payload = FinalizePayload(
1220
1312
  cleaned: cleaned,
1221
1313
  q: q,
@@ -1228,6 +1320,7 @@ public final class IncrementalStitcher: NSObject {
1228
1320
  batchBlenderType: batchBlenderType,
1229
1321
  batchSeamFinderType: batchSeamFinderType,
1230
1322
  batchEnableInscribedRectCrop: batchEnableInscribedRectCrop,
1323
+ batchStitchModeResolved: stitchModeResolved,
1231
1324
  keyframeExifOrientation: keyframeExifOrientation,
1232
1325
  captureOrientation: captureOrientation,
1233
1326
  drops: drops,
@@ -1492,7 +1585,8 @@ public final class IncrementalStitcher: NSObject {
1492
1585
  blenderType: payload.batchBlenderType,
1493
1586
  seamFinderType: payload.batchSeamFinderType,
1494
1587
  captureOrientation: payload.captureOrientation,
1495
- useInscribedRectCrop: payload.batchEnableInscribedRectCrop
1588
+ useInscribedRectCrop: payload.batchEnableInscribedRectCrop,
1589
+ stitchMode: payload.batchStitchModeResolved
1496
1590
  )
1497
1591
  // V16 fix-attempt 9 (verified on device,
1498
1592
  // 2026-05-13) — sentinel-result detection.
@@ -1569,6 +1663,14 @@ public final class IncrementalStitcher: NSObject {
1569
1663
  if r.finalConfidenceThresh >= 0 {
1570
1664
  batchDict["finalConfidenceThresh"] = r.finalConfidenceThresh
1571
1665
  }
1666
+ // 2026-05-22 (audit F2g) — surface the
1667
+ // auto-resolver's choice (or the operator's
1668
+ // explicit setting) so JS can show "scans"/
1669
+ // "panorama" on the output preview + debug
1670
+ // toast. Always set on the batch path —
1671
+ // helps the operator understand why the
1672
+ // panorama looks the way it does.
1673
+ batchDict["stitchModeResolved"] = payload.batchStitchModeResolved
1572
1674
  completion(batchDict, nil)
1573
1675
  } catch let stitchErr as NSError {
1574
1676
  completion(nil, stitchErr)
@@ -1727,6 +1829,17 @@ public final class IncrementalStitcher: NSObject {
1727
1829
  let seam = (config["seamFinderType"] as? String) ?? "graphcut"
1728
1830
  let orientation = (config["captureOrientation"] as? String) ?? "portrait"
1729
1831
  let useInscribed = (config["useInscribedRectCrop"] as? Bool) ?? false
1832
+ // 2026-05-22 (audit F2) — refine path reads stitchMode too.
1833
+ // The refine flow doesn't have access to first/last accepted
1834
+ // pose ivars (this is a separate JS-driven entry point that
1835
+ // may be called against a saved keyframe set), so we accept
1836
+ // an explicit 'panorama' / 'scans' value here. Default
1837
+ // 'scans' for the refine path since it's typically called on
1838
+ // shelf-scan captures (the slow-path quality bake where SCANS'
1839
+ // translation tolerance gives best results — see docstring
1840
+ // at line 738 of src/stitching/incremental.ts). JS callers
1841
+ // can override by passing config["stitchMode"].
1842
+ let refineStitchMode = (config["stitchMode"] as? String) ?? "scans"
1730
1843
  let quality = max(1, min(100, (config["jpegQuality"] as? Int) ?? 90))
1731
1844
  let cleanedOutput = outputPath.hasPrefix("file://")
1732
1845
  ? String(outputPath.dropFirst(7))
@@ -1753,7 +1866,8 @@ public final class IncrementalStitcher: NSObject {
1753
1866
  blenderType: blender,
1754
1867
  seamFinderType: seam,
1755
1868
  captureOrientation: orientation,
1756
- useInscribedRectCrop: useInscribed
1869
+ useInscribedRectCrop: useInscribed,
1870
+ stitchMode: refineStitchMode
1757
1871
  )
1758
1872
  // fix-9 sentinel detection — see the finalize() path
1759
1873
  // for the full rationale. A 0×0 result means
@@ -2026,11 +2140,37 @@ public final class IncrementalStitcher: NSObject {
2026
2140
  }
2027
2141
  stateLock.unlock()
2028
2142
 
2029
- // Pose-only gate evaluation no pixel buffer, no plane.
2030
- let decision = self.keyframeGate.evaluate(
2031
- pose: pose,
2032
- latchedPlane: nil
2033
- )
2143
+ // 2026-05-21 (v0.3) — pixel-aware Flow-strategy evaluation.
2144
+ // Pre-0.3 this was `evaluate(pose:latchedPlane:)` with no pixel
2145
+ // buffer, which forced the C++ gate to silently fall back to
2146
+ // Pose strategy (same bug as Android non-AR; both fixed in
2147
+ // v0.3). We now decode the JPEG snapshot at `path` to a
2148
+ // single-channel grayscale CVPixelBuffer and pass it through,
2149
+ // so the gate's Flow strategy actually runs sparse-flow
2150
+ // novelty on real image content.
2151
+ //
2152
+ // CGImageSource → CGContext into a OneComponent8 CVPixelBuffer.
2153
+ // ~10-20 ms per snapshot on iPhone 13/16 Pro; well under the
2154
+ // ~250 ms non-AR snapshot interval (~4 FPS cadence). v0.4
2155
+ // will replace this path entirely by moving non-AR capture to
2156
+ // vision-camera's Frame Processor API (tracked at issue #11).
2157
+ let decision: KeyframeGateDecision
2158
+ if let grayBuffer = Self.decodeJpegToGrayscalePixelBuffer(path: path) {
2159
+ decision = self.keyframeGate.evaluate(
2160
+ pose: pose,
2161
+ latchedPlane: nil,
2162
+ pixelBuffer: grayBuffer
2163
+ )
2164
+ } else {
2165
+ // JPEG decode failed (corrupt file, OOM, etc.). Fall back
2166
+ // to pose-only so the capture doesn't lock up — matches
2167
+ // the C++ side's defensive grayData==nullptr handling
2168
+ // inside evaluateWithFrame.
2169
+ decision = self.keyframeGate.evaluate(
2170
+ pose: pose,
2171
+ latchedPlane: nil
2172
+ )
2173
+ }
2034
2174
  if !decision.accept {
2035
2175
  self.emitKeyframeRejectState(decision: decision)
2036
2176
  return false
@@ -2041,6 +2181,13 @@ public final class IncrementalStitcher: NSObject {
2041
2181
  stateLock.lock()
2042
2182
  self.keyframePaths.append(path)
2043
2183
  self.keyframePoses.append(pose.asDictionary())
2184
+ // 2026-05-22 (audit F2) — track first + last pose for the
2185
+ // stitchMode auto-resolver. iOS parity: Android records
2186
+ // these in IncrementalStitcher.kt at the same accept points.
2187
+ let poseArr = [pose.tx, pose.ty, pose.tz,
2188
+ pose.qx, pose.qy, pose.qz, pose.qw]
2189
+ if self.batchFirstAcceptedPose == nil { self.batchFirstAcceptedPose = poseArr }
2190
+ self.batchLastAcceptedPose = poseArr
2044
2191
  let count = self.keyframePaths.count
2045
2192
  stateLock.unlock()
2046
2193
  os_log(.fault, log: Self.diagLog,
@@ -2056,6 +2203,76 @@ public final class IncrementalStitcher: NSObject {
2056
2203
  return true
2057
2204
  }
2058
2205
 
2206
+ /// 2026-05-21 (v0.3) — decode a JPEG file at the given path into a
2207
+ /// single-channel grayscale CVPixelBuffer (`kCVPixelFormatType_-
2208
+ /// OneComponent8`) suitable for feeding into the C++ KeyframeGate's
2209
+ /// Flow-strategy evaluate path. The bridge's `evaluatePixelBuffer:`
2210
+ /// has explicit OneComponent8 handling (added in v0.3) that reads
2211
+ /// the base address as the Y plane directly, so no extra conversion
2212
+ /// happens on the C++ side.
2213
+ ///
2214
+ /// Used by `addBatchKeyframePath` (the JS-driver non-AR path) so the
2215
+ /// Flow strategy actually runs on real pixel data — pre-0.3 this
2216
+ /// path called `evaluate(pose:latchedPlane:)` with no buffer and
2217
+ /// the C++ side silently fell back to Pose strategy.
2218
+ ///
2219
+ /// Performance: ~10-20 ms for a 1920×1080 JPEG on iPhone 13/16 Pro.
2220
+ /// Well under the ~250 ms non-AR snapshot interval (~4 FPS).
2221
+ /// v0.4 will replace this path entirely via Frame Processor — see
2222
+ /// issue #11.
2223
+ ///
2224
+ /// Returns nil on any failure (file missing, corrupt JPEG, OOM
2225
+ /// on the CVPixelBufferCreate). Callers fall back to the
2226
+ /// pose-only evaluate so the capture doesn't lock up.
2227
+ private static func decodeJpegToGrayscalePixelBuffer(
2228
+ path: String
2229
+ ) -> CVPixelBuffer? {
2230
+ let url = URL(fileURLWithPath: path)
2231
+ guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
2232
+ let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
2233
+ else {
2234
+ return nil
2235
+ }
2236
+ let width = cgImage.width
2237
+ let height = cgImage.height
2238
+
2239
+ var pixelBuffer: CVPixelBuffer?
2240
+ let attrs: NSDictionary = [
2241
+ kCVPixelBufferIOSurfacePropertiesKey: NSDictionary(),
2242
+ ]
2243
+ let status = CVPixelBufferCreate(
2244
+ kCFAllocatorDefault,
2245
+ width, height,
2246
+ kCVPixelFormatType_OneComponent8,
2247
+ attrs,
2248
+ &pixelBuffer
2249
+ )
2250
+ guard status == kCVReturnSuccess, let buffer = pixelBuffer else {
2251
+ return nil
2252
+ }
2253
+
2254
+ CVPixelBufferLockBaseAddress(buffer, [])
2255
+ defer { CVPixelBufferUnlockBaseAddress(buffer, []) }
2256
+ guard let baseAddress = CVPixelBufferGetBaseAddress(buffer) else {
2257
+ return nil
2258
+ }
2259
+ let bytesPerRow = CVPixelBufferGetBytesPerRow(buffer)
2260
+ let colorSpace = CGColorSpaceCreateDeviceGray()
2261
+ guard let context = CGContext(
2262
+ data: baseAddress,
2263
+ width: width,
2264
+ height: height,
2265
+ bitsPerComponent: 8,
2266
+ bytesPerRow: bytesPerRow,
2267
+ space: colorSpace,
2268
+ bitmapInfo: CGImageAlphaInfo.none.rawValue
2269
+ ) else {
2270
+ return nil
2271
+ }
2272
+ context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
2273
+ return buffer
2274
+ }
2275
+
2059
2276
  /// V16 Phase 1 — emit a state event when a batch-keyframe is
2060
2277
  /// saved. Carries the on-disk thumbnail path so JS can render it
2061
2278
  /// in LiveFrameStrip + advance the "Keyframes: N/M" pill.
@@ -2522,6 +2739,13 @@ public final class IncrementalStitcher: NSObject {
2522
2739
  self.stateLock.lock()
2523
2740
  self.keyframePaths.append(record.path)
2524
2741
  self.keyframePoses.append(pose.asDictionary())
2742
+ // 2026-05-22 (audit F2) — track first + last pose
2743
+ // for the stitchMode auto-resolver. Same as the
2744
+ // non-AR JS-driver site above.
2745
+ let poseArr = [pose.tx, pose.ty, pose.tz,
2746
+ pose.qx, pose.qy, pose.qz, pose.qw]
2747
+ if self.batchFirstAcceptedPose == nil { self.batchFirstAcceptedPose = poseArr }
2748
+ self.batchLastAcceptedPose = poseArr
2525
2749
  let count = self.keyframePaths.count
2526
2750
  self.stateLock.unlock()
2527
2751
  os_log(.fault, log: Self.diagLog,
@@ -2708,6 +2932,96 @@ public final class IncrementalStitcher: NSObject {
2708
2932
  let pitch = Double(asin(forward.y))
2709
2933
  return (yaw, pitch)
2710
2934
  }
2935
+
2936
+ /// 2026-05-22 (audit F2) — stitchMode auto-resolver. Port of
2937
+ /// Android's `resolveStitchModeAuto` (IncrementalStitcher.kt:1727).
2938
+ /// Picks PANORAMA vs SCANS based on the magnitude ratio of
2939
+ /// translation to rotation between first and last accepted
2940
+ /// keyframe poses:
2941
+ ///
2942
+ /// translation_score = ‖t_last - t_first‖ / 0.10 (10cm ≈ 1.0)
2943
+ /// rotation_score = angle(fwd_last, fwd_first) / 1.00 (1 rad ≈ 1.0)
2944
+ /// ratio = translation_score / (translation_score + rotation_score)
2945
+ /// ratio ≥ 0.55 → "scans" (biased toward SCANS for safety)
2946
+ /// ratio < 0.55 → "panorama"
2947
+ ///
2948
+ /// 2026-05-22 (audit F2b) — non-AR mode has no pose-derived
2949
+ /// translation (JS driver only sends yaw/pitch/quaternion), so
2950
+ /// the pose-only resolver always picked `panorama` even for
2951
+ /// shelf scans. Fold the JS-measured IMU translation magnitude
2952
+ /// into the resolver: use the LARGER of pose-translation and
2953
+ /// IMU-translation as `tMeters`. In AR mode pose-translation
2954
+ /// will dominate (accurate); in non-AR mode pose-translation is
2955
+ /// 0 and IMU translation provides the signal.
2956
+ ///
2957
+ /// Returns "panorama" or "scans" — never "auto". Degenerate
2958
+ /// inputs (nil poses, no motion either source) default to
2959
+ /// "panorama" (safer for pure-rotation captures; SCANS on a
2960
+ /// translation-free input produces unbounded canvas growth).
2961
+ private func resolveStitchModeAuto(
2962
+ first: [Double]?,
2963
+ last: [Double]?,
2964
+ imuTranslationMetres: Double
2965
+ ) -> String {
2966
+ guard let firstPose = first, firstPose.count == 7,
2967
+ let lastPose = last, lastPose.count == 7 else {
2968
+ // No pose data at all — fall back on whichever signal we
2969
+ // do have. imuTranslationMetres > 0 hints "scans"; 0
2970
+ // hints "panorama".
2971
+ return imuTranslationMetres > 0.05 ? "scans" : "panorama"
2972
+ }
2973
+ // Translation magnitude (Euclidean, in metres).
2974
+ let dtx = lastPose[0] - firstPose[0]
2975
+ let dty = lastPose[1] - firstPose[1]
2976
+ let dtz = lastPose[2] - firstPose[2]
2977
+ let tPose = (dtx*dtx + dty*dty + dtz*dtz).squareRoot()
2978
+ // Take the larger of pose-derived and IMU-measured. In AR
2979
+ // mode pose is accurate; in non-AR mode pose is 0 and IMU is
2980
+ // the only signal we have.
2981
+ let tMeters = max(tPose, imuTranslationMetres)
2982
+ // Rotation magnitude — angle between camera-forward vectors.
2983
+ // Camera-forward in body frame is (0, 0, -1) for ARKit/ARCore.
2984
+ let fwdFirst = qrotForwardZneg(
2985
+ firstPose[3], firstPose[4], firstPose[5], firstPose[6])
2986
+ let fwdLast = qrotForwardZneg(
2987
+ lastPose[3], lastPose[4], lastPose[5], lastPose[6])
2988
+ let dot = max(-1.0, min(1.0,
2989
+ fwdFirst.0 * fwdLast.0 + fwdFirst.1 * fwdLast.1 + fwdFirst.2 * fwdLast.2))
2990
+ let rRadians = acos(dot)
2991
+ // Normalisation: 10 cm of translation ≈ 1 rad of rotation as
2992
+ // "equivalent magnitude" for the ratio. Shelf scans cover
2993
+ // ~30 cm translation with ~10° (0.17 rad) rotation:
2994
+ // ratio = (0.30/0.10) / (3.0 + 0.17) = 0.95 → SCANS.
2995
+ // Pure 90° rotation panorama: 0 translation, 1.57 rad rotation:
2996
+ // ratio = 0 / (0 + 1.57) = 0.0 → PANORAMA.
2997
+ let tScore = tMeters / 0.10
2998
+ let rScore = rRadians / 1.00
2999
+ let denom = tScore + rScore
3000
+ if denom <= 1e-9 { return "panorama" } // no motion either way
3001
+ let ratio = tScore / denom
3002
+ os_log(.fault, log: Self.diagLog,
3003
+ "[stitchMode.auto] tPose=%.3fm tImu=%.3fm r=%.3frad ratio=%.3f → %{public}@",
3004
+ tPose, imuTranslationMetres, rRadians, ratio,
3005
+ ratio >= 0.55 ? "scans" : "panorama")
3006
+ return ratio >= 0.55 ? "scans" : "panorama"
3007
+ }
3008
+
3009
+ /// Closed-form q · (0,0,-1) · q⁻¹ — rotates the camera-forward
3010
+ /// unit vector by a unit quaternion (qx, qy, qz, qw). Same
3011
+ /// convention as `qrot` in cpp/keyframe_gate.cpp and
3012
+ /// `qrotForward` in IncrementalStitcher.kt.
3013
+ private func qrotForwardZneg(
3014
+ _ qx: Double, _ qy: Double, _ qz: Double, _ qw: Double
3015
+ ) -> (Double, Double, Double) {
3016
+ // v = (0, 0, -1). Expansion:
3017
+ // v' = (-2(qx*qz + qw*qy),
3018
+ // -2(qy*qz - qw*qx),
3019
+ // -1 + 2(qx*qx + qy*qy))
3020
+ let x = -2.0 * (qx * qz + qw * qy)
3021
+ let y = -2.0 * (qy * qz - qw * qx)
3022
+ let z = -1.0 + 2.0 * (qx * qx + qy * qy)
3023
+ return (x, y, z)
3024
+ }
2711
3025
  }
2712
3026
 
2713
3027
  // ── Bridge contract for ARSession ───────────────────────────────────
@@ -202,6 +202,14 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
202
202
  freshOrientation
203
203
  )
204
204
  }
205
+ // 2026-05-22 (audit F2b) — JS may pass cumulative IMU
206
+ // translation in METRES so the stitchMode auto-resolver has a
207
+ // translation signal in non-AR mode (where the JS-driver path
208
+ // doesn't carry pose tx/ty/tz). Always ≥ 0; defaults to 0 if
209
+ // unset (back-compat — auto-resolver falls back to pose data
210
+ // and to PANORAMA when both are 0).
211
+ let imuT = (options["imuTranslationMetres"] as? Double) ?? 0.0
212
+ IncrementalStitcher.shared.updateImuTranslationMetres(imuT)
205
213
  IncrementalStitcher.shared.finalize(
206
214
  toPath: outputPath,
207
215
  jpegQuality: quality
@@ -167,6 +167,18 @@ final class KeyframeGate {
167
167
  /// invoking `evaluate(...)`.
168
168
  var flowEvalEveryNFrames: Int = 1
169
169
 
170
+ /// 2026-05-22 (audit F1b) — non-AR-mode opt-out for the angular-
171
+ /// delta fallback. See the bridge doc for the full rationale.
172
+ /// In short: set this to `true` in non-AR mode (captureSource =
173
+ /// 'non-ar') where there's no AR pose — the gate's angular-delta
174
+ /// computation would otherwise run on gyro-integrated drift and
175
+ /// accept near-identical frames → cv::Stitcher degenerate-param
176
+ /// crash. Default `false` (back-compat — AR mode uses the
177
+ /// fallback). Write-only; no read accessor on the C++ side.
178
+ var disableAngularFallback: Bool = false {
179
+ didSet { bridge.setDisableAngularFallback(disableAngularFallback) }
180
+ }
181
+
170
182
 
171
183
  /// One-shot flag: when set to `true`, the very next evaluate()
172
184
  /// accepts unconditionally and the flag self-resets. Set by JS
@@ -76,6 +76,19 @@ NS_SWIFT_NAME(KeyframeGateBridge)
76
76
  /// See KeyframeGate.swift for the operator-facing description.
77
77
  - (void)setFlowNoveltyPercentile:(double)percentile;
78
78
 
79
+ /// 2026-05-22 (audit F1b) — non-AR-mode opt-out for the angular-
80
+ /// delta fallback path. In non-AR captures there is no
81
+ /// ARKit/ARCore pose, so the gate's angular-delta computation runs
82
+ /// on gyro-integrated yaw/pitch which drifts ~1–2°/min. Drift
83
+ /// accumulates past the overlap threshold even when the camera
84
+ /// hasn't moved → near-identical frames get accepted → cv::Stitcher
85
+ /// camera-param estimator goes degenerate → "warpRoi too large"
86
+ /// crash on finalize. Set this to `true` in non-AR mode to disable
87
+ /// the angular-delta fallback entirely (the Flow strategy still
88
+ /// works when pixel data is supplied). Default `false`
89
+ /// (back-compat — AR mode uses the fallback).
90
+ - (void)setDisableAngularFallback:(BOOL)disabled;
91
+
79
92
  // ── Read-only state ─────────────────────────────────────────────
80
93
  - (BOOL)isEnabled;
81
94
  - (NSInteger)acceptedCount;
@@ -126,6 +126,11 @@ static NSString *kReasonStringFor(retailens::KeyframeGateDecisionReason r) {
126
126
  _gate.setFlowNoveltyPercentile(percentile);
127
127
  }
128
128
 
129
+ - (void)setDisableAngularFallback:(BOOL)disabled {
130
+ // 2026-05-22 (audit F1b) — see header doc for rationale.
131
+ _gate.setDisableAngularFallback(disabled ? true : false);
132
+ }
133
+
129
134
  - (KGBDecision *)evaluateWithTx:(float)tx ty:(float)ty tz:(float)tz
130
135
  qx:(float)qx qy:(float)qy qz:(float)qz qw:(float)qw
131
136
  fx:(float)fx fy:(float)fy cx:(float)cx cy:(float)cy
@@ -254,6 +259,16 @@ static NSString *kReasonStringFor(retailens::KeyframeGateDecisionReason r) {
254
259
  grayWidth = static_cast<int32_t>(bgraToGrayHolder.cols);
255
260
  grayHeight = static_cast<int32_t>(bgraToGrayHolder.rows);
256
261
  grayStride = static_cast<int32_t>(bgraToGrayHolder.step);
262
+ } else if (format == kCVPixelFormatType_OneComponent8) {
263
+ // Single-channel grayscale — used by the non-AR
264
+ // batch-keyframe path (v0.3+) which decodes the JPEG snapshot
265
+ // directly to grayscale before evaluating. Base address IS
266
+ // the Y plane; no conversion cost.
267
+ grayData = static_cast<const uint8_t *>(
268
+ CVPixelBufferGetBaseAddress(pixelBuffer));
269
+ grayWidth = static_cast<int32_t>(CVPixelBufferGetWidth(pixelBuffer));
270
+ grayHeight = static_cast<int32_t>(CVPixelBufferGetHeight(pixelBuffer));
271
+ grayStride = static_cast<int32_t>(CVPixelBufferGetBytesPerRow(pixelBuffer));
257
272
  }
258
273
  // else: grayData stays nullptr. The C++ gate detects this and
259
274
  // falls back to the pose-only path inside evaluateWithFrame —
@@ -116,6 +116,7 @@ extern NSString *const RNImageStitcherErrorDomain;
116
116
  seamFinderType:(nullable NSString *)seamFinderType
117
117
  captureOrientation:(nullable NSString *)captureOrientation
118
118
  useInscribedRectCrop:(BOOL)useInscribedRectCrop
119
+ stitchMode:(nullable NSString *)stitchMode
119
120
  error:(NSError **)error;
120
121
 
121
122
  /// Extract `maxFrames` evenly-spaced frames from the video at
@@ -388,6 +388,7 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
388
388
  seamFinderType:(NSString *)seamFinderType
389
389
  captureOrientation:(NSString *)captureOrientation
390
390
  useInscribedRectCrop:(BOOL)useInscribedRectCrop
391
+ stitchMode:(NSString *)stitchMode
391
392
  error:(NSError **)error {
392
393
  // ── Phase 2 (2026-05-16): delegated to shared C++ ───────────────────
393
394
  //
@@ -430,10 +431,19 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
430
431
  cfg.captureOrientation = captureOrientation.UTF8String;
431
432
  cfg.useInscribedRectCrop = (useInscribedRectCrop != NO);
432
433
  cfg.jpegQuality = (int)quality;
433
- // The iOS API doesn't expose stitchMode yet; defaulting to Panorama
434
- // matches the prior hand-rolled pipeline's BestOf2NearestMatcher +
435
- // BundleAdjusterRay configuration (rotation-only end-to-end).
436
- cfg.stitchMode = retailens::StitchMode::Panorama;
434
+ // 2026-05-22 (audit F2) stitchMode is now wired through. Caller
435
+ // (IncrementalStitcher.swift) reads the JS setting and, when set
436
+ // to 'auto', resolves to 'panorama' or 'scans' based on accumulated
437
+ // translation/rotation ratio (mirroring Android's
438
+ // resolveStitchModeAuto at IncrementalStitcher.kt:1727).
439
+ // Unknown / nil values fall through to Panorama (the historical
440
+ // hardcoded default — preserves behaviour for callers that haven't
441
+ // updated yet).
442
+ if ([stitchMode isEqualToString:@"scans"]) {
443
+ cfg.stitchMode = retailens::StitchMode::Scans;
444
+ } else {
445
+ cfg.stitchMode = retailens::StitchMode::Panorama;
446
+ }
437
447
  // Pre-stitch memory-abort threshold inside the manual pipeline keys
438
448
  // off this value. Plumb the device's physical RAM through so the
439
449
  // heuristic scales correctly across the iPhone fleet (~2 GB legacy
@@ -813,6 +823,8 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
813
823
  // "portrait" → no bake-rotation. Callers wanting rotation should
814
824
  // use the keyframe-driven Swift path which carries the orientation
815
825
  // from the JS accelerometer hook through IncrementalStitcher.
826
+ // 2026-05-22 (audit F2) — legacy video path passes nil stitchMode,
827
+ // which falls through to Panorama (preserves prior behaviour).
816
828
  RNStitchResult *result =
817
829
  [self stitchFramePaths:frames
818
830
  outputPath:outputPath
@@ -822,6 +834,7 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
822
834
  seamFinderType:seamFinderType
823
835
  captureOrientation:nil
824
836
  useInscribedRectCrop:NO
837
+ stitchMode:nil
825
838
  error:&stitchErr];
826
839
 
827
840
  // Always tear down the tmp dir, success or fail — leaving
@@ -156,7 +156,12 @@ public enum Stitcher {
156
156
  // in the panorama settings modal). Callers that want
157
157
  // inscribed-rect can use IncrementalStitcher with
158
158
  // the toggle on.
159
- useInscribedRectCrop: false
159
+ useInscribedRectCrop: false,
160
+ // 2026-05-22 (audit F2) — legacy video-stitch API doesn't
161
+ // expose stitchMode in its options dict yet. nil falls
162
+ // through to Panorama in OpenCVStitcher.mm (preserves
163
+ // historical behaviour).
164
+ stitchMode: nil
160
165
  )
161
166
  return StitchResult(
162
167
  outputPath: result.outputPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.2.1",
3
+ "version": "0.4.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",
@@ -25,8 +25,9 @@
25
25
  "CHANGELOG.md"
26
26
  ],
27
27
  "scripts": {
28
- "build": "tsc",
28
+ "build": "tsc -p tsconfig.build.json",
29
29
  "typecheck": "tsc --noEmit",
30
+ "test": "jest",
30
31
  "clean": "rm -rf dist",
31
32
  "postinstall": "node scripts/postinstall-fetch-binaries.js"
32
33
  },
@@ -52,13 +53,16 @@
52
53
  },
53
54
  "homepage": "https://github.com/bhargavkanda/react-native-image-stitcher#readme",
54
55
  "devDependencies": {
56
+ "@types/jest": "^29.5.0",
55
57
  "@types/react": "^19.0.0",
58
+ "jest": "^29.7.0",
56
59
  "react": "^19.0.0",
57
60
  "react-native": "^0.84.0",
58
61
  "react-native-safe-area-context": "^4.0.0",
59
62
  "react-native-sensors": "^7.0.0",
60
63
  "react-native-vision-camera": "^4.0.0",
61
64
  "rxjs": "^7.0.0",
65
+ "ts-jest": "^29.1.0",
62
66
  "typescript": "^5.5.0"
63
67
  },
64
68
  "peerDependencies": {