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
|
|
392
|
-
/// (
|
|
393
|
-
///
|
|
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
|
|
400
|
-
|
|
401
|
-
let n =
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
|
|
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.
|
|
1578
|
-
//
|
|
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[
|
|
1600
|
-
cy: Double(k[
|
|
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.
|
|
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",
|