react-native-image-stitcher 0.1.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 (151) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +21 -0
  4. package/README.md +189 -0
  5. package/RNImageStitcher.podspec +76 -0
  6. package/android/build.gradle +224 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/cpp/CMakeLists.txt +124 -0
  9. package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
  10. package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
  11. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
  12. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
  13. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
  14. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
  15. package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
  16. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
  17. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
  18. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
  19. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
  20. package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
  21. package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
  22. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
  23. package/cpp/ar_frame_pose.h +63 -0
  24. package/cpp/keyframe_gate.cpp +927 -0
  25. package/cpp/keyframe_gate.hpp +240 -0
  26. package/cpp/stitcher.cpp +2207 -0
  27. package/cpp/stitcher.hpp +275 -0
  28. package/dist/ar/useARSession.d.ts +102 -0
  29. package/dist/ar/useARSession.js +133 -0
  30. package/dist/camera/ARCameraView.d.ts +93 -0
  31. package/dist/camera/ARCameraView.js +170 -0
  32. package/dist/camera/Camera.d.ts +134 -0
  33. package/dist/camera/Camera.js +688 -0
  34. package/dist/camera/CameraShutter.d.ts +80 -0
  35. package/dist/camera/CameraShutter.js +237 -0
  36. package/dist/camera/CameraView.d.ts +65 -0
  37. package/dist/camera/CameraView.js +117 -0
  38. package/dist/camera/CaptureControlsBar.d.ts +87 -0
  39. package/dist/camera/CaptureControlsBar.js +82 -0
  40. package/dist/camera/CaptureHeader.d.ts +62 -0
  41. package/dist/camera/CaptureHeader.js +81 -0
  42. package/dist/camera/CapturePreview.d.ts +70 -0
  43. package/dist/camera/CapturePreview.js +188 -0
  44. package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
  45. package/dist/camera/CaptureStatusOverlay.js +326 -0
  46. package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
  47. package/dist/camera/CaptureThumbnailStrip.js +177 -0
  48. package/dist/camera/IncrementalPanGuide.d.ts +83 -0
  49. package/dist/camera/IncrementalPanGuide.js +267 -0
  50. package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
  51. package/dist/camera/PanoramaBandOverlay.js +399 -0
  52. package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
  53. package/dist/camera/PanoramaConfirmModal.js +128 -0
  54. package/dist/camera/PanoramaGuidance.d.ts +79 -0
  55. package/dist/camera/PanoramaGuidance.js +246 -0
  56. package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
  57. package/dist/camera/PanoramaSettingsModal.js +611 -0
  58. package/dist/camera/ViewportCropOverlay.d.ts +46 -0
  59. package/dist/camera/ViewportCropOverlay.js +67 -0
  60. package/dist/camera/useCapture.d.ts +111 -0
  61. package/dist/camera/useCapture.js +160 -0
  62. package/dist/camera/useDeviceOrientation.d.ts +48 -0
  63. package/dist/camera/useDeviceOrientation.js +131 -0
  64. package/dist/camera/useVideoCapture.d.ts +79 -0
  65. package/dist/camera/useVideoCapture.js +151 -0
  66. package/dist/index.d.ts +26 -0
  67. package/dist/index.js +39 -0
  68. package/dist/quality/normaliseOrientation.d.ts +36 -0
  69. package/dist/quality/normaliseOrientation.js +62 -0
  70. package/dist/quality/runQualityCheck.d.ts +41 -0
  71. package/dist/quality/runQualityCheck.js +98 -0
  72. package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
  73. package/dist/sensors/useIMUTranslationGate.js +235 -0
  74. package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
  75. package/dist/stitching/IncrementalStitcherView.js +157 -0
  76. package/dist/stitching/incremental.d.ts +930 -0
  77. package/dist/stitching/incremental.js +133 -0
  78. package/dist/stitching/stitchFrames.d.ts +55 -0
  79. package/dist/stitching/stitchFrames.js +56 -0
  80. package/dist/stitching/stitchVideo.d.ts +119 -0
  81. package/dist/stitching/stitchVideo.js +57 -0
  82. package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
  83. package/dist/stitching/useIncrementalJSDriver.js +199 -0
  84. package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
  85. package/dist/stitching/useIncrementalStitcher.js +172 -0
  86. package/dist/types.d.ts +58 -0
  87. package/dist/types.js +15 -0
  88. package/ios/Package.swift +72 -0
  89. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
  90. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
  91. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
  92. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
  93. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
  94. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
  95. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
  96. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
  97. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
  98. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
  99. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
  101. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
  102. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
  105. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
  106. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
  107. package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
  108. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
  109. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
  110. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
  111. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
  112. package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
  113. package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
  114. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
  115. package/package.json +73 -0
  116. package/react-native.config.js +34 -0
  117. package/scripts/opencv-version.txt +1 -0
  118. package/scripts/postinstall-fetch-binaries.js +286 -0
  119. package/src/ar/useARSession.ts +210 -0
  120. package/src/camera/.gitkeep +0 -0
  121. package/src/camera/ARCameraView.tsx +256 -0
  122. package/src/camera/Camera.tsx +1053 -0
  123. package/src/camera/CameraShutter.tsx +292 -0
  124. package/src/camera/CameraView.tsx +157 -0
  125. package/src/camera/CaptureControlsBar.tsx +204 -0
  126. package/src/camera/CaptureHeader.tsx +184 -0
  127. package/src/camera/CapturePreview.tsx +318 -0
  128. package/src/camera/CaptureStatusOverlay.tsx +391 -0
  129. package/src/camera/CaptureThumbnailStrip.tsx +277 -0
  130. package/src/camera/IncrementalPanGuide.tsx +328 -0
  131. package/src/camera/PanoramaBandOverlay.tsx +498 -0
  132. package/src/camera/PanoramaConfirmModal.tsx +206 -0
  133. package/src/camera/PanoramaGuidance.tsx +327 -0
  134. package/src/camera/PanoramaSettingsModal.tsx +1357 -0
  135. package/src/camera/ViewportCropOverlay.tsx +81 -0
  136. package/src/camera/useCapture.ts +279 -0
  137. package/src/camera/useDeviceOrientation.ts +140 -0
  138. package/src/camera/useVideoCapture.ts +236 -0
  139. package/src/index.ts +53 -0
  140. package/src/quality/.gitkeep +0 -0
  141. package/src/quality/normaliseOrientation.ts +79 -0
  142. package/src/quality/runQualityCheck.ts +131 -0
  143. package/src/sensors/useIMUTranslationGate.ts +347 -0
  144. package/src/stitching/.gitkeep +0 -0
  145. package/src/stitching/IncrementalStitcherView.tsx +198 -0
  146. package/src/stitching/incremental.ts +1021 -0
  147. package/src/stitching/stitchFrames.ts +88 -0
  148. package/src/stitching/stitchVideo.ts +153 -0
  149. package/src/stitching/useIncrementalJSDriver.ts +273 -0
  150. package/src/stitching/useIncrementalStitcher.ts +252 -0
  151. package/src/types.ts +78 -0
@@ -0,0 +1,252 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // QualityChecker.swift
3
+ //
4
+ // Pure-Swift implementations of the on-device quality scores the SDK
5
+ // surfaces to the JS layer. No React Native dependency in this file —
6
+ // the bridge wrapper lives in `QualityCheckerBridge.swift` and only
7
+ // translates promise resolves into the same numeric scores this file
8
+ // produces. Keeping the bridge thin means XCTest hits the algorithms
9
+ // directly (via SwiftPM `swift test`) instead of needing an iOS
10
+ // simulator + RN runtime to validate a Laplacian variance.
11
+ //
12
+ // Why this layer at all (instead of OpenCV)?
13
+ // Phase-1 quality checks (blur + brightness) are a tiny slice of
14
+ // what OpenCV does and Apple already ships the primitives in the OS
15
+ // (Accelerate's vImage convolutions, CoreImage's CIAreaAverage).
16
+ // Pulling in opencv-mobile only for two filters would add ~10 MB to
17
+ // the IPA for no functional gain — opencv-mobile gets paid for in
18
+ // Phase 2 when stitching arrives, where Apple has no equivalent.
19
+ //
20
+ // Algorithm references:
21
+ // * Blur: variance-of-Laplacian. Pech-Pacheco et al. 2000,
22
+ // "Diatom autofocusing in brightfield microscopy: a comparative
23
+ // study." Threshold ~50–100 separates "soft" from "blurry" for
24
+ // mobile shelf imagery in our pilot data.
25
+ // * Brightness: mean luminance. Underexposed = mean < 60,
26
+ // overexposed = mean > 200, both unusable for downstream OCR.
27
+
28
+ import Accelerate
29
+ import CoreGraphics
30
+ import CoreImage
31
+ import Foundation
32
+
33
+ /// Errors the quality check can surface to the bridge. Each maps to a
34
+ /// dedicated reject-code on the JS side so the host app can branch on
35
+ /// "missing file" vs. "couldn't decode" vs. "internal".
36
+ public enum QualityCheckError: Error {
37
+ case fileNotFound(path: String)
38
+ case imageDecodeFailed(path: String)
39
+ case bufferAllocationFailed
40
+ case convolutionFailed(vImageError: vImage_Error)
41
+ }
42
+
43
+ /// Numeric quality measurements; mirrors the QualityReport TS shape.
44
+ public struct QualityScores: Equatable {
45
+ /// Variance of the Laplacian. Higher = sharper. Implementation
46
+ /// returns a non-negative Double; +∞ is reserved for the JS shim
47
+ /// fallback so production code can distinguish "we measured this
48
+ /// and it's astonishingly sharp" from "we never measured it."
49
+ public let blurScore: Double
50
+
51
+ /// Mean luminance in [0, 255]. 255 = pure white, 0 = pure black.
52
+ /// We use the standard ITU-R BT.601 luma weights (0.299 R + 0.587 G +
53
+ /// 0.114 B) — vImage's RGB-to-Y conversion uses the same weights so
54
+ /// keeping them here means the mean we compute matches the mean
55
+ /// vImage would produce had we asked it directly.
56
+ public let brightnessScore: Double
57
+
58
+ public init(blurScore: Double, brightnessScore: Double) {
59
+ self.blurScore = blurScore
60
+ self.brightnessScore = brightnessScore
61
+ }
62
+ }
63
+
64
+ public enum QualityChecker {
65
+
66
+ /// Measure both blur and brightness in one decode pass.
67
+ ///
68
+ /// Call sites that only need one score (rare — both are cheap once
69
+ /// the image is decoded) can ignore the unused field. Bundling the
70
+ /// API means we decode + convert-to-grayscale exactly once per call,
71
+ /// which is the bulk of the work; reading the grayscale buffer twice
72
+ /// to extract the two stats is essentially free.
73
+ public static func measure(imagePath: String) throws -> QualityScores {
74
+ let cgImage = try decodeImage(at: imagePath)
75
+ let grayBuffer = try makeGrayscaleBuffer(from: cgImage)
76
+ defer { grayBuffer.free() }
77
+
78
+ let brightness = grayBuffer.mean()
79
+ let blur = try grayBuffer.varianceOfLaplacian()
80
+ return QualityScores(blurScore: blur, brightnessScore: brightness)
81
+ }
82
+
83
+ /// Convenience: measure blur only. Provided so unit tests can call
84
+ /// the algorithm in isolation without invoking the brightness path.
85
+ public static func measureBlurScore(imagePath: String) throws -> Double {
86
+ return try measure(imagePath: imagePath).blurScore
87
+ }
88
+
89
+ /// Convenience: measure brightness only.
90
+ public static func measureBrightness(imagePath: String) throws -> Double {
91
+ return try measure(imagePath: imagePath).brightnessScore
92
+ }
93
+
94
+ // MARK: - Internal helpers
95
+
96
+ /// Decode the file at `imagePath` into a CGImage. Strips any
97
+ /// `file://` prefix the bridge may have passed through unchanged so
98
+ /// callers don't have to remember whether the SDK wants a URL or a
99
+ /// path.
100
+ private static func decodeImage(at imagePath: String) throws -> CGImage {
101
+ let cleaned = imagePath.hasPrefix("file://")
102
+ ? String(imagePath.dropFirst("file://".count))
103
+ : imagePath
104
+ guard FileManager.default.fileExists(atPath: cleaned) else {
105
+ throw QualityCheckError.fileNotFound(path: imagePath)
106
+ }
107
+ let url = URL(fileURLWithPath: cleaned)
108
+ guard
109
+ let source = CGImageSourceCreateWithURL(url as CFURL, nil),
110
+ let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil)
111
+ else {
112
+ throw QualityCheckError.imageDecodeFailed(path: imagePath)
113
+ }
114
+ return cgImage
115
+ }
116
+
117
+ /// Convert a CGImage to an 8-bit single-channel grayscale buffer.
118
+ ///
119
+ /// Why vImage instead of CoreImage's CIPhotoEffectMono filter?
120
+ /// CIFilter chains are deferred and lazy — we'd have to render
121
+ /// anyway to read the pixels back out. vImage gives us an actual
122
+ /// contiguous Y' buffer in one shot, which is exactly what the
123
+ /// blur/brightness stats want.
124
+ private static func makeGrayscaleBuffer(from cgImage: CGImage) throws -> GrayscaleBuffer {
125
+ var format = vImage_CGImageFormat(
126
+ bitsPerComponent: 8,
127
+ bitsPerPixel: 8,
128
+ colorSpace: Unmanaged.passRetained(CGColorSpaceCreateDeviceGray()),
129
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue),
130
+ version: 0,
131
+ decode: nil,
132
+ renderingIntent: .defaultIntent
133
+ )
134
+ var buffer = vImage_Buffer()
135
+ let createErr = vImageBuffer_InitWithCGImage(
136
+ &buffer, &format, nil, cgImage, vImage_Flags(kvImageNoFlags)
137
+ )
138
+ guard createErr == kvImageNoError else {
139
+ throw QualityCheckError.bufferAllocationFailed
140
+ }
141
+ return GrayscaleBuffer(buffer: buffer)
142
+ }
143
+ }
144
+
145
+ /// RAII wrapper around vImage_Buffer + the algorithms that read it.
146
+ /// Owns the underlying pixel memory so callers `defer { buf.free() }`
147
+ /// after construction.
148
+ struct GrayscaleBuffer {
149
+ var buffer: vImage_Buffer
150
+
151
+ func free() {
152
+ if buffer.data != nil { Foundation.free(buffer.data) }
153
+ }
154
+
155
+ /// Mean pixel value across the buffer, [0, 255]. vImage doesn't
156
+ /// expose a direct mean function for Planar8 so we walk the rows
157
+ /// manually with vDSP_meanv on each row's bytes-as-floats.
158
+ func mean() -> Double {
159
+ let width = Int(buffer.width)
160
+ let height = Int(buffer.height)
161
+ let rowBytes = buffer.rowBytes
162
+ let basePtr = buffer.data.assumingMemoryBound(to: UInt8.self)
163
+
164
+ var total: Double = 0
165
+ var count: Double = 0
166
+ var rowAccumulator = [Float](repeating: 0, count: width)
167
+ for y in 0..<height {
168
+ let rowPtr = basePtr.advanced(by: y * rowBytes)
169
+ // vImageConvert_Planar8toPlanarF would do the type conversion in
170
+ // bulk; for the per-row mean the manual loop is faster than
171
+ // setting up a vImage call per row.
172
+ for x in 0..<width {
173
+ rowAccumulator[x] = Float(rowPtr[x])
174
+ }
175
+ var rowMean: Float = 0
176
+ vDSP_meanv(rowAccumulator, 1, &rowMean, vDSP_Length(width))
177
+ total += Double(rowMean)
178
+ count += 1
179
+ }
180
+ return count > 0 ? total / count : 0
181
+ }
182
+
183
+ /// Variance-of-Laplacian — the canonical "how blurry is this" score.
184
+ ///
185
+ /// Steps:
186
+ /// 1. Convolve the grayscale buffer with the discrete Laplacian
187
+ /// kernel. Output = high-frequency / edge response.
188
+ /// 2. Compute the variance of the convolved buffer. Sharp images
189
+ /// have lots of edges with widely varying magnitudes; blurry
190
+ /// images have weak, similar values throughout.
191
+ func varianceOfLaplacian() throws -> Double {
192
+ let width = buffer.width
193
+ let height = buffer.height
194
+
195
+ // Allocate the output buffer. vImageConvolve_Planar8 writes the
196
+ // convolved bytes into a new buffer of the same dimensions.
197
+ var output = vImage_Buffer()
198
+ let outputBytes = Int(height) * Int(width)
199
+ output.data = malloc(outputBytes)
200
+ output.width = width
201
+ output.height = height
202
+ output.rowBytes = Int(width)
203
+ guard output.data != nil else {
204
+ throw QualityCheckError.bufferAllocationFailed
205
+ }
206
+ defer { Foundation.free(output.data) }
207
+
208
+ // Discrete Laplacian. Sums to zero so the convolved buffer's
209
+ // mean is ~0 except where edges live; variance then quantifies
210
+ // edge response — exactly the "is this image sharp" question.
211
+ // 0 -1 0
212
+ // -1 4 -1
213
+ // 0 -1 0
214
+ let kernel: [Int16] = [0, -1, 0, -1, 4, -1, 0, -1, 0]
215
+ let divisor: Int32 = 1
216
+
217
+ // vImageConvolve_Planar8 takes mutable buffer pointers; copy into
218
+ // local vars so we can hand it inout refs without violating Swift's
219
+ // exclusive-access rules on `self.buffer`.
220
+ var input = buffer
221
+ let convErr = kernel.withUnsafeBufferPointer { kernelPtr -> vImage_Error in
222
+ // backgroundColor is unused with kvImageEdgeExtend (we extend
223
+ // edge pixels rather than padding) — pass 0 as a placeholder.
224
+ vImageConvolve_Planar8(
225
+ &input, &output, nil, 0, 0,
226
+ kernelPtr.baseAddress, 3, 3,
227
+ divisor, 0,
228
+ vImage_Flags(kvImageEdgeExtend)
229
+ )
230
+ }
231
+ guard convErr == kvImageNoError else {
232
+ throw QualityCheckError.convolutionFailed(vImageError: convErr)
233
+ }
234
+
235
+ // Compute variance over the output pixels. vImage gives us the
236
+ // bytes; vDSP gives us the stats. Convert U8 → F32 in one bulk op
237
+ // so vDSP_normalize / vDSP_measqv can run native.
238
+ let count = Int(width) * Int(height)
239
+ var floatBuffer = [Float](repeating: 0, count: count)
240
+ let outBytes = output.data.assumingMemoryBound(to: UInt8.self)
241
+ for i in 0..<count {
242
+ floatBuffer[i] = Float(outBytes[i])
243
+ }
244
+
245
+ var mean: Float = 0
246
+ var meanSquare: Float = 0
247
+ vDSP_meanv(floatBuffer, 1, &mean, vDSP_Length(count))
248
+ vDSP_measqv(floatBuffer, 1, &meanSquare, vDSP_Length(count))
249
+ let variance = Double(meanSquare) - Double(mean) * Double(mean)
250
+ return max(0, variance)
251
+ }
252
+ }
@@ -0,0 +1,26 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // QualityCheckerBridge.m
4
+ //
5
+ // Obj-C glue that registers the Swift `QualityCheckerBridge`
6
+ // class with the React Native module map. React Native's
7
+ // `RCT_EXTERN_MODULE` and `RCT_EXTERN_METHOD` are C macros — they
8
+ // can't be invoked from Swift directly — so a `.m` shim is required
9
+ // even though the actual implementation is Swift.
10
+ //
11
+ // The first arg to RCT_EXTERN_MODULE is the Obj-C-visible class name.
12
+ // Marking the Swift class `@objc(RNImageStitcherQualityChecker)` aliases it
13
+ // to the same name on the Obj-C side, which means the JS layer sees
14
+ // `NativeModules.RNImageStitcherQualityChecker` regardless of the Swift
15
+ // class's actual Swift-side name.
16
+ //
17
+
18
+ #import <React/RCTBridgeModule.h>
19
+
20
+ @interface RCT_EXTERN_MODULE(RNImageStitcherQualityChecker, NSObject)
21
+
22
+ RCT_EXTERN_METHOD(measure:(NSString *)imagePath
23
+ resolver:(RCTPromiseResolveBlock)resolver
24
+ rejecter:(RCTPromiseRejectBlock)rejecter)
25
+
26
+ @end
@@ -0,0 +1,72 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // QualityCheckerBridge.swift
3
+ //
4
+ // React Native bridge for `QualityChecker`. This file does NOT contain
5
+ // algorithm code — it converts JS-side promise calls into the pure
6
+ // Swift API in `QualityChecker.swift` and converts the resulting
7
+ // scores or errors back into a shape the JS layer can consume.
8
+ //
9
+ // Pairing pattern:
10
+ // * QualityChecker.swift — pure Swift, XCTest-able, no RN.
11
+ // * QualityCheckerBridge.swift (this file) — RN-aware, registered
12
+ // via `@objc(RNImageStitcherQualityChecker)` so the JS shim can find it
13
+ // at NativeModules.RNImageStitcherQualityChecker.
14
+ //
15
+ // Why two files instead of one?
16
+ // The bridge depends on React (RCTPromiseResolveBlock,
17
+ // RCTBridgeModule). XCTest in SwiftPM mode can't resolve those
18
+ // without an Xcode workspace. Splitting means the algorithm tests
19
+ // build clean from the command line, and only the bridge requires
20
+ // a host-app context to compile (which it already has, via the
21
+ // mobile app's Pods workspace).
22
+
23
+ #if canImport(React)
24
+ import Foundation
25
+ import React
26
+
27
+ @objc(RNImageStitcherQualityChecker)
28
+ public class QualityCheckerBridge: NSObject {
29
+
30
+ // RCT_EXPORT_MODULE — the Obj-C bridge file picks up this name and
31
+ // registers it with the JS module map. Returning false here means
32
+ // the module's queue methods aren't required to run on the main
33
+ // thread, which we want — image decode is CPU work.
34
+ @objc public static func requiresMainQueueSetup() -> Bool { return false }
35
+
36
+ /// Bridged entry: measure both blur + brightness and resolve with
37
+ /// `{ blurScore, brightnessScore }`. Reject with a stable code so
38
+ /// the JS layer can branch (e.g. "missing-file" can fall back to a
39
+ /// retry, "decode-failed" usually can't).
40
+ @objc(measure:resolver:rejecter:)
41
+ public func measure(
42
+ imagePath: NSString,
43
+ resolver: @escaping RCTPromiseResolveBlock,
44
+ rejecter: @escaping RCTPromiseRejectBlock
45
+ ) {
46
+ do {
47
+ let scores = try QualityChecker.measure(imagePath: imagePath as String)
48
+ resolver([
49
+ "blurScore": scores.blurScore,
50
+ "brightnessScore": scores.brightnessScore,
51
+ ])
52
+ } catch let err as QualityCheckError {
53
+ switch err {
54
+ case .fileNotFound(let path):
55
+ rejecter("file-not-found", "File not found at path: \(path)", err)
56
+ case .imageDecodeFailed(let path):
57
+ rejecter("decode-failed", "Could not decode image at \(path)", err)
58
+ case .bufferAllocationFailed:
59
+ rejecter("buffer-alloc-failed", "Failed to allocate pixel buffer", err)
60
+ case .convolutionFailed(let vImageError):
61
+ rejecter(
62
+ "convolution-failed",
63
+ "vImage convolution failed (error \(vImageError))",
64
+ err
65
+ )
66
+ }
67
+ } catch {
68
+ rejecter("unknown", "Unexpected error: \(error)", error)
69
+ }
70
+ }
71
+ }
72
+ #endif
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // RNSARCameraView — native UIView that renders the AR camera
4
+ // feed for the SDK's pose-aware capture surface.
5
+ //
6
+ // Phase 4.4 of the AR measurement plan. This is the camera-access
7
+ // handoff: vision-camera owns the camera in non-AR audits, ARKit
8
+ // owns it in AR audits. React Native picks which CameraView the
9
+ // host mounts; the host never sees both at once.
10
+ //
11
+ // Why ARSCNView vs custom Metal:
12
+ // We just need to render `ARFrame.capturedImage` to screen — no
13
+ // 3D content overlays, no SceneKit nodes. Custom Metal would be
14
+ // ~150 lines of MTKView setup + a textured-quad shader. ARSCNView
15
+ // does the same thing in 2 lines: it's a UIKit view that auto-
16
+ // renders the camera feed as the SceneKit background whenever its
17
+ // `session` property points at a running ARSession. Phase 5
18
+ // stitching consumes pose data, NOT pixels-from-Metal, so we
19
+ // never need the lower-level rendering control.
20
+ //
21
+ // Why a wrapper UIView (vs. exposing ARSCNView directly):
22
+ // RCTViewManager expects to vend a UIView subclass it owns.
23
+ // Wrapping ARSCNView lets us:
24
+ // - resize its frame to match the React Native layout in
25
+ // `layoutSubviews` (auto-resizing masks alone don't always
26
+ // fire when RN's flexbox engine assigns a new bounds rect),
27
+ // - add lifecycle hooks (start/stop the singleton AR session
28
+ // when the view enters / leaves the window hierarchy), and
29
+ // - keep room for future overlays (tracking-state HUD, focus
30
+ // indicator, etc.) without touching ARSCNView internals.
31
+
32
+ import Foundation
33
+ import ARKit
34
+ import UIKit
35
+
36
+
37
+ @objc(RNSARCameraView)
38
+ public final class RNSARCameraView: UIView {
39
+
40
+ /// The ARSCNView that does the actual rendering. Bound to the
41
+ /// singleton's ARSession so all preview surfaces share the same
42
+ /// session (and the same pose log that the stitcher consumes).
43
+ private var arSCNView: ARSCNView!
44
+
45
+ public override init(frame: CGRect) {
46
+ super.init(frame: frame)
47
+ setupView()
48
+ }
49
+
50
+ public required init?(coder: NSCoder) {
51
+ super.init(coder: coder)
52
+ setupView()
53
+ }
54
+
55
+ private func setupView() {
56
+ arSCNView = ARSCNView(frame: bounds)
57
+ arSCNView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
58
+
59
+ // Bind to the singleton's session. This is the critical
60
+ // line — without it, ARSCNView would try to create its own
61
+ // session and we'd lose the pose log. Sharing means:
62
+ // - The host's `useARSession` hook still drives lifecycle.
63
+ // - Pose data captured via `RNSARSession.shared`'s
64
+ // delegate callbacks remains intact; this view is purely
65
+ // a renderer.
66
+ arSCNView.session = RNSARSession.shared.arSession
67
+
68
+ // We don't draw any 3D content in Phase 4.4. Disable
69
+ // SceneKit's automatic statistics overlay and lighting model
70
+ // — we just want the camera feed.
71
+ arSCNView.showsStatistics = false
72
+ arSCNView.automaticallyUpdatesLighting = false
73
+
74
+ // Black background while ARKit is initialising so the user
75
+ // sees a clean frame instead of whatever was there before.
76
+ backgroundColor = .black
77
+ addSubview(arSCNView)
78
+ }
79
+
80
+ public override func layoutSubviews() {
81
+ super.layoutSubviews()
82
+ // RN's flexbox can re-bound this view at any time; keep the
83
+ // ARSCNView locked to our bounds. autoresizingMask handles
84
+ // most cases but isn't always enough on rotation transitions.
85
+ arSCNView.frame = bounds
86
+ }
87
+
88
+ public override func didMoveToWindow() {
89
+ super.didMoveToWindow()
90
+ // When this view enters the hierarchy, ensure the AR session
91
+ // is running. When it leaves, stop the session so the
92
+ // hardware camera is freed for vision-camera or other uses.
93
+ //
94
+ // The singleton's start/stop are idempotent, so multiple
95
+ // ARCameraView instances mounting/unmounting won't fight
96
+ // each other (last-mount-wins semantics). In practice the
97
+ // host only mounts one at a time.
98
+ if window != nil {
99
+ if !RNSARSession.shared.isRunning {
100
+ RNSARSession.shared.start()
101
+ }
102
+ } else {
103
+ // Removed from window — stop the session. Don't clear
104
+ // the pose log here; the host explicitly clears between
105
+ // captures via `RNSARSession.shared.clearPoseLog()`
106
+ // so the JS layer controls when poses get discarded.
107
+ if RNSARSession.shared.isRunning {
108
+ RNSARSession.shared.stop()
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+