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,960 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
package io.imagestitcher.rn
|
|
3
|
+
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import com.facebook.react.bridge.WritableMap
|
|
6
|
+
// V13.0a — removed unused imports (Calib3d, DMatch, KeyPoint,
|
|
7
|
+
// MatOfByte, MatOfDMatch, MatOfKeyPoint, MatOfPoint2f, BFMatcher,
|
|
8
|
+
// ORB) after the homography revert. CvType + MatOfInt kept because
|
|
9
|
+
// they're still used elsewhere in the engine.
|
|
10
|
+
import org.opencv.core.Core
|
|
11
|
+
import org.opencv.core.CvType
|
|
12
|
+
import org.opencv.core.Mat
|
|
13
|
+
import org.opencv.core.MatOfInt
|
|
14
|
+
import org.opencv.core.Point
|
|
15
|
+
import org.opencv.core.Rect
|
|
16
|
+
import org.opencv.core.Scalar
|
|
17
|
+
import org.opencv.core.Size
|
|
18
|
+
import org.opencv.imgcodecs.Imgcodecs
|
|
19
|
+
import org.opencv.imgproc.Imgproc
|
|
20
|
+
import java.io.File
|
|
21
|
+
import java.util.Locale
|
|
22
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
23
|
+
import kotlin.math.atan2
|
|
24
|
+
import kotlin.math.cos
|
|
25
|
+
import kotlin.math.floor
|
|
26
|
+
import kotlin.math.max
|
|
27
|
+
import kotlin.math.min
|
|
28
|
+
import kotlin.math.round
|
|
29
|
+
import kotlin.math.sin
|
|
30
|
+
import kotlin.math.sqrt
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Android port of iOS' OpenCVFirstWinsCylindricalStitcher with the
|
|
34
|
+
* full V12.x stack baked in:
|
|
35
|
+
*
|
|
36
|
+
* V12.2 — cylindrical projection (mirror-fixed: theta = atan2(-wx, wz))
|
|
37
|
+
* V12.3 — orientation-aware cylinder axis (transverse for landscape)
|
|
38
|
+
* V12.4 — central 70% (pan) × 85% (perpendicular) post-warp crop
|
|
39
|
+
* V12.6 — orientation detection from R_panToCam at first frame
|
|
40
|
+
* (NOT from JS-passed frameRotationDegrees, which is wrong
|
|
41
|
+
* under iOS interface-orientation lock — Android equivalent
|
|
42
|
+
* is screen-orientation lock; same fix applies)
|
|
43
|
+
* V12.7 — rectilinear path: skip cylindrical warp entirely. First
|
|
44
|
+
* frame pasted raw onto canvas; subsequent frames contribute
|
|
45
|
+
* a narrow central strip placed by pose-delta around the
|
|
46
|
+
* dominant pan axis. First-painted-wins masks the strip.
|
|
47
|
+
*
|
|
48
|
+
* Differences from the iOS engine:
|
|
49
|
+
* - Frames arrive as JPEG paths (vision-camera + gyro driver writes
|
|
50
|
+
* them to disk on each accept), not as raw CVPixelBuffers. We
|
|
51
|
+
* read with Imgcodecs and downsample to compose dims here.
|
|
52
|
+
* - OpenCV Java bindings: cv::Mat is org.opencv.core.Mat with
|
|
53
|
+
* element accessors that return double[] — slower per-element
|
|
54
|
+
* than the C++ at<double>() but only used in setup paths, not
|
|
55
|
+
* the per-pixel inverse-map loop (that's done with put/get
|
|
56
|
+
* bulk float arrays for the remap).
|
|
57
|
+
*
|
|
58
|
+
* What this DOESN'T do (yet, intentional scope):
|
|
59
|
+
* - No KLT optical-flow refinement (iOS hybrid only; firstwins
|
|
60
|
+
* doesn't use it either)
|
|
61
|
+
* - No per-pair gain compensation
|
|
62
|
+
* - No CLAHE finalize (kept simpler to match firstwins minimalism)
|
|
63
|
+
*/
|
|
64
|
+
internal class IncrementalFirstwinsEngine(
|
|
65
|
+
val composeWidth: Int,
|
|
66
|
+
val composeHeight: Int,
|
|
67
|
+
val canvasWidth: Int,
|
|
68
|
+
val canvasHeight: Int,
|
|
69
|
+
val snapshotJpegQuality: Int,
|
|
70
|
+
val snapshotEveryNAccepts: Int,
|
|
71
|
+
/// 0/90/180/270 — output rotation applied at finalize for display.
|
|
72
|
+
/// Compute pipeline works in sensor-native landscape compose space.
|
|
73
|
+
/// V12.6: orientation detection no longer uses this; ARKit/ARCore
|
|
74
|
+
/// pose at first frame is ground truth.
|
|
75
|
+
val frameRotationDegrees: Int,
|
|
76
|
+
/// V12.7 Variant B: when true, skip cylindrical warp. See class doc.
|
|
77
|
+
val useRectilinear: Boolean,
|
|
78
|
+
/// Critic #27 fix: cache-dir from the bridge. Snapshot JPEGs are
|
|
79
|
+
/// written here. System.getProperty("java.io.tmpdir") on Android
|
|
80
|
+
/// resolves to /data/local/tmp which is NOT writable by ordinary
|
|
81
|
+
/// apps, so the previous version silently dropped every snapshot.
|
|
82
|
+
val snapshotCacheDir: String,
|
|
83
|
+
) {
|
|
84
|
+
// V12.12 — canvas allocation:
|
|
85
|
+
// • RECTILINEAR engine: deferred to first-frame branch so the
|
|
86
|
+
// canvas can be sized from the pose-detected orientation.
|
|
87
|
+
// Empty Mats here; the first-frame branch checks
|
|
88
|
+
// `canvas.empty()` and allocates.
|
|
89
|
+
// • CYLINDRICAL engines (hybrid, firstwins): use the
|
|
90
|
+
// constructor's square 5000×5000 canvas as before — they
|
|
91
|
+
// compute paste positions in cylinder coords and don't
|
|
92
|
+
// benefit from orientation-aware sizing.
|
|
93
|
+
private var canvas: Mat = if (useRectilinear) {
|
|
94
|
+
Mat()
|
|
95
|
+
} else {
|
|
96
|
+
Mat.zeros(canvasHeight, canvasWidth, CvType.CV_8UC3)
|
|
97
|
+
}
|
|
98
|
+
private var canvasMask: Mat = if (useRectilinear) {
|
|
99
|
+
Mat()
|
|
100
|
+
} else {
|
|
101
|
+
Mat.zeros(canvasHeight, canvasWidth, CvType.CV_8UC1)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// V12.x state — mirrors iOS layout.
|
|
105
|
+
private var firstRotationArkit: Mat = Mat()
|
|
106
|
+
private var rPanToWorld: Mat = Mat()
|
|
107
|
+
private var kCompose: Mat = Mat()
|
|
108
|
+
private var focalCompose: Double = 0.0
|
|
109
|
+
private val mArkitToCv: Mat = Mat(3, 3, CvType.CV_64F).apply {
|
|
110
|
+
// diag(1, -1, -1) — ARKit/ARCore world (Y-up, -Z forward) → OpenCV.
|
|
111
|
+
setTo(Scalar(0.0))
|
|
112
|
+
put(0, 0, 1.0); put(1, 1, -1.0); put(2, 2, -1.0)
|
|
113
|
+
}
|
|
114
|
+
private var canvasOriginCylX: Int = 0
|
|
115
|
+
private var canvasOriginCylY: Int = 0
|
|
116
|
+
/// V12.7 first-frame anchor for rectilinear placement (canvas-pixel).
|
|
117
|
+
private var firstFrameDstX: Int = 0
|
|
118
|
+
private var firstFrameDstY: Int = 0
|
|
119
|
+
/// V12.11 Step D — running max position along the pan axis.
|
|
120
|
+
/// Mirrors iOS' _maxDstX/_maxDstY. Reverse-direction stop
|
|
121
|
+
/// fires when the homography-corrected dst regresses below
|
|
122
|
+
/// max - kReverseStopPx.
|
|
123
|
+
private var maxDstX: Int = 0
|
|
124
|
+
private var maxDstY: Int = 0
|
|
125
|
+
/// V12.6 detected at first frame from R_panToCam.
|
|
126
|
+
private var isLandscape: Boolean = false
|
|
127
|
+
|
|
128
|
+
private var hasFirstFrame: Boolean = false
|
|
129
|
+
private var acceptsSinceSnapshot: Int = 0
|
|
130
|
+
/// Critic #19 fix: AtomicInteger so JS-thread reads from
|
|
131
|
+
/// `getState`/promise resolves see a consistent value.
|
|
132
|
+
private val acceptedCountAtomic = AtomicInteger(0)
|
|
133
|
+
val acceptedCount: Int get() = acceptedCountAtomic.get()
|
|
134
|
+
private var snapshotSeq: Int = 0
|
|
135
|
+
/// Critic #30 fix: cache the painted-region bbox so we don't
|
|
136
|
+
/// re-scan a 25M-px mask N times per accept (writeOut + width
|
|
137
|
+
/// + height + buildState all called boundingRect separately).
|
|
138
|
+
private var cachedBoundingRect: Rect? = null
|
|
139
|
+
var lastState: WritableMap? = null
|
|
140
|
+
private set
|
|
141
|
+
|
|
142
|
+
/// V12.4 slit-scan + long-side clip fractions. Same values as iOS.
|
|
143
|
+
private val kPanStripFraction: Double = 0.70
|
|
144
|
+
private val kLongSideFraction: Double = 0.85
|
|
145
|
+
/// V12.12 — fraction of the PAN AXIS (not the long side) the
|
|
146
|
+
/// rectilinear engine retains per frame. Apple-pano slit-scan:
|
|
147
|
+
/// each frame contributes a narrower-than-frame slit perpendicular
|
|
148
|
+
/// to motion. See iOS `kPanAxisFractionRect` for the full
|
|
149
|
+
/// rationale. Both platforms MUST stay in sync.
|
|
150
|
+
private val kPanAxisFractionRect: Double = 0.70
|
|
151
|
+
|
|
152
|
+
/// V12.12 — pan-axis canvas extent, sized at first-frame
|
|
153
|
+
/// allocation alongside the orientation-detected perpendicular
|
|
154
|
+
/// dim. Default 5000 (max of the constructor canvas dims) —
|
|
155
|
+
/// kept as max-of-args for backwards compatibility with hosts
|
|
156
|
+
/// that pass canvasWidth/Height hints.
|
|
157
|
+
private val canvasPanExtent: Int = max(canvasWidth, canvasHeight)
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Same shape as the V7 IncrementalEngine.addFrameAtPath() so the
|
|
161
|
+
* RN bridge can route frames here without changing the JS contract.
|
|
162
|
+
*/
|
|
163
|
+
fun addFrameAtPath(
|
|
164
|
+
path: String,
|
|
165
|
+
qx: Double,
|
|
166
|
+
qy: Double,
|
|
167
|
+
qz: Double,
|
|
168
|
+
qw: Double,
|
|
169
|
+
fx: Double,
|
|
170
|
+
fy: Double,
|
|
171
|
+
cx: Double,
|
|
172
|
+
cy: Double,
|
|
173
|
+
imageWidth: Int,
|
|
174
|
+
imageHeight: Int,
|
|
175
|
+
yaw: Double,
|
|
176
|
+
pitch: Double,
|
|
177
|
+
fovHorizDegrees: Double,
|
|
178
|
+
fovVertDegrees: Double,
|
|
179
|
+
trackingPoor: Boolean,
|
|
180
|
+
): FrameTelemetry {
|
|
181
|
+
val t0 = System.nanoTime()
|
|
182
|
+
if (trackingPoor) {
|
|
183
|
+
return FrameTelemetry(
|
|
184
|
+
FrameOutcome.SkippedTrackingPoor, -1.0, 0, yaw, pitch,
|
|
185
|
+
msSince(t0),
|
|
186
|
+
isLandscape = isLandscape,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
val cleaned = path.removePrefix("file://")
|
|
190
|
+
val srcRaw = Imgcodecs.imread(cleaned, Imgcodecs.IMREAD_COLOR)
|
|
191
|
+
if (srcRaw.empty()) {
|
|
192
|
+
return FrameTelemetry(
|
|
193
|
+
FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch,
|
|
194
|
+
msSince(t0),
|
|
195
|
+
isLandscape = isLandscape,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
val frameBGR = downsampleToCompose(srcRaw)
|
|
199
|
+
if (frameBGR !== srcRaw) srcRaw.release()
|
|
200
|
+
|
|
201
|
+
val rNew = quaternionToRotationMat(qx, qy, qz, qw)
|
|
202
|
+
|
|
203
|
+
if (!hasFirstFrame) {
|
|
204
|
+
// V11/V12 first-frame setup: build panorama frame, detect
|
|
205
|
+
// orientation, paste/warp the first frame.
|
|
206
|
+
firstRotationArkit = rNew.clone()
|
|
207
|
+
val sx = frameBGR.cols().toDouble() / max(1, imageWidth)
|
|
208
|
+
val sy = frameBGR.rows().toDouble() / max(1, imageHeight)
|
|
209
|
+
kCompose = Mat(3, 3, CvType.CV_64F).apply {
|
|
210
|
+
setTo(Scalar(0.0))
|
|
211
|
+
put(0, 0, fx * sx); put(0, 2, cx * sx)
|
|
212
|
+
put(1, 1, fy * sy); put(1, 2, cy * sy)
|
|
213
|
+
put(2, 2, 1.0)
|
|
214
|
+
}
|
|
215
|
+
focalCompose = sqrt(fx * sx * fy * sy)
|
|
216
|
+
|
|
217
|
+
// Build R_panToWorld from horizontal projection of camera-forward.
|
|
218
|
+
val fwdArkitCam = Mat(3, 1, CvType.CV_64F).apply {
|
|
219
|
+
put(0, 0, 0.0); put(1, 0, 0.0); put(2, 0, -1.0)
|
|
220
|
+
}
|
|
221
|
+
val fwdWorld = Mat()
|
|
222
|
+
Core.gemm(firstRotationArkit, fwdArkitCam, 1.0, Mat(), 0.0, fwdWorld)
|
|
223
|
+
val fwx = fwdWorld[0, 0][0]
|
|
224
|
+
val fwz = fwdWorld[2, 0][0]
|
|
225
|
+
val horiz = sqrt(fwx * fwx + fwz * fwz)
|
|
226
|
+
if (horiz < 0.1) {
|
|
227
|
+
// V11 Gap #3 — refuse first-frame init while looking near vertical.
|
|
228
|
+
frameBGR.release()
|
|
229
|
+
return FrameTelemetry(
|
|
230
|
+
FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch,
|
|
231
|
+
msSince(t0),
|
|
232
|
+
isLandscape = isLandscape,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
val pzx = fwx / horiz
|
|
236
|
+
val pzz = fwz / horiz
|
|
237
|
+
rPanToWorld = Mat(3, 3, CvType.CV_64F).apply {
|
|
238
|
+
put(0, 0, pzz); put(0, 1, 0.0); put(0, 2, pzx)
|
|
239
|
+
put(1, 0, 0.0); put(1, 1, 1.0); put(1, 2, 0.0)
|
|
240
|
+
put(2, 0, -pzx); put(2, 1, 0.0); put(2, 2, pzz)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// V12.6 orientation detection from R_panToCam.
|
|
244
|
+
val rPanToCamFirst = computeRPanToCam(firstRotationArkit)
|
|
245
|
+
val absR01 = Math.abs(rPanToCamFirst[0, 1][0])
|
|
246
|
+
val absR11 = Math.abs(rPanToCamFirst[1, 1][0])
|
|
247
|
+
isLandscape = absR11 > absR01
|
|
248
|
+
Log.i(
|
|
249
|
+
"V12.6-orient",
|
|
250
|
+
"engine=android-firstwins detected isLandscape=$isLandscape " +
|
|
251
|
+
"|R[0,1]|=${"%.4f".format(absR01)} " +
|
|
252
|
+
"|R[1,1]|=${"%.4f".format(absR11)}",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if (useRectilinear) {
|
|
256
|
+
// V12.12 — orientation-aware clip + engine-internal
|
|
257
|
+
// canvas allocation. Pan axis = canvas Y for
|
|
258
|
+
// landscape (vertical pan), canvas X for portrait
|
|
259
|
+
// (horizontal pan). Clip is ALONG the pan axis.
|
|
260
|
+
// Mirrors iOS' OpenCVSlitScanStitcher.mm exactly.
|
|
261
|
+
val clipW: Int
|
|
262
|
+
val clipH: Int
|
|
263
|
+
val srcClipX: Int
|
|
264
|
+
val srcClipY: Int
|
|
265
|
+
if (isLandscape) {
|
|
266
|
+
clipW = frameBGR.cols()
|
|
267
|
+
clipH = max(1, (frameBGR.rows() * kPanAxisFractionRect).toInt())
|
|
268
|
+
srcClipX = 0
|
|
269
|
+
srcClipY = (frameBGR.rows() - clipH) / 2
|
|
270
|
+
} else {
|
|
271
|
+
clipW = max(1, (frameBGR.cols() * kPanAxisFractionRect).toInt())
|
|
272
|
+
clipH = frameBGR.rows()
|
|
273
|
+
srcClipX = (frameBGR.cols() - clipW) / 2
|
|
274
|
+
srcClipY = 0
|
|
275
|
+
}
|
|
276
|
+
val frameClipped = Mat(frameBGR, Rect(srcClipX, srcClipY, clipW, clipH))
|
|
277
|
+
|
|
278
|
+
// V12.12 — engine-internal canvas allocation, sized
|
|
279
|
+
// from detected orientation + actual frame dims.
|
|
280
|
+
if (canvas.empty()) {
|
|
281
|
+
val newCanvasCols: Int
|
|
282
|
+
val newCanvasRows: Int
|
|
283
|
+
if (isLandscape) {
|
|
284
|
+
newCanvasCols = frameBGR.cols() // perp full
|
|
285
|
+
newCanvasRows = canvasPanExtent // pan extent
|
|
286
|
+
} else {
|
|
287
|
+
newCanvasCols = canvasPanExtent // pan extent
|
|
288
|
+
newCanvasRows = frameBGR.rows() // perp full
|
|
289
|
+
}
|
|
290
|
+
canvas = Mat.zeros(newCanvasRows, newCanvasCols, CvType.CV_8UC3)
|
|
291
|
+
canvasMask = Mat.zeros(newCanvasRows, newCanvasCols, CvType.CV_8UC1)
|
|
292
|
+
Log.i(
|
|
293
|
+
"V12.12-canvas",
|
|
294
|
+
"allocated ${newCanvasCols}x${newCanvasRows} (cols x rows) for " +
|
|
295
|
+
"isLandscape=$isLandscape (pan extent $canvasPanExtent, " +
|
|
296
|
+
"frame=${frameBGR.cols()}x${frameBGR.rows()})",
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// V12.12 — first-frame placement at canvas ORIGIN
|
|
301
|
+
// (0,0). Canvas perpendicular dim now matches
|
|
302
|
+
// clipped frame perpendicular dim, so no centring
|
|
303
|
+
// offset. As user pans, dstX (portrait) or dstY
|
|
304
|
+
// (landscape) advances from 0.
|
|
305
|
+
val dstX = 0
|
|
306
|
+
val dstY = 0
|
|
307
|
+
val roi = Rect(dstX, dstY, clipW, clipH).intersection(
|
|
308
|
+
Rect(0, 0, canvas.cols(), canvas.rows())
|
|
309
|
+
)
|
|
310
|
+
val srcR = Rect(0, 0, roi.width, roi.height)
|
|
311
|
+
Mat(frameClipped, srcR).copyTo(Mat(canvas, roi))
|
|
312
|
+
Imgproc.rectangle(
|
|
313
|
+
canvasMask,
|
|
314
|
+
Point(roi.x.toDouble(), roi.y.toDouble()),
|
|
315
|
+
Point((roi.x + roi.width).toDouble(), (roi.y + roi.height).toDouble()),
|
|
316
|
+
Scalar(255.0), -1
|
|
317
|
+
)
|
|
318
|
+
firstFrameDstX = dstX
|
|
319
|
+
firstFrameDstY = dstY
|
|
320
|
+
// V12.11 Step D — initialise running-max trackers
|
|
321
|
+
// to first-frame position.
|
|
322
|
+
maxDstX = dstX
|
|
323
|
+
maxDstY = dstY
|
|
324
|
+
hasFirstFrame = true
|
|
325
|
+
acceptedCountAtomic.set(1); cachedBoundingRect = null
|
|
326
|
+
Log.i(
|
|
327
|
+
"V12.12-rect",
|
|
328
|
+
"first frame placed at ($dstX,$dstY) clipped=${clipW}x${clipH} " +
|
|
329
|
+
"(srcClip=$srcClipX,$srcClipY) along-pan-axis " +
|
|
330
|
+
"isLandscape=$isLandscape focal=${"%.2f".format(focalCompose)} " +
|
|
331
|
+
"canvas=${canvas.cols()}x${canvas.rows()}",
|
|
332
|
+
)
|
|
333
|
+
frameClipped.release()
|
|
334
|
+
frameBGR.release()
|
|
335
|
+
return FrameTelemetry(
|
|
336
|
+
FrameOutcome.AcceptedHigh, 1.0, 0, yaw, pitch, msSince(t0),
|
|
337
|
+
isLandscape = isLandscape,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// V12.2 cylindrical first frame: warp + place at canvas centre.
|
|
342
|
+
val warped = Mat()
|
|
343
|
+
val warpedMask = Mat()
|
|
344
|
+
val firstCornerCyl = cylindricalWarp(frameBGR, rNew, warped, warpedMask)
|
|
345
|
+
if (warped.empty()) {
|
|
346
|
+
frameBGR.release()
|
|
347
|
+
return FrameTelemetry(
|
|
348
|
+
FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch,
|
|
349
|
+
msSince(t0),
|
|
350
|
+
isLandscape = isLandscape,
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
val dstX = (canvasWidth - warped.cols()) / 2
|
|
354
|
+
val dstY = (canvasHeight - warped.rows()) / 2
|
|
355
|
+
val roi = Rect(dstX, dstY, warped.cols(), warped.rows()).intersection(
|
|
356
|
+
Rect(0, 0, canvasWidth, canvasHeight)
|
|
357
|
+
)
|
|
358
|
+
val srcR = Rect(0, 0, roi.width, roi.height)
|
|
359
|
+
Mat(warped, srcR).copyTo(Mat(canvas, roi), Mat(warpedMask, srcR))
|
|
360
|
+
Mat(warpedMask, srcR).copyTo(Mat(canvasMask, roi), Mat(warpedMask, srcR))
|
|
361
|
+
canvasOriginCylX = firstCornerCyl.x.toInt() - dstX
|
|
362
|
+
canvasOriginCylY = firstCornerCyl.y.toInt() - dstY
|
|
363
|
+
|
|
364
|
+
warped.release(); warpedMask.release()
|
|
365
|
+
hasFirstFrame = true
|
|
366
|
+
acceptedCountAtomic.set(1); cachedBoundingRect = null
|
|
367
|
+
frameBGR.release()
|
|
368
|
+
return FrameTelemetry(
|
|
369
|
+
FrameOutcome.AcceptedHigh, 1.0, 0, yaw, pitch, msSince(t0),
|
|
370
|
+
isLandscape = isLandscape,
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─── Subsequent frame ───────────────────────────────────────
|
|
375
|
+
if (useRectilinear) {
|
|
376
|
+
// V12.8 Variant B: paste the SAME long-side-clipped portion
|
|
377
|
+
// as the first frame at canvas offset = pan_angle * focal.
|
|
378
|
+
// First-painted-wins masking ensures only the leading-edge
|
|
379
|
+
// sliver (the part outside the previously-painted region)
|
|
380
|
+
// gets painted — smooth incremental growth from frame 2,
|
|
381
|
+
// no V12.7 dead-zone.
|
|
382
|
+
val rRel = Mat()
|
|
383
|
+
val firstT = firstRotationArkit.t()
|
|
384
|
+
try {
|
|
385
|
+
Core.gemm(firstT, rNew, 1.0, Mat(), 0.0, rRel)
|
|
386
|
+
} finally {
|
|
387
|
+
firstT.release()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// V12.12 — orientation-aware clip, MUST match the
|
|
391
|
+
// first-frame branch above so subsequent frames have the
|
|
392
|
+
// same shape as the first frame (otherwise paste positions
|
|
393
|
+
// get inconsistent).
|
|
394
|
+
val clipW: Int
|
|
395
|
+
val clipH: Int
|
|
396
|
+
val srcClipX: Int
|
|
397
|
+
val srcClipY: Int
|
|
398
|
+
if (isLandscape) {
|
|
399
|
+
clipW = frameBGR.cols()
|
|
400
|
+
clipH = max(1, (frameBGR.rows() * kPanAxisFractionRect).toInt())
|
|
401
|
+
srcClipX = 0
|
|
402
|
+
srcClipY = (frameBGR.rows() - clipH) / 2
|
|
403
|
+
} else {
|
|
404
|
+
clipW = max(1, (frameBGR.cols() * kPanAxisFractionRect).toInt())
|
|
405
|
+
clipH = frameBGR.rows()
|
|
406
|
+
srcClipX = (frameBGR.cols() - clipW) / 2
|
|
407
|
+
srcClipY = 0
|
|
408
|
+
}
|
|
409
|
+
val frameClipped = Mat(frameBGR, Rect(srcClipX, srcClipY, clipW, clipH))
|
|
410
|
+
|
|
411
|
+
var dstX: Int
|
|
412
|
+
var dstY: Int
|
|
413
|
+
val alpha: Double
|
|
414
|
+
if (isLandscape) {
|
|
415
|
+
// Vertical pan around cam +X: alpha = atan2(R_rel[2,1], R_rel[1,1]).
|
|
416
|
+
alpha = atan2(rRel[2, 1][0], rRel[1, 1][0])
|
|
417
|
+
dstX = firstFrameDstX
|
|
418
|
+
// alpha > 0 (look up) → content shifts UP in canvas.
|
|
419
|
+
dstY = firstFrameDstY - round(alpha * focalCompose).toInt()
|
|
420
|
+
} else {
|
|
421
|
+
// Horizontal pan around cam +Y: alpha = atan2(R_rel[0,2], R_rel[0,0]).
|
|
422
|
+
alpha = atan2(rRel[0, 2][0], rRel[0, 0][0])
|
|
423
|
+
// alpha > 0 (look right) → content shifts RIGHT in canvas.
|
|
424
|
+
dstX = firstFrameDstX + round(alpha * focalCompose).toInt()
|
|
425
|
+
dstY = firstFrameDstY
|
|
426
|
+
}
|
|
427
|
+
rRel.release()
|
|
428
|
+
|
|
429
|
+
// V13.0a — REVERTED V12.11 Step 4 + V12.11.1 Item E + V12.14
|
|
430
|
+
// homography refinement (mirrors iOS revert). See
|
|
431
|
+
// OpenCVSlitScanStitcher.mm for the rationale. Restored
|
|
432
|
+
// pose-only paste; perpendicular drift correction will
|
|
433
|
+
// come back in V13.0b as 1D column-edge NCC correlation.
|
|
434
|
+
|
|
435
|
+
// V12.11 Step D — reverse-direction detection. Mirrors
|
|
436
|
+
// iOS' running-max check. See OpenCVSlitScanStitcher.mm
|
|
437
|
+
// for the full rationale. 150 px ≈ 4° of pan at the
|
|
438
|
+
// typical iPhone focal — comfortably above wobble.
|
|
439
|
+
val kReverseStopPx = 150
|
|
440
|
+
if (isLandscape) {
|
|
441
|
+
if (dstY > maxDstY) {
|
|
442
|
+
maxDstY = dstY
|
|
443
|
+
} else if (dstY < maxDstY - kReverseStopPx) {
|
|
444
|
+
Log.i(
|
|
445
|
+
"V12.11-reverse",
|
|
446
|
+
"landscape stop: dstY=$dstY max=$maxDstY (regressed ${maxDstY - dstY} px)",
|
|
447
|
+
)
|
|
448
|
+
frameClipped.release()
|
|
449
|
+
frameBGR.release()
|
|
450
|
+
return FrameTelemetry(
|
|
451
|
+
FrameOutcome.RejectedReverseDirection, -1.0, 0, yaw, pitch,
|
|
452
|
+
msSince(t0),
|
|
453
|
+
isLandscape = isLandscape,
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
if (dstX > maxDstX) {
|
|
458
|
+
maxDstX = dstX
|
|
459
|
+
} else if (dstX < maxDstX - kReverseStopPx) {
|
|
460
|
+
Log.i(
|
|
461
|
+
"V12.11-reverse",
|
|
462
|
+
"portrait stop: dstX=$dstX max=$maxDstX (regressed ${maxDstX - dstX} px)",
|
|
463
|
+
)
|
|
464
|
+
frameClipped.release()
|
|
465
|
+
frameBGR.release()
|
|
466
|
+
return FrameTelemetry(
|
|
467
|
+
FrameOutcome.RejectedReverseDirection, -1.0, 0, yaw, pitch,
|
|
468
|
+
msSince(t0),
|
|
469
|
+
isLandscape = isLandscape,
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// V13.0a — pose-only paste (the ONLY paste path now).
|
|
475
|
+
// V12.11.1's full warpPerspective paste was removed; this
|
|
476
|
+
// pose-projected (dstX, dstY) is the final paste position.
|
|
477
|
+
val dstRoi = Rect(dstX, dstY, clipW, clipH).intersection(
|
|
478
|
+
Rect(0, 0, canvas.cols(), canvas.rows())
|
|
479
|
+
)
|
|
480
|
+
if (dstRoi.width <= 0 || dstRoi.height <= 0) {
|
|
481
|
+
frameClipped.release()
|
|
482
|
+
frameBGR.release()
|
|
483
|
+
return FrameTelemetry(
|
|
484
|
+
FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch,
|
|
485
|
+
msSince(t0),
|
|
486
|
+
isLandscape = isLandscape,
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
val srcRoi = Rect(
|
|
490
|
+
dstRoi.x - dstX,
|
|
491
|
+
dstRoi.y - dstY,
|
|
492
|
+
dstRoi.width, dstRoi.height,
|
|
493
|
+
)
|
|
494
|
+
val srcRegion = Mat(frameClipped, srcRoi)
|
|
495
|
+
val canvasRoi = Mat(canvas, dstRoi)
|
|
496
|
+
val maskRoi = Mat(canvasMask, dstRoi)
|
|
497
|
+
val emptyMask = Mat()
|
|
498
|
+
Core.compare(maskRoi, Scalar(0.0), emptyMask, Core.CMP_EQ)
|
|
499
|
+
val newPixels = Core.countNonZero(emptyMask)
|
|
500
|
+
if (newPixels > 0) {
|
|
501
|
+
srcRegion.copyTo(canvasRoi, emptyMask)
|
|
502
|
+
maskRoi.setTo(Scalar(255.0), emptyMask)
|
|
503
|
+
acceptedCountAtomic.incrementAndGet(); cachedBoundingRect = null
|
|
504
|
+
srcRegion.release(); canvasRoi.release(); maskRoi.release()
|
|
505
|
+
emptyMask.release(); frameClipped.release()
|
|
506
|
+
frameBGR.release()
|
|
507
|
+
return FrameTelemetry(
|
|
508
|
+
FrameOutcome.AcceptedHigh, 1.0, 0, yaw, pitch, msSince(t0),
|
|
509
|
+
isLandscape = isLandscape,
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
srcRegion.release(); canvasRoi.release(); maskRoi.release()
|
|
513
|
+
emptyMask.release(); frameClipped.release()
|
|
514
|
+
frameBGR.release()
|
|
515
|
+
return FrameTelemetry(
|
|
516
|
+
FrameOutcome.SkippedTooClose, 0.0, 0, yaw, pitch, msSince(t0),
|
|
517
|
+
isLandscape = isLandscape,
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// V12.4 firstwins (cylindrical + central crop + first-painted-wins).
|
|
522
|
+
val warped = Mat()
|
|
523
|
+
val warpedMask = Mat()
|
|
524
|
+
val newCornerCyl = cylindricalWarp(frameBGR, rNew, warped, warpedMask)
|
|
525
|
+
if (warped.empty()) {
|
|
526
|
+
frameBGR.release()
|
|
527
|
+
return FrameTelemetry(
|
|
528
|
+
FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch, msSince(t0),
|
|
529
|
+
isLandscape = isLandscape,
|
|
530
|
+
)
|
|
531
|
+
}
|
|
532
|
+
val newCornerCanvas = Point(
|
|
533
|
+
(newCornerCyl.x - canvasOriginCylX).toDouble(),
|
|
534
|
+
(newCornerCyl.y - canvasOriginCylY).toDouble(),
|
|
535
|
+
)
|
|
536
|
+
val dstRoi = Rect(
|
|
537
|
+
newCornerCanvas.x.toInt(), newCornerCanvas.y.toInt(),
|
|
538
|
+
warped.cols(), warped.rows(),
|
|
539
|
+
).intersection(Rect(0, 0, canvas.cols(), canvas.rows()))
|
|
540
|
+
if (dstRoi.width <= 0 || dstRoi.height <= 0) {
|
|
541
|
+
warped.release(); warpedMask.release(); frameBGR.release()
|
|
542
|
+
return FrameTelemetry(
|
|
543
|
+
FrameOutcome.RejectedAlignmentLost, -1.0, 0, yaw, pitch, msSince(t0),
|
|
544
|
+
isLandscape = isLandscape,
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
val srcRoi = Rect(
|
|
548
|
+
dstRoi.x - newCornerCanvas.x.toInt(),
|
|
549
|
+
dstRoi.y - newCornerCanvas.y.toInt(),
|
|
550
|
+
dstRoi.width, dstRoi.height,
|
|
551
|
+
)
|
|
552
|
+
val warpedClipped = Mat(warped, srcRoi)
|
|
553
|
+
val warpedMaskClipped = Mat(warpedMask, srcRoi)
|
|
554
|
+
val canvasRoi = Mat(canvas, dstRoi)
|
|
555
|
+
val canvasMaskRoi = Mat(canvasMask, dstRoi)
|
|
556
|
+
|
|
557
|
+
// First-painted-wins: paint where canvasMask == 0 AND warped mask == 255.
|
|
558
|
+
val noPrior = Mat()
|
|
559
|
+
Core.compare(canvasMaskRoi, Scalar(0.0), noPrior, Core.CMP_EQ)
|
|
560
|
+
val paintMask = Mat()
|
|
561
|
+
Core.bitwise_and(noPrior, warpedMaskClipped, paintMask)
|
|
562
|
+
val newPixels = Core.countNonZero(paintMask)
|
|
563
|
+
if (newPixels > 0) {
|
|
564
|
+
warpedClipped.copyTo(canvasRoi, paintMask)
|
|
565
|
+
paintMask.copyTo(canvasMaskRoi, paintMask)
|
|
566
|
+
acceptedCountAtomic.incrementAndGet(); cachedBoundingRect = null
|
|
567
|
+
}
|
|
568
|
+
noPrior.release(); paintMask.release()
|
|
569
|
+
warped.release(); warpedMask.release(); frameBGR.release()
|
|
570
|
+
return FrameTelemetry(
|
|
571
|
+
if (newPixels > 0) FrameOutcome.AcceptedHigh else FrameOutcome.SkippedTooClose,
|
|
572
|
+
if (newPixels > 0) 1.0 else 0.0, 0, yaw, pitch, msSince(t0),
|
|
573
|
+
isLandscape = isLandscape,
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Live-snapshot path — same JPEG-cycle pattern as iOS. Cycles
|
|
579
|
+
* through 4 filenames so RN's <Image> cache sees a fresh URI
|
|
580
|
+
* each accept.
|
|
581
|
+
*
|
|
582
|
+
* Critic #8 / #9 fix: ALWAYS build state and pass `telemetry` so
|
|
583
|
+
* JS sees outcome / confidence / overlapPercent / processingMs
|
|
584
|
+
* even on rejected/skipped frames. Matches the JS
|
|
585
|
+
* IncrementalState contract in src/stitching/incremental.ts.
|
|
586
|
+
*/
|
|
587
|
+
fun snapshotIfDue(telemetry: FrameTelemetry): WritableMap? {
|
|
588
|
+
val isAccept = telemetry.outcome == FrameOutcome.AcceptedHigh ||
|
|
589
|
+
telemetry.outcome == FrameOutcome.AcceptedMedium
|
|
590
|
+
var snapshotPath: String? = null
|
|
591
|
+
if (isAccept) {
|
|
592
|
+
acceptsSinceSnapshot += 1
|
|
593
|
+
if (acceptsSinceSnapshot >= snapshotEveryNAccepts) {
|
|
594
|
+
acceptsSinceSnapshot = 0
|
|
595
|
+
val path = currentSnapshotPath()
|
|
596
|
+
if (writeOut(path, snapshotJpegQuality, applyExposureComp = false)) {
|
|
597
|
+
snapshotPath = path
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
lastState = buildState(snapshotPath = snapshotPath, telemetry = telemetry)
|
|
602
|
+
return lastState
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
fun finalize(outputPath: String, quality: Int): StitcherSnapshot? {
|
|
606
|
+
val cleaned = outputPath.removePrefix("file://")
|
|
607
|
+
val ok = writeOut(cleaned, quality, applyExposureComp = true)
|
|
608
|
+
if (!ok) return null
|
|
609
|
+
val bbox = cachedBoundingRect ?: Imgproc.boundingRect(canvasMask).also { cachedBoundingRect = it }
|
|
610
|
+
val snap = StitcherSnapshot(
|
|
611
|
+
cleaned,
|
|
612
|
+
if (bbox.width > 0) bbox.width else canvasWidth,
|
|
613
|
+
if (bbox.height > 0) bbox.height else canvasHeight,
|
|
614
|
+
acceptedCount,
|
|
615
|
+
)
|
|
616
|
+
reset()
|
|
617
|
+
return snap
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Critic #22 fix: explicit native-buffer release (75 MB canvas
|
|
622
|
+
* + 25 MB mask + smaller transient Mats). Call from the bridge
|
|
623
|
+
* when the engine is being thrown away (finalize/cancel paths).
|
|
624
|
+
* After this, the engine is unusable.
|
|
625
|
+
*/
|
|
626
|
+
fun release() {
|
|
627
|
+
canvas.release()
|
|
628
|
+
canvasMask.release()
|
|
629
|
+
firstRotationArkit.release()
|
|
630
|
+
rPanToWorld.release()
|
|
631
|
+
kCompose.release()
|
|
632
|
+
mArkitToCv.release()
|
|
633
|
+
cachedBoundingRect = null
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
fun reset() {
|
|
637
|
+
canvas.setTo(Scalar(0.0, 0.0, 0.0))
|
|
638
|
+
canvasMask.setTo(Scalar(0.0))
|
|
639
|
+
firstRotationArkit = Mat()
|
|
640
|
+
rPanToWorld = Mat()
|
|
641
|
+
kCompose = Mat()
|
|
642
|
+
focalCompose = 0.0
|
|
643
|
+
canvasOriginCylX = 0
|
|
644
|
+
canvasOriginCylY = 0
|
|
645
|
+
firstFrameDstX = 0
|
|
646
|
+
firstFrameDstY = 0
|
|
647
|
+
// V12.11 Step D — clear running-max trackers; reinitialised
|
|
648
|
+
// to first-frame position on next accept.
|
|
649
|
+
maxDstX = 0
|
|
650
|
+
maxDstY = 0
|
|
651
|
+
isLandscape = false
|
|
652
|
+
hasFirstFrame = false
|
|
653
|
+
acceptsSinceSnapshot = 0
|
|
654
|
+
acceptedCountAtomic.set(0)
|
|
655
|
+
snapshotSeq = 0
|
|
656
|
+
lastState = null
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ─── Internals ─────────────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
private fun computeRPanToCam(rArkit: Mat): Mat {
|
|
662
|
+
// R_panToCam = M · R_arkit^T · R_panToWorld
|
|
663
|
+
val tmp1 = Mat()
|
|
664
|
+
Core.gemm(rArkit.t(), rPanToWorld, 1.0, Mat(), 0.0, tmp1)
|
|
665
|
+
val out = Mat()
|
|
666
|
+
Core.gemm(mArkitToCv, tmp1, 1.0, Mat(), 0.0, out)
|
|
667
|
+
tmp1.release()
|
|
668
|
+
return out
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* V12.6 cylindrical warp — full annotation in iOS' OpenCVSlitScanStitcher.mm.
|
|
673
|
+
* Returns the bbox top-left in cylindrical-pixel coords. Writes the
|
|
674
|
+
* warped frame into outImage and the corresponding mask into outMask.
|
|
675
|
+
*/
|
|
676
|
+
private fun cylindricalWarp(src: Mat, rArkit: Mat, outImage: Mat, outMask: Mat): Point {
|
|
677
|
+
if (rPanToWorld.empty() || focalCompose <= 0) {
|
|
678
|
+
outImage.release(); outMask.release()
|
|
679
|
+
return Point(0.0, 0.0)
|
|
680
|
+
}
|
|
681
|
+
val rPanToCam = computeRPanToCam(rArkit)
|
|
682
|
+
val rCamToPan = rPanToCam.t()
|
|
683
|
+
|
|
684
|
+
val fx = kCompose[0, 0][0]
|
|
685
|
+
val fy = kCompose[1, 1][0]
|
|
686
|
+
val cx = kCompose[0, 2][0]
|
|
687
|
+
val cy = kCompose[1, 2][0]
|
|
688
|
+
val f = focalCompose
|
|
689
|
+
|
|
690
|
+
// Forward-project the 4 source corners.
|
|
691
|
+
val r00 = rPanToCam[0, 0][0]; val r01 = rPanToCam[0, 1][0]; val r02 = rPanToCam[0, 2][0]
|
|
692
|
+
val r10 = rPanToCam[1, 0][0]; val r11 = rPanToCam[1, 1][0]; val r12 = rPanToCam[1, 2][0]
|
|
693
|
+
val r20 = rPanToCam[2, 0][0]; val r21 = rPanToCam[2, 1][0]; val r22 = rPanToCam[2, 2][0]
|
|
694
|
+
val cTP00 = rCamToPan[0, 0][0]; val cTP01 = rCamToPan[0, 1][0]; val cTP02 = rCamToPan[0, 2][0]
|
|
695
|
+
val cTP10 = rCamToPan[1, 0][0]; val cTP11 = rCamToPan[1, 1][0]; val cTP12 = rCamToPan[1, 2][0]
|
|
696
|
+
val cTP20 = rCamToPan[2, 0][0]; val cTP21 = rCamToPan[2, 1][0]; val cTP22 = rCamToPan[2, 2][0]
|
|
697
|
+
|
|
698
|
+
fun project(u: Double, v: Double): DoubleArray {
|
|
699
|
+
val rx = (u - cx) / fx
|
|
700
|
+
val ry = (v - cy) / fy
|
|
701
|
+
val rz = 1.0
|
|
702
|
+
val wx = cTP00 * rx + cTP01 * ry + cTP02 * rz
|
|
703
|
+
val wy = cTP10 * rx + cTP11 * ry + cTP12 * rz
|
|
704
|
+
val wz = cTP20 * rx + cTP21 * ry + cTP22 * rz
|
|
705
|
+
return if (isLandscape) {
|
|
706
|
+
// Transverse cylinder (axis = pan_X)
|
|
707
|
+
val denom = sqrt(wy * wy + wz * wz)
|
|
708
|
+
val s = if (denom > 1e-9) (-wx / denom) else 0.0
|
|
709
|
+
val theta = atan2(wy, wz)
|
|
710
|
+
doubleArrayOf(f * s, -f * theta)
|
|
711
|
+
} else {
|
|
712
|
+
// Vertical cylinder (axis = pan_Y), V12 mirror fix.
|
|
713
|
+
val theta = atan2(-wx, wz)
|
|
714
|
+
val denom = sqrt(wx * wx + wz * wz)
|
|
715
|
+
val h = if (denom > 1e-9) (wy / denom) else 0.0
|
|
716
|
+
doubleArrayOf(f * theta, -f * h)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
val c00 = project(0.0, 0.0)
|
|
721
|
+
val c10 = project((src.cols() - 1).toDouble(), 0.0)
|
|
722
|
+
val c01 = project(0.0, (src.rows() - 1).toDouble())
|
|
723
|
+
val c11 = project((src.cols() - 1).toDouble(), (src.rows() - 1).toDouble())
|
|
724
|
+
val xs = doubleArrayOf(c00[0], c10[0], c01[0], c11[0])
|
|
725
|
+
val ys = doubleArrayOf(c00[1], c10[1], c01[1], c11[1])
|
|
726
|
+
val minX = xs.min(); val maxX = xs.max()
|
|
727
|
+
val minY = ys.min(); val maxY = ys.max()
|
|
728
|
+
|
|
729
|
+
var bboxX = floor(minX).toInt()
|
|
730
|
+
var bboxY = floor(minY).toInt()
|
|
731
|
+
var bboxW = (Math.ceil(maxX - minX).toInt()) + 1
|
|
732
|
+
var bboxH = (Math.ceil(maxY - minY).toInt()) + 1
|
|
733
|
+
if (bboxW <= 0 || bboxH <= 0 ||
|
|
734
|
+
bboxW > canvasWidth * 2 || bboxH > canvasHeight * 2) {
|
|
735
|
+
outImage.release(); outMask.release()
|
|
736
|
+
rPanToCam.release(); rCamToPan.release()
|
|
737
|
+
return Point(0.0, 0.0)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// V12.4 slit-scan + long-side clip.
|
|
741
|
+
run {
|
|
742
|
+
val newW: Int
|
|
743
|
+
val newH: Int
|
|
744
|
+
if (isLandscape) {
|
|
745
|
+
newW = max(1, (bboxW * kLongSideFraction).toInt())
|
|
746
|
+
newH = max(1, (bboxH * kPanStripFraction).toInt())
|
|
747
|
+
} else {
|
|
748
|
+
newW = max(1, (bboxW * kPanStripFraction).toInt())
|
|
749
|
+
newH = max(1, (bboxH * kLongSideFraction).toInt())
|
|
750
|
+
}
|
|
751
|
+
bboxX += (bboxW - newW) / 2
|
|
752
|
+
bboxY += (bboxH - newH) / 2
|
|
753
|
+
bboxW = newW
|
|
754
|
+
bboxH = newH
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Inverse-map: build mapX/mapY for cv::remap, then warp.
|
|
758
|
+
val mapX = Mat(bboxH, bboxW, CvType.CV_32FC1)
|
|
759
|
+
val mapY = Mat(bboxH, bboxW, CvType.CV_32FC1)
|
|
760
|
+
|
|
761
|
+
val rowMx = FloatArray(bboxW)
|
|
762
|
+
val rowMy = FloatArray(bboxW)
|
|
763
|
+
val srcCols = src.cols(); val srcRows = src.rows()
|
|
764
|
+
|
|
765
|
+
if (isLandscape) {
|
|
766
|
+
for (y in 0 until bboxH) {
|
|
767
|
+
val sphereY = (bboxY + y).toDouble()
|
|
768
|
+
val theta = -sphereY / f
|
|
769
|
+
val sinT = sin(theta); val cosT = cos(theta)
|
|
770
|
+
for (x in 0 until bboxW) {
|
|
771
|
+
val sphereX = (bboxX + x).toDouble()
|
|
772
|
+
val s = sphereX / f
|
|
773
|
+
val wx = -s
|
|
774
|
+
val wy = sinT
|
|
775
|
+
val wz = cosT
|
|
776
|
+
val rx = r00 * wx + r01 * wy + r02 * wz
|
|
777
|
+
val ry = r10 * wx + r11 * wy + r12 * wz
|
|
778
|
+
val rz = r20 * wx + r21 * wy + r22 * wz
|
|
779
|
+
if (rz <= 1e-6) {
|
|
780
|
+
rowMx[x] = -1.0f; rowMy[x] = -1.0f
|
|
781
|
+
} else {
|
|
782
|
+
val u = fx * rx / rz + cx
|
|
783
|
+
val v = fy * ry / rz + cy
|
|
784
|
+
if (u < 0 || u >= srcCols || v < 0 || v >= srcRows) {
|
|
785
|
+
rowMx[x] = -1.0f; rowMy[x] = -1.0f
|
|
786
|
+
} else {
|
|
787
|
+
rowMx[x] = u.toFloat(); rowMy[x] = v.toFloat()
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
mapX.put(y, 0, rowMx)
|
|
792
|
+
mapY.put(y, 0, rowMy)
|
|
793
|
+
}
|
|
794
|
+
} else {
|
|
795
|
+
for (y in 0 until bboxH) {
|
|
796
|
+
val cylY = (bboxY + y).toDouble()
|
|
797
|
+
val h = -cylY / f
|
|
798
|
+
for (x in 0 until bboxW) {
|
|
799
|
+
val cylX = (bboxX + x).toDouble()
|
|
800
|
+
val theta = cylX / f
|
|
801
|
+
val sinT = sin(theta); val cosT = cos(theta)
|
|
802
|
+
val wx = -sinT; val wy = h; val wz = cosT
|
|
803
|
+
val rx = r00 * wx + r01 * wy + r02 * wz
|
|
804
|
+
val ry = r10 * wx + r11 * wy + r12 * wz
|
|
805
|
+
val rz = r20 * wx + r21 * wy + r22 * wz
|
|
806
|
+
if (rz <= 1e-6) {
|
|
807
|
+
rowMx[x] = -1.0f; rowMy[x] = -1.0f
|
|
808
|
+
} else {
|
|
809
|
+
val u = fx * rx / rz + cx
|
|
810
|
+
val v = fy * ry / rz + cy
|
|
811
|
+
if (u < 0 || u >= srcCols || v < 0 || v >= srcRows) {
|
|
812
|
+
rowMx[x] = -1.0f; rowMy[x] = -1.0f
|
|
813
|
+
} else {
|
|
814
|
+
rowMx[x] = u.toFloat(); rowMy[x] = v.toFloat()
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
mapX.put(y, 0, rowMx)
|
|
819
|
+
mapY.put(y, 0, rowMy)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
outImage.create(bboxH, bboxW, src.type())
|
|
824
|
+
Imgproc.remap(
|
|
825
|
+
src, outImage, mapX, mapY,
|
|
826
|
+
Imgproc.INTER_LINEAR, Core.BORDER_CONSTANT, Scalar(0.0, 0.0, 0.0),
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
// Build the mask in a single pass by re-reading mapX rows.
|
|
830
|
+
outMask.create(bboxH, bboxW, CvType.CV_8UC1)
|
|
831
|
+
outMask.setTo(Scalar(0.0))
|
|
832
|
+
val maskRow = ByteArray(bboxW)
|
|
833
|
+
for (y in 0 until bboxH) {
|
|
834
|
+
mapX.get(y, 0, rowMx)
|
|
835
|
+
for (x in 0 until bboxW) {
|
|
836
|
+
maskRow[x] = if (rowMx[x] >= 0.0f) 255.toByte() else 0.toByte()
|
|
837
|
+
}
|
|
838
|
+
outMask.put(y, 0, maskRow)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
mapX.release(); mapY.release()
|
|
842
|
+
rPanToCam.release(); rCamToPan.release()
|
|
843
|
+
return Point(bboxX.toDouble(), bboxY.toDouble())
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private fun downsampleToCompose(src: Mat): Mat {
|
|
847
|
+
val scale = min(
|
|
848
|
+
composeWidth.toDouble() / src.cols(),
|
|
849
|
+
composeHeight.toDouble() / src.rows(),
|
|
850
|
+
)
|
|
851
|
+
if (scale >= 1.0) return src
|
|
852
|
+
val outW = max(1, round(src.cols() * scale).toInt())
|
|
853
|
+
val outH = max(1, round(src.rows() * scale).toInt())
|
|
854
|
+
val out = Mat()
|
|
855
|
+
Imgproc.resize(src, out, Size(outW.toDouble(), outH.toDouble()), 0.0, 0.0, Imgproc.INTER_AREA)
|
|
856
|
+
return out
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
private fun quaternionToRotationMat(qx: Double, qy: Double, qz: Double, qw: Double): Mat {
|
|
860
|
+
val n = sqrt(qx * qx + qy * qy + qz * qz + qw * qw)
|
|
861
|
+
val x = if (n > 1e-9) qx / n else qx
|
|
862
|
+
val y = if (n > 1e-9) qy / n else qy
|
|
863
|
+
val z = if (n > 1e-9) qz / n else qz
|
|
864
|
+
val w = if (n > 1e-9) qw / n else qw
|
|
865
|
+
return Mat(3, 3, CvType.CV_64F).apply {
|
|
866
|
+
put(0, 0, 1 - 2 * (y * y + z * z)); put(0, 1, 2 * (x * y - w * z)); put(0, 2, 2 * (x * z + w * y))
|
|
867
|
+
put(1, 0, 2 * (x * y + w * z)); put(1, 1, 1 - 2 * (x * x + z * z)); put(1, 2, 2 * (y * z - w * x))
|
|
868
|
+
put(2, 0, 2 * (x * z - w * y)); put(2, 1, 2 * (y * z + w * x)); put(2, 2, 1 - 2 * (x * x + y * y))
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private fun currentSnapshotPath(): String {
|
|
873
|
+
snapshotSeq += 1
|
|
874
|
+
val slot = snapshotSeq % 4
|
|
875
|
+
// Critic #27 fix: use the bridge-provided cache dir
|
|
876
|
+
// (reactContext.cacheDir.absolutePath), NOT java.io.tmpdir
|
|
877
|
+
// which on Android is /data/local/tmp (rooted-only).
|
|
878
|
+
return "$snapshotCacheDir/rlis-live-$slot.jpg"
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
private fun writeOut(path: String, quality: Int, applyExposureComp: Boolean): Boolean {
|
|
882
|
+
// V12 — bbox crop only, no inscribed-rect search (dropped per V12 plan).
|
|
883
|
+
val bbox = Imgproc.boundingRect(canvasMask)
|
|
884
|
+
val cropRect = if (bbox.width > 0 && bbox.height > 0) bbox
|
|
885
|
+
else Rect(0, 0, canvasWidth, canvasHeight)
|
|
886
|
+
val cropped = Mat(canvas, cropRect).clone()
|
|
887
|
+
val out = if (applyExposureComp) applyClahe(cropped) else cropped
|
|
888
|
+
val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, max(1, min(100, quality)))
|
|
889
|
+
val ok = Imgcodecs.imwrite(path, out, params)
|
|
890
|
+
if (cropped !== out) cropped.release()
|
|
891
|
+
out.release()
|
|
892
|
+
return ok
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private fun applyClahe(src: Mat): Mat {
|
|
896
|
+
val lab = Mat()
|
|
897
|
+
Imgproc.cvtColor(src, lab, Imgproc.COLOR_BGR2Lab)
|
|
898
|
+
val channels = mutableListOf<Mat>()
|
|
899
|
+
Core.split(lab, channels)
|
|
900
|
+
val clahe = Imgproc.createCLAHE(2.0, Size(8.0, 8.0))
|
|
901
|
+
clahe.apply(channels[0], channels[0])
|
|
902
|
+
Core.merge(channels, lab)
|
|
903
|
+
val out = Mat()
|
|
904
|
+
Imgproc.cvtColor(lab, out, Imgproc.COLOR_Lab2BGR)
|
|
905
|
+
for (c in channels) c.release()
|
|
906
|
+
lab.release()
|
|
907
|
+
return out
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Critic #8/#9/#23 fix: always include the full state event shape
|
|
912
|
+
* the JS IncrementalState interface expects (outcome, confidence,
|
|
913
|
+
* overlapPercent, processingMs). Matches the hybrid engine's
|
|
914
|
+
* shape so JS subscribers don't break when the engine variant
|
|
915
|
+
* is toggled at runtime.
|
|
916
|
+
*
|
|
917
|
+
* Critic #30 fix: use cached bounding rect; refresh once per
|
|
918
|
+
* accept inside the inner loops, NOT here on every state event.
|
|
919
|
+
*/
|
|
920
|
+
private fun buildState(snapshotPath: String?, telemetry: FrameTelemetry): WritableMap {
|
|
921
|
+
val map = com.facebook.react.bridge.Arguments.createMap()
|
|
922
|
+
map.putInt("acceptedCount", acceptedCount)
|
|
923
|
+
if (snapshotPath != null) {
|
|
924
|
+
map.putString("panoramaPath", snapshotPath)
|
|
925
|
+
} else {
|
|
926
|
+
map.putNull("panoramaPath")
|
|
927
|
+
}
|
|
928
|
+
val r = cachedBoundingRect ?: Imgproc.boundingRect(canvasMask).also { cachedBoundingRect = it }
|
|
929
|
+
map.putInt("width", if (r.width > 0) r.width else 0)
|
|
930
|
+
map.putInt("height", if (r.height > 0) r.height else 0)
|
|
931
|
+
map.putInt("outcome", telemetry.outcome.ordinal)
|
|
932
|
+
map.putDouble("confidence", telemetry.confidence)
|
|
933
|
+
map.putDouble("overlapPercent", telemetry.overlapPercent)
|
|
934
|
+
map.putDouble("processingMs", telemetry.processingMs)
|
|
935
|
+
// V12.12 — engine-detected orientation, plumbed through to JS
|
|
936
|
+
// for the band overlay + dim bar UI.
|
|
937
|
+
map.putBoolean("isLandscape", telemetry.isLandscape)
|
|
938
|
+
return map
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private fun msSince(t0Nanos: Long): Double =
|
|
942
|
+
(System.nanoTime() - t0Nanos) / 1_000_000.0
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Helper: Rect intersection (Android OpenCV doesn't expose it cleanly).
|
|
946
|
+
private fun Rect.intersection(other: Rect): Rect {
|
|
947
|
+
val x = max(this.x, other.x)
|
|
948
|
+
val y = max(this.y, other.y)
|
|
949
|
+
val r = min(this.x + this.width, other.x + other.width)
|
|
950
|
+
val b = min(this.y + this.height, other.y + other.height)
|
|
951
|
+
return Rect(x, y, max(0, r - x), max(0, b - y))
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// V13.0a — homographyOffset(), HomographyResult, and the kHomogTier*
|
|
955
|
+
// constants were removed in the revert from V12.11.1 + V12.14
|
|
956
|
+
// (ORB+RANSAC homography correction with 3-tier confidence ladder)
|
|
957
|
+
// back to pose-only paste. See iOS OpenCVSlitScanStitcher.mm for
|
|
958
|
+
// the matching revert + V13.0b plan (1D column-edge NCC correlation
|
|
959
|
+
// for sub-pixel perpendicular drift correction).
|
|
960
|
+
|