react-native-image-stitcher 0.20.0 → 0.20.1

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 CHANGED
@@ -14,6 +14,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
14
  > during 0.x are bumped to a new MINOR (e.g., 0.1 → 0.2), and the
15
15
  > upgrade path is documented in this CHANGELOG.
16
16
 
17
+ ## [0.20.1] — 2026-06-21
18
+
19
+ ### Fixed
20
+
21
+ - **AR camera intrinsics principal point (`cx`/`cy`).** `RNISARFrameContext`
22
+ and `onArFrame`'s `intrinsics` reported `cx`/`cy` as `0` — they were read
23
+ from the wrong indices of the **column-major** `ARCamera.intrinsics` matrix
24
+ (`fx`/`fy` survived because they sit on the diagonal). Any pixel↔world
25
+ unprojection that used the principal point was therefore wrong. Now reads
26
+ `cx = k[2][0]`, `cy = k[2][1]`.
27
+ - **AR `takePhoto` resolution.** AR-mode photo capture used the low-resolution
28
+ AR video frame (and then downscaled it to the stitch-keyframe budget) — far
29
+ too low-res for document OCR / detail capture. It now captures a
30
+ **full-resolution still** via `ARSession.captureHighResolutionFrame`
31
+ (iOS 16+), falling back to the live frame on older OS. Non-AR capture is
32
+ unchanged.
33
+ - **AR overlay `worldQuad` outline thickness.** The outline drew as 1px
34
+ SceneKit `.line` primitives (unscalable). Edges now render as thin cylinders
35
+ so the outline is actually visible.
36
+
17
37
  ## [0.20.0] — 2026-06-20
18
38
 
19
39
  ### Added — AR overlay / annotation renderer
@@ -388,35 +388,24 @@ public final class RNSARCameraView: UIView, ARSCNViewDelegate {
388
388
  return node
389
389
  }
390
390
 
391
- /// A 3D line-loop through corners expressed RELATIVE to the anchor origin
392
- /// (the anchor is at the quad centroid), with an optional camera-facing
393
- /// label at the centre.
391
+ /// A THICK 3D outline through corners expressed RELATIVE to the anchor
392
+ /// origin (anchor at the quad centroid). Each edge is a thin cylinder —
393
+ /// SceneKit `.line` primitives are always 1px and unscalable, so a visible
394
+ /// outline must be real geometry. Optional camera-facing label at centre.
394
395
  private static func makeQuadOutlineNode(
395
396
  relCorners: [simd_float3],
396
397
  color: UIColor,
397
398
  label: String?
398
399
  ) -> SCNNode {
399
- let vertices = relCorners.map { SCNVector3($0.x, $0.y, $0.z) }
400
- var indices: [Int32] = []
401
- let n = vertices.count
402
- indices.reserveCapacity(n * 2)
400
+ let node = SCNNode()
401
+ node.renderingOrder = 1000
402
+ let n = relCorners.count
403
403
  for i in 0..<n {
404
- indices.append(Int32(i))
405
- indices.append(Int32((i + 1) % n)) // close the loop
404
+ if let edge = edgeCylinder(
405
+ from: relCorners[i], to: relCorners[(i + 1) % n], color: color) {
406
+ node.addChildNode(edge)
407
+ }
406
408
  }
407
- let src = SCNGeometrySource(vertices: vertices)
408
- let elem = SCNGeometryElement(indices: indices, primitiveType: .line)
409
- let geo = SCNGeometry(sources: [src], elements: [elem])
410
- let mat = SCNMaterial()
411
- mat.diffuse.contents = color
412
- mat.lightingModel = .constant
413
- mat.writesToDepthBuffer = false
414
- mat.readsFromDepthBuffer = false
415
- geo.firstMaterial = mat
416
-
417
- let node = SCNNode(geometry: geo)
418
- node.renderingOrder = 1000
419
-
420
409
  if let label = label, !label.isEmpty {
421
410
  // Label at the centroid (≈ local origin in relative space).
422
411
  let labelNode = makeBillboardNode(
@@ -427,6 +416,37 @@ public final class RNSARCameraView: UIView, ARSCNViewDelegate {
427
416
  return node
428
417
  }
429
418
 
419
+ /// A thin cylinder (≈4 mm) spanning two points — one edge of a quad
420
+ /// outline. SCNCylinder's axis is +Y, so we centre it at the midpoint and
421
+ /// rotate +Y onto the edge direction.
422
+ private static func edgeCylinder(
423
+ from a: simd_float3, to b: simd_float3, color: UIColor
424
+ ) -> SCNNode? {
425
+ let d = b - a
426
+ let len = simd_length(d)
427
+ guard len > 1e-5 else { return nil }
428
+ let cyl = SCNCylinder(radius: 0.004, height: CGFloat(len))
429
+ let mat = SCNMaterial()
430
+ mat.diffuse.contents = color
431
+ mat.lightingModel = .constant
432
+ mat.writesToDepthBuffer = false
433
+ mat.readsFromDepthBuffer = false
434
+ cyl.firstMaterial = mat
435
+ let node = SCNNode(geometry: cyl)
436
+ node.renderingOrder = 1000
437
+ node.simdPosition = (a + b) * 0.5
438
+ let yAxis = simd_float3(0, 1, 0)
439
+ let dir = d / len
440
+ let dot = simd_dot(yAxis, dir)
441
+ if dot < -0.9999 {
442
+ node.simdOrientation = simd_quatf(angle: .pi, axis: simd_float3(1, 0, 0))
443
+ } else if dot < 0.9999 {
444
+ let axis = simd_normalize(simd_cross(yAxis, dir))
445
+ node.simdOrientation = simd_quatf(angle: acos(dot), axis: axis)
446
+ }
447
+ return node
448
+ }
449
+
430
450
  /// Render a stroked rounded-rect outline + optional centred label chip to
431
451
  /// a square image, used as the billboard plane's texture. Transparent
432
452
  /// background so only the outline + chip show over the camera feed.
@@ -1103,54 +1103,67 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
1103
1103
  } else {
1104
1104
  resolvedPath = rawPath
1105
1105
  }
1106
- guard let frame = arSession.currentFrame else {
1107
- completion(nil, NSError(
1108
- domain: "RNImageStitcherARCapture",
1109
- code: 2001,
1110
- userInfo: [NSLocalizedDescriptionKey:
1111
- "AR session has no current frame start the session first."]
1112
- ))
1113
- return
1106
+ // Capture a HIGH-RESOLUTION still. ARKit's live `capturedImage` is the
1107
+ // small AR video format (and the SDK picks the SMALLEST 4:3 format), far
1108
+ // too low-res for document OCR / detail capture.
1109
+ // `captureHighResolutionFrame` (iOS 16+) grabs a full-resolution photo
1110
+ // WITHOUT leaving the AR session. Fall back to the live frame on older
1111
+ // OS or if the high-res grab fails.
1112
+ let encode: (CVPixelBuffer) -> Void = { [weak self] pixelBuffer in
1113
+ self?.encodeArPhoto(
1114
+ pixelBuffer: pixelBuffer,
1115
+ toPath: resolvedPath,
1116
+ quality: quality,
1117
+ orientation: orientation,
1118
+ completion: completion
1119
+ )
1114
1120
  }
1115
- let pixelBuffer = frame.capturedImage
1121
+ if #available(iOS 16.0, *) {
1122
+ arSession.captureHighResolutionFrame { [weak self] hiResFrame, error in
1123
+ if let hiResFrame = hiResFrame {
1124
+ encode(hiResFrame.capturedImage)
1125
+ } else if let live = self?.arSession.currentFrame {
1126
+ encode(live.capturedImage)
1127
+ } else {
1128
+ completion(nil, NSError(
1129
+ domain: "RNImageStitcherARCapture",
1130
+ code: 2001,
1131
+ userInfo: [NSLocalizedDescriptionKey:
1132
+ "AR high-res capture failed: \(error?.localizedDescription ?? "no current frame")."]
1133
+ ))
1134
+ }
1135
+ }
1136
+ } else {
1137
+ guard let frame = arSession.currentFrame else {
1138
+ completion(nil, NSError(
1139
+ domain: "RNImageStitcherARCapture",
1140
+ code: 2001,
1141
+ userInfo: [NSLocalizedDescriptionKey:
1142
+ "AR session has no current frame — start the session first."]
1143
+ ))
1144
+ return
1145
+ }
1146
+ encode(frame.capturedImage)
1147
+ }
1148
+ }
1116
1149
 
1117
- // v0.12.0 Pre-v0.12 this method hardcoded `.right` (90° CW)
1118
- // to rotate-to-portrait, assuming the user always held the
1119
- // phone in portrait. Under R2-lite the device can be in
1120
- // any orientation, so we pick the CIImage orientation per
1121
- // the JS-supplied `orientation` arg (from
1122
- // `useDeviceOrientation()`).
1123
- //
1124
- // Empirical mapping (on-device test 2026-05-28):
1125
- // portrait → .right (90° CW preserved from pre-v0.12)
1126
- // landscape-left → .up (sensor matches device tilt; no rotation)
1127
- // landscape-right → .down (180° — sensor opposite of device tilt)
1128
- // portrait-upside-down → .left (90° CCW)
1129
- //
1130
- // The landscape mapping (landscape-left → .up) was determined
1131
- // empirically and is the opposite of what Apple's ARKit
1132
- // pixel-buffer-orientation docs would imply. Likely because
1133
- // `useDeviceOrientation()` reports `landscape-left` via the
1134
- // `UIDeviceOrientation` convention (home indicator on user-
1135
- // right) while iOS's sensor-native orientation matches that
1136
- // tilt direction directly. Without this fix, AR-mode single
1137
- // photos in landscape come out upside-down.
1138
- // v0.12.0 — Pre-v0.12 this method hardcoded `.right` (90° CW)
1139
- // to rotate-to-portrait, assuming the user always held the
1140
- // phone in portrait. Under R2-lite the device can be in
1141
- // any orientation, so we pick the CIImage orientation per
1142
- // the JS-supplied `orientation` arg (from
1143
- // `useDeviceOrientation()`).
1144
- //
1145
- // Empirical mapping (on-device test 2026-05-28):
1146
- // portrait → .right (90° CW — preserved from pre-v0.12)
1147
- // landscape-left → .up (sensor matches device tilt; no rotation)
1148
- // landscape-right → .down (180° — sensor opposite of device tilt)
1149
- // portrait-upside-down → .left (90° CCW)
1150
- //
1151
- // The landscape mapping (landscape-left → .up) was determined
1152
- // empirically; the user reported AR landscape photos came out
1153
- // upside-down with .down and correctly upright with .up.
1150
+ /// Encode an AR pixel buffer an oriented, FULL-RESOLUTION JPEG at `path`.
1151
+ /// Unlike stitch keyframes, a user / document photo is NOT downscaled — OCR
1152
+ /// and detail capture need the full resolution that
1153
+ /// `captureHighResolutionFrame` provides.
1154
+ ///
1155
+ /// Orientation maps the JS `useDeviceOrientation()` value → CIImage
1156
+ /// orientation (empirical, on-device 2026-05-28): portrait → .right,
1157
+ /// landscape-left .up, landscape-right .down,
1158
+ /// portrait-upside-down → .left. Without this, AR-mode landscape photos
1159
+ /// come out upside-down.
1160
+ private func encodeArPhoto(
1161
+ pixelBuffer: CVPixelBuffer,
1162
+ toPath resolvedPath: String,
1163
+ quality: Int,
1164
+ orientation: String,
1165
+ completion: @escaping ([String: Any]?, NSError?) -> Void
1166
+ ) {
1154
1167
  let exifOrientation: CGImagePropertyOrientation
1155
1168
  switch orientation {
1156
1169
  case "landscape-left": exifOrientation = .up
@@ -1158,21 +1171,9 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
1158
1171
  case "portrait-upside-down": exifOrientation = .left
1159
1172
  default: exifOrientation = .right // portrait + unknown
1160
1173
  }
1161
- var ciImage = CIImage(cvPixelBuffer: pixelBuffer)
1162
- .oriented(exifOrientation)
1163
- // AR keyframe downscale guard — normalise long edge to the budget so
1164
- // every device produces a ~0.3 MP keyframe (cross-device-consistent
1165
- // stitch memory). Mirrors Android's downscale in YuvImageConverter.
1166
- let kfLongEdge = max(ciImage.extent.width, ciImage.extent.height)
1167
- if kfLongEdge > Self.arKeyframeMaxLongEdge {
1168
- let kfScale = Self.arKeyframeMaxLongEdge / kfLongEdge
1169
- ciImage = ciImage.transformed(by: CGAffineTransform(scaleX: kfScale, y: kfScale))
1170
- }
1174
+ let ciImage = CIImage(cvPixelBuffer: pixelBuffer).oriented(exifOrientation)
1171
1175
  let context = CIContext(options: nil)
1172
- guard let cgImage = context.createCGImage(
1173
- ciImage,
1174
- from: ciImage.extent
1175
- ) else {
1176
+ guard let cgImage = context.createCGImage(ciImage, from: ciImage.extent) else {
1176
1177
  completion(nil, NSError(
1177
1178
  domain: "RNImageStitcherARCapture",
1178
1179
  code: 2002,
@@ -1194,7 +1195,6 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
1194
1195
  ))
1195
1196
  return
1196
1197
  }
1197
-
1198
1198
  let cleanedPath = Self.normalisePath(resolvedPath)
1199
1199
  let url = URL(fileURLWithPath: cleanedPath)
1200
1200
  // Best-effort delete an existing file at the same path —
@@ -1574,8 +1574,13 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
1574
1574
  // simd_quatf from a 4x4 matrix uses the rotational part.
1575
1575
  let q = simd_quatf(t)
1576
1576
 
1577
- // Camera intrinsics. Apple gives us a 3x3 matrix where
1578
- // [0][0] = fx, [1][1] = fy, [0][2] = cx, [1][2] = cy.
1577
+ // Camera intrinsics. `simd_float3x3` subscripts as k[column][row]
1578
+ // (COLUMN-MAJOR). ARKit's K is:
1579
+ // column 0 = (fx, 0, 0), column 1 = (0, fy, 0), column 2 = (cx, cy, 1)
1580
+ // so fx = k[0][0], fy = k[1][1], cx = k[2][0], cy = k[2][1].
1581
+ // (Pre-0.20.1 bug: read cx/cy as k[0][2]/k[1][2] = 0 — fx/fy survived
1582
+ // because they're on the diagonal, so the principal point came through
1583
+ // as 0,0 and broke any pixel↔world unprojection.)
1579
1584
  let k = frame.camera.intrinsics
1580
1585
  let imageRes = frame.camera.imageResolution
1581
1586
 
@@ -1596,8 +1601,8 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
1596
1601
  qw: Double(q.real),
1597
1602
  fx: Double(k[0][0]),
1598
1603
  fy: Double(k[1][1]),
1599
- cx: Double(k[0][2]),
1600
- cy: Double(k[1][2]),
1604
+ cx: Double(k[2][0]),
1605
+ cy: Double(k[2][1]),
1601
1606
  imageWidth: Int(imageRes.width),
1602
1607
  imageHeight: Int(imageRes.height),
1603
1608
  timestampMs: frame.timestamp * 1000.0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
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",