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,256 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn
3
+
4
+ /**
5
+ * Kotlin facade over the shared C++ KeyframeGate (in
6
+ * retailens-capture-sdk/cpp/keyframe_gate.{hpp,cpp}).
7
+ *
8
+ * Architecture parity with iOS:
9
+ * iOS uses an Obj-C++ bridge (KeyframeGateBridge.mm) to wrap the
10
+ * same C++ class. This Kotlin class is the Android equivalent —
11
+ * thin facade, JNI plumbing, identical public surface. Both
12
+ * platforms call into the same C++ algorithm, so panorama
13
+ * composition decisions are bit-identical across platforms (the
14
+ * whole point of the P3 work).
15
+ *
16
+ * Lifecycle:
17
+ * Each instance owns one C++ KeyframeGate via a `Long` opaque
18
+ * handle. Caller MUST call [close] before the instance is GC'd,
19
+ * otherwise we leak a small heap allocation per gate-instance.
20
+ * Practice on Android: KeyframeGate is held by
21
+ * IncrementalStitcher as a member; we add cleanup hook
22
+ * in `onCatalystInstanceDestroy()` so the JNI native heap stays
23
+ * bounded across RN reloads.
24
+ *
25
+ * Threading:
26
+ * The underlying C++ class is NOT thread-safe. Caller MUST
27
+ * serialise — typically via the engine's workScope serial
28
+ * dispatcher. Same contract the iOS side has.
29
+ *
30
+ * Reason-string parity:
31
+ * The JS layer reads `decision.reason` for telemetry; that string
32
+ * value MUST match iOS byte-for-byte or the UI pill drifts
33
+ * silently. The mapping is centralised in [reasonFromCode] —
34
+ * 1:1 with `KeyframeGateBridge.mm::kReasonStringFor` on iOS.
35
+ * Drift here is a parity bug.
36
+ */
37
+ internal class KeyframeGate : AutoCloseable {
38
+
39
+ private val nativeHandle: Long = nativeCreate()
40
+
41
+ @Volatile private var closed: Boolean = false
42
+
43
+ override fun close() {
44
+ if (!closed) {
45
+ closed = true
46
+ nativeDestroy(nativeHandle)
47
+ }
48
+ }
49
+
50
+ // Defensive net for missed close() calls. Kotlin/JVM finalizers
51
+ // are unreliable but better than nothing — they prevent a slow
52
+ // native-heap leak in pathological "module rebuilt many times
53
+ // without explicit cleanup" cases. Always prefer explicit close().
54
+ @Suppress("DEPRECATION")
55
+ protected fun finalize() {
56
+ close()
57
+ }
58
+
59
+ // ── Settings ────────────────────────────────────────────────
60
+
61
+ var enabled: Boolean
62
+ get() = nativeIsEnabled(nativeHandle)
63
+ set(value) = nativeSetEnabled(nativeHandle, value)
64
+
65
+ /// Required new-content fraction (0…1). Default 0.4. No getter
66
+ /// — the C++ side has no read accessor (Swift side never needed
67
+ /// to read this back either). Stored locally for diagnostic
68
+ /// readbacks; written into C++ via setter.
69
+ var overlapThreshold: Double = 0.4
70
+ set(value) {
71
+ field = value
72
+ nativeSetOverlapThreshold(nativeHandle, value)
73
+ }
74
+
75
+ var maxCount: Int
76
+ get() = nativeGetMaxCount(nativeHandle)
77
+ set(value) = nativeSetMaxCount(nativeHandle, value)
78
+
79
+ /// One-shot write-only trigger. Setting `true` arms the next
80
+ /// evaluate() to force-accept; the trigger is consumed inside
81
+ /// the C++ gate. Reading always returns false (matches the
82
+ /// iOS Swift facade's behaviour).
83
+ var forceAcceptNext: Boolean
84
+ get() = false
85
+ set(value) {
86
+ if (value) nativeMarkNextFrameAsLast(nativeHandle)
87
+ }
88
+
89
+ /// 2026-05-14 — disable the angular-delta fallback. See C++
90
+ /// `setDisableAngularFallback` doc for the full rationale. In
91
+ /// short: set this to `true` in non-AR mode (captureSource ∈
92
+ /// {wide, ultrawide}) where pose data isn't available — the
93
+ /// gate's angular calculation would otherwise produce nonsense.
94
+ /// Default `false` (back-compat — AR mode uses the fallback).
95
+ /// Write-only; no read accessor on the C++ side.
96
+ var disableAngularFallback: Boolean = false
97
+ set(value) {
98
+ field = value
99
+ nativeSetDisableAngularFallback(nativeHandle, value)
100
+ }
101
+
102
+ /// 2026-05-14 — Flow strategy: novelty aggregation percentile
103
+ /// (same knob iOS exposes via setFlowNoveltyPercentile). C++
104
+ /// clamps to [0.5, 0.99]. Stored locally for diagnostic
105
+ /// readback; the C++ side has no getter.
106
+ var flowNoveltyPercentile: Double = 0.85
107
+ set(value) {
108
+ field = value
109
+ nativeSetFlowNoveltyPercentile(nativeHandle, value)
110
+ }
111
+
112
+ /// 2026-05-14 — Flow strategy: max translation in METRES between
113
+ /// consecutive accepted keyframes before force-acceptance. Same
114
+ /// knob iOS exposes via setFlowMaxTranslationM. In non-AR mode
115
+ /// the JS host computes translation from react-native-sensors
116
+ /// IMU integration and pushes it through this setter so the
117
+ /// translation-budget logic in C++ kicks in even without ARKit/
118
+ /// ARCore pose. 0.0 (default) = disabled.
119
+ var flowMaxTranslationM: Double = 0.0
120
+ set(value) {
121
+ field = value
122
+ nativeSetFlowMaxTranslationM(nativeHandle, value)
123
+ }
124
+
125
+ // ── Read-only state ─────────────────────────────────────────
126
+
127
+ val acceptedCount: Int get() = nativeGetAcceptedCount(nativeHandle)
128
+
129
+ // ── Lifecycle ───────────────────────────────────────────────
130
+
131
+ fun reset() {
132
+ nativeReset(nativeHandle)
133
+ // Re-apply locally-stored settings the C++ doesn't track for
134
+ // readback. (Currently just overlapThreshold.) Matches the
135
+ // iOS facade's reset() which re-writes overlapThreshold too.
136
+ nativeSetOverlapThreshold(nativeHandle, overlapThreshold)
137
+ }
138
+
139
+ // ── Evaluation ──────────────────────────────────────────────
140
+
141
+ /**
142
+ * Decide whether to accept this ARCore frame as a keyframe.
143
+ *
144
+ * @param pose Camera pose + intrinsics for this frame.
145
+ * @param latchedPlaneMatrix Column-major 4×4 plane transform
146
+ * (16 floats). Pass null for the angular-delta fallback path
147
+ * (when no plane is latched). Format MUST match what ARCore's
148
+ * `Pose.toMatrix(out, offset)` produces — column-major, same
149
+ * layout as iOS simd_float4x4.
150
+ */
151
+ fun evaluate(
152
+ pose: RNSARFramePose,
153
+ latchedPlaneMatrix: FloatArray?,
154
+ ): KeyframeGateDecision {
155
+ val result = nativeEvaluate(
156
+ nativeHandle,
157
+ pose.tx.toFloat(), pose.ty.toFloat(), pose.tz.toFloat(),
158
+ pose.qx.toFloat(), pose.qy.toFloat(), pose.qz.toFloat(), pose.qw.toFloat(),
159
+ pose.fx.toFloat(), pose.fy.toFloat(), pose.cx.toFloat(), pose.cy.toFloat(),
160
+ pose.imageWidth, pose.imageHeight,
161
+ latchedPlaneMatrix,
162
+ )
163
+ // result layout (matches keyframe_gate_jni.cpp::nativeEvaluate):
164
+ // [0] accept (1.0 / 0.0)
165
+ // [1] reasonCode (int)
166
+ // [2] newContentFraction (-1.0 when not computed)
167
+ // [3] acceptedCount (int)
168
+ // [4] maxCount (int)
169
+ return KeyframeGateDecision(
170
+ accept = result[0] >= 0.5,
171
+ reason = reasonFromCode(result[1].toInt()),
172
+ newContentFraction = result[2],
173
+ acceptedCount = result[3].toInt(),
174
+ maxCount = result[4].toInt(),
175
+ )
176
+ }
177
+
178
+ // ── JNI thunks ──────────────────────────────────────────────
179
+
180
+ private external fun nativeCreate(): Long
181
+ private external fun nativeDestroy(handle: Long)
182
+ private external fun nativeSetEnabled(handle: Long, enabled: Boolean)
183
+ private external fun nativeSetOverlapThreshold(handle: Long, t: Double)
184
+ private external fun nativeSetMaxCount(handle: Long, n: Int)
185
+ private external fun nativeMarkNextFrameAsLast(handle: Long)
186
+ private external fun nativeReset(handle: Long)
187
+ private external fun nativeGetAcceptedCount(handle: Long): Int
188
+ private external fun nativeGetMaxCount(handle: Long): Int
189
+ private external fun nativeIsEnabled(handle: Long): Boolean
190
+ // 2026-05-14 — new setters for the non-AR mode plumbing + the
191
+ // setFlowNoveltyPercentile / setFlowMaxTranslationM iOS-parity
192
+ // setters (Android JNI was a P3-followup until 2026-05-14).
193
+ private external fun nativeSetDisableAngularFallback(handle: Long, disabled: Boolean)
194
+ private external fun nativeSetFlowNoveltyPercentile(handle: Long, percentile: Double)
195
+ private external fun nativeSetFlowMaxTranslationM(handle: Long, metres: Double)
196
+ private external fun nativeEvaluate(
197
+ handle: Long,
198
+ tx: Float, ty: Float, tz: Float,
199
+ qx: Float, qy: Float, qz: Float, qw: Float,
200
+ fx: Float, fy: Float, cx: Float, cy: Float,
201
+ imageWidth: Int, imageHeight: Int,
202
+ plane16: FloatArray?,
203
+ ): DoubleArray
204
+
205
+ companion object {
206
+ init {
207
+ // libimage_stitcher.so contains both the OpenCV stitcher
208
+ // shim AND the C++ KeyframeGate + JNI bindings (single .so
209
+ // keeps APK lean and avoids a second System.loadLibrary).
210
+ System.loadLibrary("image_stitcher")
211
+ }
212
+
213
+ /**
214
+ * Map C++ `KeyframeGateDecisionReason` enum int → telemetry
215
+ * string. MUST stay byte-for-byte identical to iOS' mapping
216
+ * in `KeyframeGateBridge.mm::kReasonStringFor`. JS reads
217
+ * `decision.reason` and surfaces it directly to the UI pill
218
+ * (live frame strip "keyframe rejected: max-reached" etc.),
219
+ * so drift across platforms is a parity bug.
220
+ */
221
+ private fun reasonFromCode(code: Int): String = when (code) {
222
+ 0 -> "gate-disabled"
223
+ 1 -> "force-last"
224
+ 2 -> "first-anchored-on-plane"
225
+ 3 -> "first-no-plane"
226
+ 4 -> "ok"
227
+ 5 -> "ok-angular"
228
+ 6 -> "projection-degenerate"
229
+ 7 -> "current-area-zero"
230
+ 8 -> "no-pose-yet"
231
+ 9 -> "max-reached"
232
+ 10 -> "overlap-too-high"
233
+ 11 -> "overlap-too-high (angular)"
234
+ else -> "unknown($code)"
235
+ }
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Result of a single [KeyframeGate.evaluate] call. Layout mirrors
241
+ * iOS `KeyframeGateDecision` exactly so JS-side telemetry handlers
242
+ * don't branch on platform.
243
+ */
244
+ internal data class KeyframeGateDecision(
245
+ /// Caller checks this first — `true` means ingest the frame.
246
+ val accept: Boolean,
247
+ /// Telemetry string ("ok" / "max-reached" / "overlap-too-high" / etc).
248
+ val reason: String,
249
+ /// Computed [0, 1] new-content fraction, or -1.0 if not computed
250
+ /// (gate disabled, force-first/last, no plane available).
251
+ val newContentFraction: Double,
252
+ /// Keyframes accepted so far (includes this one if accept=true).
253
+ val acceptedCount: Int,
254
+ /// Cap for this capture (0 if gate disabled).
255
+ val maxCount: Int,
256
+ )
@@ -0,0 +1,167 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn
3
+
4
+ import com.facebook.react.bridge.Promise
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
7
+ import com.facebook.react.bridge.ReactMethod
8
+ import com.facebook.react.bridge.ReadableMap
9
+ import com.facebook.react.bridge.WritableNativeMap
10
+ import kotlinx.coroutines.CoroutineScope
11
+ import kotlinx.coroutines.Dispatchers
12
+ import kotlinx.coroutines.launch
13
+ import org.opencv.core.Core
14
+ import org.opencv.core.CvType
15
+ import org.opencv.core.Mat
16
+ import org.opencv.core.MatOfDouble
17
+ import org.opencv.imgcodecs.Imgcodecs
18
+ import org.opencv.imgproc.Imgproc
19
+
20
+ /**
21
+ * Android twin of the iOS QualityChecker (`RNImageStitcherQualityChecker` native module).
22
+ *
23
+ * Algorithm:
24
+ * - Blur score: variance of the Laplacian of the grayscale image.
25
+ * Higher = sharper. Threshold (passed by host) typically 100.
26
+ * - Brightness score: mean luminance of the grayscale image, in
27
+ * [0..1]. Threshold range typically [0.2, 0.8].
28
+ *
29
+ * Same surface as the iOS module:
30
+ * runQualityCheck({ imagePath, blurThreshold, brightnessLow,
31
+ * brightnessHigh })
32
+ * → { passed, blurScore, brightnessScore, issues: [] }
33
+ *
34
+ * OpenCV is initialised lazily on first call. The init is fast
35
+ * (no native loader prompts in OpenCV 4.x) and the result is
36
+ * cached.
37
+ */
38
+ class QualityChecker(reactContext: ReactApplicationContext)
39
+ : ReactContextBaseJavaModule(reactContext) {
40
+
41
+ override fun getName(): String = "RNImageStitcherQualityChecker"
42
+
43
+ @ReactMethod
44
+ fun runQualityCheck(options: ReadableMap, promise: Promise) {
45
+ val imagePath = options.getString("imagePath")
46
+ ?: return promise.reject("invalid-options", "imagePath required")
47
+ val blurThreshold =
48
+ if (options.hasKey("blurThreshold")) options.getDouble("blurThreshold") else 100.0
49
+ val brightnessLow =
50
+ if (options.hasKey("brightnessLow")) options.getDouble("brightnessLow") else 0.2
51
+ val brightnessHigh =
52
+ if (options.hasKey("brightnessHigh")) options.getDouble("brightnessHigh") else 0.8
53
+
54
+ // Run on background coroutine — image decode + Laplacian
55
+ // takes ~30-80 ms on a midrange phone, enough to drop a
56
+ // frame on the JS thread.
57
+ CoroutineScope(Dispatchers.Default).launch {
58
+ try {
59
+ ensureOpenCv()
60
+ val cleaned = stripFileScheme(imagePath)
61
+ val src = Imgcodecs.imread(cleaned, Imgcodecs.IMREAD_COLOR)
62
+ if (src.empty()) {
63
+ promise.reject(
64
+ "read-failed",
65
+ "Could not decode image at $imagePath",
66
+ )
67
+ return@launch
68
+ }
69
+
70
+ val gray = Mat()
71
+ Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY)
72
+
73
+ // Laplacian variance — same algorithm as iOS.
74
+ val lap = Mat()
75
+ Imgproc.Laplacian(gray, lap, CvType.CV_64F)
76
+ val mean = MatOfDouble()
77
+ val stddev = MatOfDouble()
78
+ Core.meanStdDev(lap, mean, stddev)
79
+ val blurScore = stddev.toArray()[0].let { it * it }
80
+
81
+ // Mean luminance, normalised to 0..1.
82
+ val meanBrightness = Core.mean(gray).`val`[0] / 255.0
83
+
84
+ val issues = mutableListOf<Map<String, String>>()
85
+ if (blurScore < blurThreshold) {
86
+ issues.add(mapOf(
87
+ "type" to "blur",
88
+ "message" to "Image is too blurry (score $blurScore < $blurThreshold)",
89
+ "severity" to "error",
90
+ ))
91
+ }
92
+ if (meanBrightness < brightnessLow) {
93
+ issues.add(mapOf(
94
+ "type" to "brightness_low",
95
+ "message" to "Image is too dark",
96
+ "severity" to "warning",
97
+ ))
98
+ } else if (meanBrightness > brightnessHigh) {
99
+ issues.add(mapOf(
100
+ "type" to "brightness_high",
101
+ "message" to "Image is too bright",
102
+ "severity" to "warning",
103
+ ))
104
+ }
105
+
106
+ val passed = issues.none { it["severity"] == "error" }
107
+
108
+ val result = WritableNativeMap().apply {
109
+ putBoolean("passed", passed)
110
+ putDouble("blurScore", blurScore)
111
+ putDouble("brightnessScore", meanBrightness)
112
+ val issuesArray = com.facebook.react.bridge.WritableNativeArray()
113
+ for (issue in issues) {
114
+ val m = WritableNativeMap()
115
+ m.putString("type", issue["type"])
116
+ m.putString("message", issue["message"])
117
+ m.putString("severity", issue["severity"])
118
+ issuesArray.pushMap(m)
119
+ }
120
+ putArray("issues", issuesArray)
121
+ }
122
+
123
+ src.release()
124
+ gray.release()
125
+ lap.release()
126
+ mean.release()
127
+ stddev.release()
128
+
129
+ promise.resolve(result)
130
+ } catch (t: Throwable) {
131
+ promise.reject("quality-check-failed", t.message, t)
132
+ }
133
+ }
134
+ }
135
+
136
+ private fun ensureOpenCv() {
137
+ if (!opencvInitialised) {
138
+ // Load the prebuilt OpenCV native lib directly from
139
+ // the APK's lib/<ABI>/ folder. We deliberately avoid
140
+ // OpenCV's `OpenCVLoader.initDebug()` because the rest
141
+ // of `org.opencv.android.*` (AsyncServiceHelper,
142
+ // StaticHelper, BaseLoaderCallback, etc.) is the
143
+ // legacy "OpenCV Manager service" code path that
144
+ // depends on a deprecated AIDL interface and an
145
+ // auto-generated R class — both excluded from our
146
+ // build. System.loadLibrary is the same final call
147
+ // those helpers make under the hood.
148
+ try {
149
+ System.loadLibrary("opencv_java4")
150
+ opencvInitialised = true
151
+ } catch (e: UnsatisfiedLinkError) {
152
+ throw IllegalStateException(
153
+ "OpenCV native library 'opencv_java4' failed to load",
154
+ e,
155
+ )
156
+ }
157
+ }
158
+ }
159
+
160
+ companion object {
161
+ @Volatile
162
+ private var opencvInitialised: Boolean = false
163
+
164
+ internal fun stripFileScheme(path: String): String =
165
+ if (path.startsWith("file://")) path.removePrefix("file://") else path
166
+ }
167
+ }
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn
3
+
4
+ import com.facebook.react.ReactPackage
5
+ import com.facebook.react.bridge.NativeModule
6
+ import com.facebook.react.bridge.ReactApplicationContext
7
+ import com.facebook.react.uimanager.ViewManager
8
+
9
+ /**
10
+ * ReactPackage that registers the SDK's two native modules with
11
+ * the host app. Picked up by RN autolinking via the package's
12
+ * sourceDir entry in `react-native.config.js`.
13
+ *
14
+ * Modules registered:
15
+ * - QualityChecker: blur + brightness scoring
16
+ * - BatchStitcher: stitch / stitchVideo / normaliseImage
17
+ *
18
+ * The Android JS surface mirrors iOS exactly so any code using
19
+ * `NativeModules.RNImageStitcherQualityChecker.runQualityCheck(...)` or
20
+ * `NativeModules.BatchStitcher.stitch(...)` works the same on
21
+ * both platforms — no conditional branching needed in the SDK's
22
+ * JS layer.
23
+ */
24
+ class RNImageStitcherPackage : ReactPackage {
25
+ override fun createNativeModules(
26
+ reactContext: ReactApplicationContext,
27
+ ): List<NativeModule> = listOf(
28
+ QualityChecker(reactContext),
29
+ BatchStitcher(reactContext),
30
+ RNSARSession(reactContext),
31
+ IncrementalStitcher(reactContext),
32
+ )
33
+
34
+ override fun createViewManagers(
35
+ reactContext: ReactApplicationContext,
36
+ ): List<ViewManager<*, *>> = listOf(
37
+ RNSARCameraViewManager(),
38
+ )
39
+ }