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,204 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // keyframe_gate_jni.cpp — JNI bindings exposing the shared C++
4
+ // retailens::KeyframeGate (in ../../../../cpp/) to the Kotlin side
5
+ // (com.retailens.capturesdk.KeyframeGate).
6
+ //
7
+ // Architecture parity with iOS:
8
+ // iOS uses an Obj-C++ bridge (KeyframeGateBridge.mm) to wrap the
9
+ // same C++ class. Android uses these JNI thunks. Both ultimately
10
+ // call into the same code in cpp/keyframe_gate.cpp — that's the
11
+ // point of the port.
12
+ //
13
+ // Handle pattern:
14
+ // nativeCreate() returns a `Long` opaque handle (the C++ KeyframeGate
15
+ // pointer cast to jlong). All subsequent calls pass the handle
16
+ // back. The Kotlin wrapper owns the handle's lifetime and MUST
17
+ // call nativeDestroy() before being garbage-collected (otherwise we
18
+ // leak a small heap allocation per gate-instance).
19
+ //
20
+ // Decision packing:
21
+ // Evaluate returns a DoubleArray of length 5:
22
+ // [0] accept — 1.0 or 0.0
23
+ // [1] reasonCode — int (the C++ enum int value)
24
+ // [2] newContentFraction — -1.0 when not computed
25
+ // [3] acceptedCount — int
26
+ // [4] maxCount — int
27
+ // Kotlin wrapper unpacks into a data class. This avoids JNI
28
+ // per-call object construction (NewObject) which is ~10× more
29
+ // expensive than a primitive-array allocation.
30
+
31
+ #include <jni.h>
32
+ #include <cstring>
33
+
34
+ #include "keyframe_gate.hpp"
35
+ #include "ar_frame_pose.h"
36
+
37
+ namespace {
38
+ inline retailens::KeyframeGate* gate(jlong h) {
39
+ return reinterpret_cast<retailens::KeyframeGate*>(h);
40
+ }
41
+ } // anonymous namespace
42
+
43
+ extern "C" {
44
+
45
+ // ── Lifecycle ────────────────────────────────────────────────────
46
+
47
+ JNIEXPORT jlong JNICALL
48
+ Java_io_imagestitcher_rn_KeyframeGate_nativeCreate(JNIEnv*, jclass) {
49
+ return reinterpret_cast<jlong>(new retailens::KeyframeGate());
50
+ }
51
+
52
+ JNIEXPORT void JNICALL
53
+ Java_io_imagestitcher_rn_KeyframeGate_nativeDestroy(
54
+ JNIEnv*, jclass, jlong handle)
55
+ {
56
+ delete gate(handle);
57
+ }
58
+
59
+ // ── Settings ─────────────────────────────────────────────────────
60
+
61
+ JNIEXPORT void JNICALL
62
+ Java_io_imagestitcher_rn_KeyframeGate_nativeSetEnabled(
63
+ JNIEnv*, jclass, jlong handle, jboolean enabled)
64
+ {
65
+ gate(handle)->setEnabled(enabled);
66
+ }
67
+
68
+ JNIEXPORT void JNICALL
69
+ Java_io_imagestitcher_rn_KeyframeGate_nativeSetOverlapThreshold(
70
+ JNIEnv*, jclass, jlong handle, jdouble t)
71
+ {
72
+ gate(handle)->setOverlapThreshold(t);
73
+ }
74
+
75
+ JNIEXPORT void JNICALL
76
+ Java_io_imagestitcher_rn_KeyframeGate_nativeSetMaxCount(
77
+ JNIEnv*, jclass, jlong handle, jint n)
78
+ {
79
+ gate(handle)->setMaxCount(static_cast<int32_t>(n));
80
+ }
81
+
82
+ JNIEXPORT void JNICALL
83
+ Java_io_imagestitcher_rn_KeyframeGate_nativeMarkNextFrameAsLast(
84
+ JNIEnv*, jclass, jlong handle)
85
+ {
86
+ gate(handle)->markNextFrameAsLast();
87
+ }
88
+
89
+ // 2026-05-14 — non-AR-mode opt-out for the angular-delta fallback.
90
+ // See `setDisableAngularFallback` doc in keyframe_gate.hpp for the
91
+ // rationale (no usable pose data in non-AR captures).
92
+ JNIEXPORT void JNICALL
93
+ Java_io_imagestitcher_rn_KeyframeGate_nativeSetDisableAngularFallback(
94
+ JNIEnv*, jclass, jlong handle, jboolean disabled)
95
+ {
96
+ gate(handle)->setDisableAngularFallback(static_cast<bool>(disabled));
97
+ }
98
+
99
+ // 2026-05-14 — JS-driven IMU translation budget for non-AR mode.
100
+ // In non-AR captures, the gate has no ARKit/ARCore pose; the JS
101
+ // host computes translation via react-native-sensors accelerometer
102
+ // integration and forwards it via this setter so the gate's
103
+ // translation-budget logic still kicks in. See setFlowMaxTranslationM
104
+ // doc in keyframe_gate.hpp. This is the Android JNI counterpart of
105
+ // the iOS bridge method that already exists in KeyframeGateBridge.
106
+ JNIEXPORT void JNICALL
107
+ Java_io_imagestitcher_rn_KeyframeGate_nativeSetFlowMaxTranslationM(
108
+ JNIEnv*, jclass, jlong handle, jdouble metres)
109
+ {
110
+ gate(handle)->setFlowMaxTranslationM(static_cast<double>(metres));
111
+ }
112
+
113
+ // 2026-05-14 — Android JNI for the percentile setter so JS Settings
114
+ // can tune novelty aggregation on Android (was iOS-only until now).
115
+ // See setFlowNoveltyPercentile doc in keyframe_gate.hpp.
116
+ JNIEXPORT void JNICALL
117
+ Java_io_imagestitcher_rn_KeyframeGate_nativeSetFlowNoveltyPercentile(
118
+ JNIEnv*, jclass, jlong handle, jdouble percentile)
119
+ {
120
+ gate(handle)->setFlowNoveltyPercentile(static_cast<double>(percentile));
121
+ }
122
+
123
+ JNIEXPORT void JNICALL
124
+ Java_io_imagestitcher_rn_KeyframeGate_nativeReset(
125
+ JNIEnv*, jclass, jlong handle)
126
+ {
127
+ gate(handle)->reset();
128
+ }
129
+
130
+ // ── Read-only state ──────────────────────────────────────────────
131
+
132
+ JNIEXPORT jint JNICALL
133
+ Java_io_imagestitcher_rn_KeyframeGate_nativeGetAcceptedCount(
134
+ JNIEnv*, jclass, jlong handle)
135
+ {
136
+ return static_cast<jint>(gate(handle)->getAcceptedCount());
137
+ }
138
+
139
+ JNIEXPORT jint JNICALL
140
+ Java_io_imagestitcher_rn_KeyframeGate_nativeGetMaxCount(
141
+ JNIEnv*, jclass, jlong handle)
142
+ {
143
+ return static_cast<jint>(gate(handle)->getMaxCount());
144
+ }
145
+
146
+ JNIEXPORT jboolean JNICALL
147
+ Java_io_imagestitcher_rn_KeyframeGate_nativeIsEnabled(
148
+ JNIEnv*, jclass, jlong handle)
149
+ {
150
+ return static_cast<jboolean>(gate(handle)->isEnabled());
151
+ }
152
+
153
+ // ── Per-frame evaluate ───────────────────────────────────────────
154
+ //
155
+ // plane16OrNull is FloatArray of exactly 16 elements (column-major),
156
+ // or null for angular-delta fallback. Returns DoubleArray[5] as
157
+ // described in the file header.
158
+
159
+ JNIEXPORT jdoubleArray JNICALL
160
+ Java_io_imagestitcher_rn_KeyframeGate_nativeEvaluate(
161
+ JNIEnv* env, jclass, jlong handle,
162
+ jfloat tx, jfloat ty, jfloat tz,
163
+ jfloat qx, jfloat qy, jfloat qz, jfloat qw,
164
+ jfloat fx, jfloat fy, jfloat cx, jfloat cy,
165
+ jint imageWidth, jint imageHeight,
166
+ jfloatArray plane16OrNull)
167
+ {
168
+ retailens::Pose pose;
169
+ pose.tx = tx; pose.ty = ty; pose.tz = tz;
170
+ pose.qx = qx; pose.qy = qy; pose.qz = qz; pose.qw = qw;
171
+ pose.fx = fx; pose.fy = fy; pose.cx = cx; pose.cy = cy;
172
+ pose.imageWidth = static_cast<int32_t>(imageWidth);
173
+ pose.imageHeight = static_cast<int32_t>(imageHeight);
174
+
175
+ retailens::PlaneTransform planeStorage;
176
+ const retailens::PlaneTransform* planePtr = nullptr;
177
+ if (plane16OrNull) {
178
+ jsize len = env->GetArrayLength(plane16OrNull);
179
+ if (len == 16) {
180
+ jfloat* src = env->GetFloatArrayElements(plane16OrNull, nullptr);
181
+ if (src) {
182
+ std::memcpy(planeStorage.m, src, sizeof(float) * 16);
183
+ env->ReleaseFloatArrayElements(plane16OrNull, src, JNI_ABORT);
184
+ planePtr = &planeStorage;
185
+ }
186
+ }
187
+ // len != 16 silently falls through to angular fallback; the
188
+ // Kotlin caller is responsible for passing exactly 16 floats.
189
+ }
190
+
191
+ retailens::KeyframeGateDecision d = gate(handle)->evaluate(pose, planePtr);
192
+
193
+ jdoubleArray out = env->NewDoubleArray(5);
194
+ jdouble values[5];
195
+ values[0] = d.accept ? 1.0 : 0.0;
196
+ values[1] = static_cast<jdouble>(static_cast<int32_t>(d.reason));
197
+ values[2] = d.newContentFraction;
198
+ values[3] = static_cast<jdouble>(d.acceptedCount);
199
+ values[4] = static_cast<jdouble>(d.maxCount);
200
+ env->SetDoubleArrayRegion(out, 0, 5, values);
201
+ return out;
202
+ }
203
+
204
+ } // extern "C"
@@ -0,0 +1,426 @@
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.Mat
14
+ import org.opencv.core.MatOfInt
15
+ import org.opencv.imgcodecs.Imgcodecs
16
+ import java.io.File
17
+ import java.util.concurrent.atomic.AtomicBoolean
18
+
19
+ /**
20
+ * Android twin of the iOS BatchStitcher. Mirrors the JS-facing
21
+ * surface exactly:
22
+ *
23
+ * stitch({ framePaths, outputPath, quality })
24
+ * → { outputPath, width, height, durationMs }
25
+ *
26
+ * stitchVideo({ videoPath, outputPath, maxFrames, quality })
27
+ * → { outputPath, width, height, durationMs }
28
+ *
29
+ * normaliseImage({ imagePath })
30
+ * → { width, height }
31
+ *
32
+ * Algorithm choices match iOS:
33
+ * - cv::Stitcher::SCANS mode (translational, planar subject — the
34
+ * shelf-walking gesture) instead of PANORAMA (which assumes
35
+ * rotational camera).
36
+ * - 10 evenly-spaced frames as the SCANS-mode sweet spot.
37
+ * - JPEG quality 85 default; adjustable per call.
38
+ *
39
+ * Frame extraction uses Android's MediaMetadataRetriever — analogous
40
+ * to iOS' AVAssetImageGenerator. Each call to getFrameAtTime is
41
+ * fast (~30-50 ms) and the API blocks per-frame, so we run the
42
+ * whole pipeline on a background coroutine.
43
+ */
44
+ class BatchStitcher(reactContext: ReactApplicationContext)
45
+ : ReactContextBaseJavaModule(reactContext) {
46
+
47
+ override fun getName(): String = "BatchStitcher"
48
+
49
+ /**
50
+ * JNI bridge to our custom-built OpenCV stitcher. Mirrors iOS'
51
+ * OpenCVStitcher.stitchFramePaths so the batch-keyframe flow has
52
+ * parity across platforms. Implementation:
53
+ * retailens-capture-sdk/android/src/main/cpp/image_stitcher_jni.cpp
54
+ *
55
+ * @param framePaths input JPEG paths in capture order (≥2 required)
56
+ * @param outputPath destination JPEG path
57
+ * @param jpegQuality 0..100
58
+ * @param warperType "plane" | "cylindrical" | "spherical"
59
+ * @param blenderType "multiband" | "feather"
60
+ * @param seamFinderType "graphcut" | "skip" | "voronoi"
61
+ * @param captureOrientation "portrait" | "portrait-upside-down"
62
+ * | "landscape-left" | "landscape-right"
63
+ * (drives output bake-rotation table,
64
+ * mirrors iOS)
65
+ * @param useInscribedRectCrop reserved for future parity with
66
+ * iOS' inscribed-rect crop toggle;
67
+ * currently bbox-only on Android
68
+ * @return [width, height] of the written JPEG
69
+ * @throws RuntimeException on stitch failure
70
+ */
71
+ private external fun nativeStitchFramePaths(
72
+ framePaths: Array<String>,
73
+ outputPath: String,
74
+ jpegQuality: Int,
75
+ warperType: String,
76
+ blenderType: String,
77
+ seamFinderType: String,
78
+ captureOrientation: String,
79
+ useInscribedRectCrop: Boolean,
80
+ // V16-followup (Android OOM fix): cv::Stitcher staged-resolution
81
+ // budgets in megapixels. Pass any negative value to keep
82
+ // cv::Stitcher's library default for that stage. See
83
+ // image_stitcher_jni.cpp arg doc for the full rationale; the
84
+ // tl;dr is that the cv::Stitcher COMPOSITING default is
85
+ // ORIG_RESOL (no downscale) which on Android with 1920×1080
86
+ // sensor frames balloons MultiBand memory and triggers lmkd.
87
+ // Bounding compositing to ~1.0 MP keeps stitch peak < 200 MB
88
+ // on the A35.
89
+ registrationResolMP: Double,
90
+ seamEstimationResolMP: Double,
91
+ compositingResolMP: Double,
92
+ // 2026-05-14 — cv::Stitcher pipeline mode picker.
93
+ // "panorama" → cv::Stitcher::PANORAMA (rotation-only)
94
+ // "scans" → cv::Stitcher::SCANS (translation/affine)
95
+ // Always a concrete mode at this layer; 'auto' is resolved
96
+ // upstream in IncrementalStitcher.finalize() based
97
+ // on accumulated translation/rotation totals. Defaults to
98
+ // "scans" in the JNI on unknown input (safer fallback —
99
+ // SCANS canvas size is bounded by sum-of-frames; PANORAMA
100
+ // can diverge to multi-GB on translation-heavy input).
101
+ stitchMode: String,
102
+ ): IntArray
103
+
104
+ // ── Stitch frames → panorama ─────────────────────────────────
105
+
106
+ @ReactMethod
107
+ fun stitch(options: ReadableMap, promise: Promise) {
108
+ // Unmarshal options. We accept iOS-aligned parameter names
109
+ // so the JS-side code stays platform-agnostic.
110
+ val framePathsArr = options.getArray("framePaths")
111
+ if (framePathsArr == null || framePathsArr.size() < 2) {
112
+ promise.reject(
113
+ "invalid-options",
114
+ "framePaths must be an array of at least 2 paths " +
115
+ "(got ${framePathsArr?.size() ?: 0}).",
116
+ )
117
+ return
118
+ }
119
+ val framePaths = Array(framePathsArr.size()) {
120
+ stripFileScheme(framePathsArr.getString(it) ?: "")
121
+ }
122
+ val outputPath = options.getString("outputPath")
123
+ ?.let(::stripFileScheme)
124
+ ?: return promise.reject("invalid-options", "outputPath required")
125
+ val quality = if (options.hasKey("quality"))
126
+ options.getInt("quality") else 85
127
+ val warperType = options.getString("warperType") ?: "plane"
128
+ val blenderType = options.getString("blenderType") ?: "multiband"
129
+ val seamFinderType = options.getString("seamFinderType") ?: "graphcut"
130
+ val captureOrientation = options.getString("captureOrientation") ?: "portrait"
131
+ val useInscribedRectCrop = options.hasKey("useInscribedRectCrop") &&
132
+ options.getBoolean("useInscribedRectCrop")
133
+ // V16-followup (Android OOM fix): cv::Stitcher staged-resolution
134
+ // budgets in MP. Defaults:
135
+ // registrationResolMP = -1.0 → keep cv::Stitcher default 0.6 MP
136
+ // seamEstimationResolMP = -1.0 → keep cv::Stitcher default 0.1 MP
137
+ // compositingResolMP = 1.0 → OVERRIDE the dangerous
138
+ // ORIG_RESOL (-1.0) default
139
+ // Caller-supplied negative values keep the library default;
140
+ // any positive value scales the stage to that target MP.
141
+ val registrationResolMP = if (options.hasKey("registrationResolMP"))
142
+ options.getDouble("registrationResolMP") else -1.0
143
+ val seamEstimationResolMP = if (options.hasKey("seamEstimationResolMP"))
144
+ options.getDouble("seamEstimationResolMP") else -1.0
145
+ val compositingResolMP = if (options.hasKey("compositingResolMP"))
146
+ options.getDouble("compositingResolMP") else 1.0
147
+ // 2026-05-14 — cv::Stitcher pipeline mode. Caller from
148
+ // IncrementalStitcher.finalize resolves 'auto' to
149
+ // 'panorama' or 'scans' before reaching here. Direct
150
+ // @ReactMethod callers (CLI / tests) can pass 'auto' too;
151
+ // we default to 'scans' if missing/unrecognised since SCANS
152
+ // is the safer mode (bounded canvas; can't lmkd-kill on
153
+ // translation-heavy input).
154
+ val stitchMode = (options.getString("stitchMode") ?: "scans")
155
+ .let { if (it in setOf("panorama", "scans")) it else "scans" }
156
+
157
+ CoroutineScope(Dispatchers.Default).launch {
158
+ val start = System.currentTimeMillis()
159
+ try {
160
+ ensureNativeStitcher()
161
+ val dims = nativeStitchFramePaths(
162
+ framePaths,
163
+ outputPath,
164
+ quality,
165
+ warperType,
166
+ blenderType,
167
+ seamFinderType,
168
+ captureOrientation,
169
+ useInscribedRectCrop,
170
+ registrationResolMP,
171
+ seamEstimationResolMP,
172
+ compositingResolMP,
173
+ stitchMode,
174
+ )
175
+ val duration = System.currentTimeMillis() - start
176
+ // 2026-05-15 (D) — dims layout from native JNI:
177
+ // [0] width, [1] height, [2] framesRequested,
178
+ // [3] framesIncluded, [4] finalThresholdMilli
179
+ // (see image_stitcher_jni.cpp return site).
180
+ // dims.size >= 5 guards against older native libs
181
+ // (defensive — keeps Kotlin/native loosely versioned).
182
+ val framesRequested = if (dims.size > 2) dims[2] else framePaths.size
183
+ val framesIncluded = if (dims.size > 3) dims[3] else framePaths.size
184
+ val finalConfidenceThresh =
185
+ if (dims.size > 4) dims[4].toDouble() / 1000.0 else -1.0
186
+ val result = WritableNativeMap().apply {
187
+ putString("outputPath", outputPath)
188
+ putInt("width", dims[0])
189
+ putInt("height", dims[1])
190
+ putInt("durationMs", duration.toInt())
191
+ putInt("framesRequested", framesRequested)
192
+ putInt("framesIncluded", framesIncluded)
193
+ putInt("framesDropped", framesRequested - framesIncluded)
194
+ putDouble("finalConfidenceThresh", finalConfidenceThresh)
195
+ }
196
+ promise.resolve(result)
197
+ } catch (t: Throwable) {
198
+ promise.reject(
199
+ "stitch-failed",
200
+ "Native stitch threw: ${t.message ?: t.javaClass.simpleName}",
201
+ t,
202
+ )
203
+ }
204
+ }
205
+ }
206
+
207
+ // ── Stitch video → panorama (extract + stitch + cleanup) ─────
208
+
209
+ @ReactMethod
210
+ fun stitchVideo(options: ReadableMap, promise: Promise) {
211
+ // Video → frames extraction not yet implemented on Android.
212
+ // The batch-keyframe flow drives stitch() directly with
213
+ // already-captured frame paths. If video-driven panorama
214
+ // ever ships on Android, extract via MediaMetadataRetriever
215
+ // and delegate to nativeStitchFramePaths.
216
+ promise.reject(
217
+ "STITCH_VIDEO_NOT_IMPLEMENTED",
218
+ "stitchVideo() is not implemented on Android. Use " +
219
+ "stitch() with pre-extracted framePaths instead.",
220
+ )
221
+ }
222
+
223
+ // ── Normalise photo orientation (bake EXIF into pixels) ──────
224
+
225
+ @ReactMethod
226
+ fun normaliseImage(options: ReadableMap, promise: Promise) {
227
+ val imagePath = options.getString("imagePath")
228
+ ?: return promise.reject("invalid-options", "imagePath required")
229
+
230
+ CoroutineScope(Dispatchers.Default).launch {
231
+ try {
232
+ ensureOpenCv()
233
+ val cleaned = stripFileScheme(imagePath)
234
+ if (!File(cleaned).exists()) {
235
+ promise.reject("read-failed", "Image not found: $imagePath")
236
+ return@launch
237
+ }
238
+ // First rotate the source on the Java side using
239
+ // ExifInterface — Android's cv::imread, unlike iOS',
240
+ // doesn't auto-honour EXIF. We re-write a rotated
241
+ // intermediate to disk THEN call the JNI normalise
242
+ // (which re-reads + re-writes via cv::imread/imwrite,
243
+ // stripping EXIF metadata for good measure).
244
+ val img = Imgcodecs.imread(cleaned, Imgcodecs.IMREAD_COLOR)
245
+ if (img.empty()) {
246
+ promise.reject("read-failed", "Could not decode $imagePath")
247
+ return@launch
248
+ }
249
+ val rotated = applyExifOrientation(cleaned, img)
250
+ val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, 92)
251
+ if (!Imgcodecs.imwrite(cleaned, rotated, params)) {
252
+ promise.reject("write-failed", "Could not rewrite $imagePath")
253
+ return@launch
254
+ }
255
+ promise.resolve(WritableNativeMap().apply {
256
+ putInt("width", rotated.cols())
257
+ putInt("height", rotated.rows())
258
+ })
259
+ img.release()
260
+ if (rotated !== img) rotated.release()
261
+ } catch (t: Throwable) {
262
+ promise.reject("normalise-failed", t.message, t)
263
+ }
264
+ }
265
+ }
266
+
267
+ // ── Internals ────────────────────────────────────────────────
268
+
269
+ /**
270
+ * Read EXIF orientation tag and rotate the Mat accordingly.
271
+ * The original Mat is released and replaced if rotation was
272
+ * needed — caller can compare references to know if a fresh
273
+ * Mat was returned.
274
+ */
275
+ private fun applyExifOrientation(path: String, src: Mat): Mat {
276
+ val exif = androidx.exifinterface.media.ExifInterface(path)
277
+ val orientation = exif.getAttributeInt(
278
+ androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
279
+ androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL,
280
+ )
281
+ val rotated = Mat()
282
+ when (orientation) {
283
+ androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
284
+ org.opencv.core.Core.rotate(src, rotated, org.opencv.core.Core.ROTATE_90_CLOCKWISE)
285
+ }
286
+ androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
287
+ org.opencv.core.Core.rotate(src, rotated, org.opencv.core.Core.ROTATE_180)
288
+ }
289
+ androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
290
+ org.opencv.core.Core.rotate(src, rotated, org.opencv.core.Core.ROTATE_90_COUNTERCLOCKWISE)
291
+ }
292
+ else -> return src
293
+ }
294
+ return rotated
295
+ }
296
+
297
+ private fun stripFileScheme(path: String): String =
298
+ if (path.startsWith("file://")) path.removePrefix("file://") else path
299
+
300
+ private fun ensureOpenCv() {
301
+ if (!opencvInitialised.get()) {
302
+ try {
303
+ System.loadLibrary("opencv_java4")
304
+ opencvInitialised.set(true)
305
+ } catch (e: UnsatisfiedLinkError) {
306
+ throw IllegalStateException(
307
+ "OpenCV native library 'opencv_java4' failed to load",
308
+ e,
309
+ )
310
+ }
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Internal-visibility synchronous stitch entry point so the
316
+ * orchestrator (IncrementalStitcher) can drive the V16
317
+ * batch-keyframe finalize without re-marshalling through the
318
+ * @ReactMethod surface. Loads the JNI shim if not yet loaded,
319
+ * then calls straight into native. Throws on error.
320
+ */
321
+ internal fun stitchSync(
322
+ framePaths: Array<String>,
323
+ outputPath: String,
324
+ jpegQuality: Int,
325
+ warperType: String,
326
+ blenderType: String,
327
+ seamFinderType: String,
328
+ captureOrientation: String,
329
+ useInscribedRectCrop: Boolean,
330
+ // V16-followup (Android OOM fix): MP budgets for the
331
+ // staged-resolution pipeline. Negative = use cv::Stitcher
332
+ // library default. Default param values here apply the OOM
333
+ // fix (compose=1.0 MP) by default for the internal-orchestrator
334
+ // call site — caller can override per capture if needed.
335
+ registrationResolMP: Double = -1.0,
336
+ seamEstimationResolMP: Double = -1.0,
337
+ compositingResolMP: Double = 1.0,
338
+ // 2026-05-14 — cv::Stitcher pipeline mode. Caller resolves
339
+ // 'auto' upstream; "panorama" or "scans" only here. Default
340
+ // 'scans' since SCANS handles both rotation-light and
341
+ // translation captures safely (PANORAMA on translation can
342
+ // diverge → multi-GB canvas → lmkd kill).
343
+ stitchMode: String = "scans",
344
+ ): IntArray {
345
+ ensureNativeStitcher()
346
+ return nativeStitchFramePaths(
347
+ framePaths,
348
+ outputPath,
349
+ jpegQuality,
350
+ warperType,
351
+ blenderType,
352
+ seamFinderType,
353
+ captureOrientation,
354
+ useInscribedRectCrop,
355
+ registrationResolMP,
356
+ seamEstimationResolMP,
357
+ compositingResolMP,
358
+ stitchMode,
359
+ )
360
+ }
361
+
362
+ /**
363
+ * Load the JNI shim that exposes cv::Stitcher. libopencv_java4
364
+ * must be loaded FIRST because the shim dynamically links against
365
+ * it (uses cv::Mat, cv::imread/imwrite, cv::imgproc symbols
366
+ * exported by the fat lib).
367
+ */
368
+ internal fun ensureNativeStitcher() {
369
+ ensureOpenCv()
370
+ if (!stitcherInitialised.get()) {
371
+ try {
372
+ System.loadLibrary("image_stitcher")
373
+ stitcherInitialised.set(true)
374
+ } catch (e: UnsatisfiedLinkError) {
375
+ throw IllegalStateException(
376
+ "JNI shim 'image_stitcher' failed to load. " +
377
+ "Check that the custom OpenCV build artifacts " +
378
+ "(libopencv_java4.so + libopencv_stitching.a) " +
379
+ "are in vendor/OpenCV-android-sdk/sdk/native/.",
380
+ e,
381
+ )
382
+ }
383
+ }
384
+ }
385
+
386
+ private class StitcherException(val code: String, message: String) : Exception(message)
387
+
388
+ init {
389
+ // Singleton-style accessor for callers that need the
390
+ // BatchStitcher instance from outside the @ReactMethod
391
+ // path (e.g. IncrementalStitcher.finalize() during
392
+ // batch-keyframe stitching).
393
+ //
394
+ // Why this exists:
395
+ // reactContext.getNativeModule(BatchStitcher::class.java)
396
+ // returns null under bridgeless / new-architecture mode for
397
+ // modules registered the legacy way (ReactPackage +
398
+ // createNativeModules), even when the module is fully
399
+ // registered. Empirically confirmed by Galaxy A35
400
+ // capture session 2026-05-13: stitcher IS registered (see
401
+ // RNImageStitcherPackage.kt) but lookup returned null →
402
+ // "BatchStitcher module not registered" IllegalState
403
+ // at finalize time.
404
+ //
405
+ // Same pattern IncrementalStitcher uses (its
406
+ // `bridgeInstance` companion).
407
+ bridgeInstance = this
408
+ }
409
+
410
+ companion object {
411
+ @JvmStatic
412
+ private val opencvInitialised = AtomicBoolean(false)
413
+
414
+ @JvmStatic
415
+ private val stitcherInitialised = AtomicBoolean(false)
416
+
417
+ /// Direct access to the last-constructed BatchStitcher.
418
+ /// RN may rebuild modules across reloads; the lookup always
419
+ /// returns the latest reference. Read-only from outside;
420
+ /// only the init {} above sets it.
421
+ @JvmStatic
422
+ @Volatile
423
+ var bridgeInstance: BatchStitcher? = null
424
+ private set
425
+ }
426
+ }