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.
- package/CHANGELOG.md +96 -0
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +189 -0
- package/RNImageStitcher.podspec +76 -0
- package/android/build.gradle +224 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/cpp/CMakeLists.txt +124 -0
- package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
- package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
- package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
- package/cpp/ar_frame_pose.h +63 -0
- package/cpp/keyframe_gate.cpp +927 -0
- package/cpp/keyframe_gate.hpp +240 -0
- package/cpp/stitcher.cpp +2207 -0
- package/cpp/stitcher.hpp +275 -0
- package/dist/ar/useARSession.d.ts +102 -0
- package/dist/ar/useARSession.js +133 -0
- package/dist/camera/ARCameraView.d.ts +93 -0
- package/dist/camera/ARCameraView.js +170 -0
- package/dist/camera/Camera.d.ts +134 -0
- package/dist/camera/Camera.js +688 -0
- package/dist/camera/CameraShutter.d.ts +80 -0
- package/dist/camera/CameraShutter.js +237 -0
- package/dist/camera/CameraView.d.ts +65 -0
- package/dist/camera/CameraView.js +117 -0
- package/dist/camera/CaptureControlsBar.d.ts +87 -0
- package/dist/camera/CaptureControlsBar.js +82 -0
- package/dist/camera/CaptureHeader.d.ts +62 -0
- package/dist/camera/CaptureHeader.js +81 -0
- package/dist/camera/CapturePreview.d.ts +70 -0
- package/dist/camera/CapturePreview.js +188 -0
- package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
- package/dist/camera/CaptureStatusOverlay.js +326 -0
- package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
- package/dist/camera/CaptureThumbnailStrip.js +177 -0
- package/dist/camera/IncrementalPanGuide.d.ts +83 -0
- package/dist/camera/IncrementalPanGuide.js +267 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
- package/dist/camera/PanoramaBandOverlay.js +399 -0
- package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
- package/dist/camera/PanoramaConfirmModal.js +128 -0
- package/dist/camera/PanoramaGuidance.d.ts +79 -0
- package/dist/camera/PanoramaGuidance.js +246 -0
- package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
- package/dist/camera/PanoramaSettingsModal.js +611 -0
- package/dist/camera/ViewportCropOverlay.d.ts +46 -0
- package/dist/camera/ViewportCropOverlay.js +67 -0
- package/dist/camera/useCapture.d.ts +111 -0
- package/dist/camera/useCapture.js +160 -0
- package/dist/camera/useDeviceOrientation.d.ts +48 -0
- package/dist/camera/useDeviceOrientation.js +131 -0
- package/dist/camera/useVideoCapture.d.ts +79 -0
- package/dist/camera/useVideoCapture.js +151 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +39 -0
- package/dist/quality/normaliseOrientation.d.ts +36 -0
- package/dist/quality/normaliseOrientation.js +62 -0
- package/dist/quality/runQualityCheck.d.ts +41 -0
- package/dist/quality/runQualityCheck.js +98 -0
- package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
- package/dist/sensors/useIMUTranslationGate.js +235 -0
- package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
- package/dist/stitching/IncrementalStitcherView.js +157 -0
- package/dist/stitching/incremental.d.ts +930 -0
- package/dist/stitching/incremental.js +133 -0
- package/dist/stitching/stitchFrames.d.ts +55 -0
- package/dist/stitching/stitchFrames.js +56 -0
- package/dist/stitching/stitchVideo.d.ts +119 -0
- package/dist/stitching/stitchVideo.js +57 -0
- package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
- package/dist/stitching/useIncrementalJSDriver.js +199 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
- package/dist/stitching/useIncrementalStitcher.js +172 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +15 -0
- package/ios/Package.swift +72 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
- package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
- package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
- package/package.json +73 -0
- package/react-native.config.js +34 -0
- package/scripts/opencv-version.txt +1 -0
- package/scripts/postinstall-fetch-binaries.js +286 -0
- package/src/ar/useARSession.ts +210 -0
- package/src/camera/.gitkeep +0 -0
- package/src/camera/ARCameraView.tsx +256 -0
- package/src/camera/Camera.tsx +1053 -0
- package/src/camera/CameraShutter.tsx +292 -0
- package/src/camera/CameraView.tsx +157 -0
- package/src/camera/CaptureControlsBar.tsx +204 -0
- package/src/camera/CaptureHeader.tsx +184 -0
- package/src/camera/CapturePreview.tsx +318 -0
- package/src/camera/CaptureStatusOverlay.tsx +391 -0
- package/src/camera/CaptureThumbnailStrip.tsx +277 -0
- package/src/camera/IncrementalPanGuide.tsx +328 -0
- package/src/camera/PanoramaBandOverlay.tsx +498 -0
- package/src/camera/PanoramaConfirmModal.tsx +206 -0
- package/src/camera/PanoramaGuidance.tsx +327 -0
- package/src/camera/PanoramaSettingsModal.tsx +1357 -0
- package/src/camera/ViewportCropOverlay.tsx +81 -0
- package/src/camera/useCapture.ts +279 -0
- package/src/camera/useDeviceOrientation.ts +140 -0
- package/src/camera/useVideoCapture.ts +236 -0
- package/src/index.ts +53 -0
- package/src/quality/.gitkeep +0 -0
- package/src/quality/normaliseOrientation.ts +79 -0
- package/src/quality/runQualityCheck.ts +131 -0
- package/src/sensors/useIMUTranslationGate.ts +347 -0
- package/src/stitching/.gitkeep +0 -0
- package/src/stitching/IncrementalStitcherView.tsx +198 -0
- package/src/stitching/incremental.ts +1021 -0
- package/src/stitching/stitchFrames.ts +88 -0
- package/src/stitching/stitchVideo.ts +153 -0
- package/src/stitching/useIncrementalJSDriver.ts +273 -0
- package/src/stitching/useIncrementalStitcher.ts +252 -0
- 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
|
+
}
|