react-native-image-stitcher 0.15.2 → 0.16.1
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 +171 -1
- package/README.md +131 -5
- package/android/src/main/cpp/image_stitcher_jni.cpp +154 -13
- package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +220 -87
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
- package/cpp/crop_quad.cpp +162 -0
- package/cpp/crop_quad.hpp +163 -0
- package/cpp/keyframe_gate.cpp +54 -15
- package/cpp/keyframe_gate.hpp +33 -0
- package/cpp/stitcher.cpp +1122 -132
- package/cpp/stitcher.hpp +62 -0
- package/cpp/warp_guard.hpp +212 -0
- package/dist/camera/Camera.d.ts +209 -12
- package/dist/camera/Camera.js +575 -36
- package/dist/camera/CameraView.js +35 -16
- package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
- package/dist/camera/CaptureCountdownOverlay.js +239 -0
- package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
- package/dist/camera/CaptureFrameCounterOverlay.js +153 -0
- package/dist/camera/CaptureMemoryPill.d.ts +24 -8
- package/dist/camera/CaptureMemoryPill.js +37 -12
- package/dist/camera/CapturePreview.js +2 -1
- package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
- package/dist/camera/CaptureStatusOverlay.js +22 -5
- package/dist/camera/CaptureThumbnailStrip.js +2 -1
- package/dist/camera/LateralMotionModal.d.ts +85 -0
- package/dist/camera/LateralMotionModal.js +134 -0
- package/dist/camera/PanHowToOverlay.d.ts +76 -0
- package/dist/camera/PanHowToOverlay.js +222 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
- package/dist/camera/PanoramaBandOverlay.js +9 -3
- package/dist/camera/PanoramaSettings.d.ts +8 -6
- package/dist/camera/PanoramaSettings.js +19 -1
- package/dist/camera/PanoramaSettingsModal.js +4 -4
- package/dist/camera/RectCropPreview.d.ts +135 -0
- package/dist/camera/RectCropPreview.js +370 -0
- package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
- package/dist/camera/RotateToLandscapePrompt.js +138 -0
- package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
- package/dist/camera/buildPanoramaInitialSettings.js +9 -0
- package/dist/camera/cameraErrorMessages.d.ts +30 -1
- package/dist/camera/cameraErrorMessages.js +26 -10
- package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
- package/dist/camera/cameraGuidanceCopy.js +80 -0
- package/dist/camera/captureCountdown.d.ts +52 -0
- package/dist/camera/captureCountdown.js +76 -0
- package/dist/camera/captureWarnings.d.ts +90 -0
- package/dist/camera/captureWarnings.js +108 -0
- package/dist/camera/classifyStitchError.d.ts +30 -0
- package/dist/camera/classifyStitchError.js +42 -0
- package/dist/camera/cropGeometry.d.ts +136 -0
- package/dist/camera/cropGeometry.js +223 -0
- package/dist/camera/displayDecodeImageProps.d.ts +25 -0
- package/dist/camera/displayDecodeImageProps.js +29 -0
- package/dist/camera/guidanceGraphics.d.ts +58 -0
- package/dist/camera/guidanceGraphics.js +280 -0
- package/dist/camera/guidanceTokens.d.ts +54 -0
- package/dist/camera/guidanceTokens.js +58 -0
- package/dist/camera/panModeGate.d.ts +54 -0
- package/dist/camera/panModeGate.js +62 -0
- package/dist/camera/pickCaptureFormat.d.ts +71 -0
- package/dist/camera/pickCaptureFormat.js +85 -0
- package/dist/camera/stitchDebugInfo.d.ts +27 -0
- package/dist/camera/stitchDebugInfo.js +55 -0
- package/dist/camera/usePanMotion.d.ts +250 -0
- package/dist/camera/usePanMotion.js +451 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +33 -2
- package/dist/stitching/computeInscribedRect.d.ts +40 -0
- package/dist/stitching/computeInscribedRect.js +55 -0
- package/dist/stitching/cropQuad.d.ts +78 -0
- package/dist/stitching/cropQuad.js +116 -0
- package/dist/stitching/incremental.d.ts +74 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
- package/dist/stitching/useIncrementalStitcher.js +7 -1
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +154 -29
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +211 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
- package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
- package/package.json +5 -1
- package/src/camera/Camera.tsx +945 -47
- package/src/camera/CameraView.tsx +48 -16
- package/src/camera/CaptureCountdownOverlay.tsx +272 -0
- package/src/camera/CaptureFrameCounterOverlay.tsx +197 -0
- package/src/camera/CaptureMemoryPill.tsx +50 -12
- package/src/camera/CapturePreview.tsx +5 -0
- package/src/camera/CaptureStatusOverlay.tsx +35 -7
- package/src/camera/CaptureThumbnailStrip.tsx +4 -0
- package/src/camera/LateralMotionModal.tsx +199 -0
- package/src/camera/PanHowToOverlay.tsx +246 -0
- package/src/camera/PanoramaBandOverlay.tsx +9 -1
- package/src/camera/PanoramaSettings.ts +27 -7
- package/src/camera/PanoramaSettingsModal.tsx +4 -4
- package/src/camera/RectCropPreview.tsx +638 -0
- package/src/camera/RotateToLandscapePrompt.tsx +188 -0
- package/src/camera/buildPanoramaInitialSettings.ts +30 -1
- package/src/camera/cameraErrorMessages.ts +39 -2
- package/src/camera/cameraGuidanceCopy.ts +145 -0
- package/src/camera/captureCountdown.ts +83 -0
- package/src/camera/captureWarnings.ts +190 -0
- package/src/camera/classifyStitchError.ts +68 -0
- package/src/camera/cropGeometry.ts +268 -0
- package/src/camera/displayDecodeImageProps.ts +25 -0
- package/src/camera/guidanceGraphics.tsx +347 -0
- package/src/camera/guidanceTokens.ts +57 -0
- package/src/camera/panModeGate.ts +81 -0
- package/src/camera/pickCaptureFormat.ts +130 -0
- package/src/camera/stitchDebugInfo.ts +71 -0
- package/src/camera/usePanMotion.ts +667 -0
- package/src/index.ts +66 -3
- package/src/stitching/computeInscribedRect.ts +81 -0
- package/src/stitching/cropQuad.ts +167 -0
- package/src/stitching/incremental.ts +74 -0
- package/src/stitching/useIncrementalStitcher.ts +13 -0
- package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
- package/cpp/tests/CMakeLists.txt +0 -104
- package/cpp/tests/README.md +0 -86
- package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
- package/cpp/tests/pose_test.cpp +0 -74
- package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
- package/cpp/tests/stubs/jsi/jsi.h +0 -33
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
- package/cpp/tests/warp_guard_test.cpp +0 -48
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
- package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
- package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
- package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
- package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
- package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
- package/src/camera/__tests__/useContentRotation.test.ts +0 -89
- package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
- package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
package/cpp/stitcher.cpp
CHANGED
|
@@ -30,18 +30,24 @@
|
|
|
30
30
|
#include <opencv2/stitching/warpers.hpp>
|
|
31
31
|
|
|
32
32
|
#include <algorithm>
|
|
33
|
+
#include <atomic>
|
|
33
34
|
#include <chrono>
|
|
34
35
|
#include <cfloat>
|
|
35
36
|
#include <cmath>
|
|
36
37
|
#include <cstdarg>
|
|
37
38
|
#include <cstdio>
|
|
38
39
|
#include <cstring>
|
|
40
|
+
#include <functional>
|
|
39
41
|
#include <string>
|
|
42
|
+
#include <thread>
|
|
40
43
|
#include <unistd.h>
|
|
41
44
|
#include <vector>
|
|
42
45
|
|
|
43
46
|
#include "warp_guard.hpp"
|
|
44
47
|
|
|
48
|
+
// RNIS_MEMORY_PROFILING is defined in stitcher.hpp (shared across the 3 native
|
|
49
|
+
// translation units). kMemProfilingCompiled exposes it as a constexpr below.
|
|
50
|
+
|
|
45
51
|
|
|
46
52
|
namespace retailens {
|
|
47
53
|
|
|
@@ -69,12 +75,21 @@ void log_error(const LogFn& logFn, const char* tag, const char* fmt, ...) {
|
|
|
69
75
|
logFn(2, tag, buf);
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
// guard
|
|
77
|
-
|
|
78
|
+
// Compile-time gate as a constexpr (so dead branches fold away in release).
|
|
79
|
+
constexpr bool kMemProfilingCompiled = (RNIS_MEMORY_PROFILING != 0);
|
|
80
|
+
|
|
81
|
+
// Per-stitch resident-memory probe, installed for the duration of a stitch by
|
|
82
|
+
// MemProbeScope below. Lets rss_mb() (and therefore every OOM guard, phase log,
|
|
83
|
+
// the peak sampler and the per-stitch record) read the platform's real source
|
|
84
|
+
// without threading `config` through dozens of call sites. Calls are serialized
|
|
85
|
+
// (stitchFramePaths contract), and the sampler thread only reads it within the
|
|
86
|
+
// scope's lifetime, so a plain file-static is safe.
|
|
87
|
+
std::function<double()> g_memProbe;
|
|
88
|
+
|
|
89
|
+
// Read /proc/self/statm to get RSS in MB. Cheap (~20 µs). Returns -1 when
|
|
90
|
+
// procfs is absent — which is the case on iOS (no /proc), so this is the Android
|
|
91
|
+
// path; iOS supplies g_memProbe instead.
|
|
92
|
+
double rss_mb_proc() {
|
|
78
93
|
FILE* f = std::fopen("/proc/self/statm", "r");
|
|
79
94
|
if (f == nullptr) return -1.0;
|
|
80
95
|
long size_pages = 0, resident_pages = 0;
|
|
@@ -85,6 +100,97 @@ double rss_mb() {
|
|
|
85
100
|
return (double) resident_pages * (double) page_bytes / (1024.0 * 1024.0);
|
|
86
101
|
}
|
|
87
102
|
|
|
103
|
+
// Effective resident-memory reader (MB). Prefers the installed probe (iOS
|
|
104
|
+
// phys_footprint), else /proc (Android). -1 when neither is available. Used at
|
|
105
|
+
// pipeline phase boundaries + by the OOM guards — making the guards work on iOS,
|
|
106
|
+
// where they previously got -1 (the runtime-pressure router was dead).
|
|
107
|
+
double rss_mb() {
|
|
108
|
+
if (g_memProbe) {
|
|
109
|
+
const double v = g_memProbe();
|
|
110
|
+
if (v >= 0.0) return v;
|
|
111
|
+
}
|
|
112
|
+
return rss_mb_proc();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Which source rss_mb() is currently resolving to — for the per-stitch record.
|
|
116
|
+
const char* mem_source_label() {
|
|
117
|
+
if (g_memProbe && g_memProbe() >= 0.0) return "phys_footprint";
|
|
118
|
+
if (rss_mb_proc() >= 0.0) return "rss";
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// RAII: install/uninstall the resident-memory probe for one stitch.
|
|
123
|
+
struct MemProbeScope {
|
|
124
|
+
explicit MemProbeScope(std::function<double()> probe) { g_memProbe = std::move(probe); }
|
|
125
|
+
~MemProbeScope() { g_memProbe = nullptr; }
|
|
126
|
+
MemProbeScope(const MemProbeScope&) = delete;
|
|
127
|
+
MemProbeScope& operator=(const MemProbeScope&) = delete;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// DRY [memstat] phase log — gated, and (unlike the old inline form) skips the
|
|
131
|
+
// rss_mb() read entirely when profiling is off, so release pays nothing.
|
|
132
|
+
void log_memstat(const LogFn& logFn, bool enabled, const char* phase) {
|
|
133
|
+
if (!enabled || !logFn) return;
|
|
134
|
+
char buf[80];
|
|
135
|
+
std::snprintf(buf, sizeof(buf), "phase=%s rss=%.1f MB", phase, rss_mb());
|
|
136
|
+
logFn(0, "[memstat]", buf);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Background peak-memory sampler. Wakes every ~50 ms during a stitch and tracks
|
|
140
|
+
// the max resident memory into `peak` — the ONLY way to catch the transient
|
|
141
|
+
// warp-all + GraphCut + MultiBand spike, which the phase-boundary reads (taken
|
|
142
|
+
// after the blender frees its pyramids) systematically miss. RAII: the thread
|
|
143
|
+
// starts in the ctor (when active) and is joined in stop()/dtor. It is a pure
|
|
144
|
+
// memory reader, not OpenCV work, so cv::setNumThreads(1) does not constrain it.
|
|
145
|
+
class PeakSampler {
|
|
146
|
+
public:
|
|
147
|
+
PeakSampler(bool active, std::atomic<double>& peak) : peak_(peak), active_(active) {
|
|
148
|
+
if (!active_) return;
|
|
149
|
+
thread_ = std::thread([this]() {
|
|
150
|
+
while (!stop_.load(std::memory_order_relaxed)) {
|
|
151
|
+
const double v = rss_mb();
|
|
152
|
+
double cur = peak_.load(std::memory_order_relaxed);
|
|
153
|
+
while (v > cur &&
|
|
154
|
+
!peak_.compare_exchange_weak(cur, v, std::memory_order_relaxed)) {}
|
|
155
|
+
// Sleep in 10 ms slices so stop() is responsive (~50 ms cadence).
|
|
156
|
+
for (int i = 0; i < 5 && !stop_.load(std::memory_order_relaxed); ++i)
|
|
157
|
+
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
void stop() {
|
|
162
|
+
if (!active_) return;
|
|
163
|
+
stop_.store(true, std::memory_order_relaxed);
|
|
164
|
+
if (thread_.joinable()) thread_.join();
|
|
165
|
+
active_ = false;
|
|
166
|
+
}
|
|
167
|
+
~PeakSampler() { stop(); }
|
|
168
|
+
PeakSampler(const PeakSampler&) = delete;
|
|
169
|
+
PeakSampler& operator=(const PeakSampler&) = delete;
|
|
170
|
+
private:
|
|
171
|
+
std::atomic<double>& peak_;
|
|
172
|
+
std::atomic<bool> stop_{false};
|
|
173
|
+
std::thread thread_;
|
|
174
|
+
bool active_;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Total physical RAM in MB, read natively. The Android JNI bridge sets no
|
|
178
|
+
// availableRamMB, so without this a 6 GB device is mis-treated as the 4 GB
|
|
179
|
+
// fallback (and the step-7.7 canvas budget + pre-stitch abort under-size).
|
|
180
|
+
// _SC_PHYS_PAGES is TOTAL and stable across runs (unlike _SC_AVPHYS_PAGES,
|
|
181
|
+
// which is free RAM and varies). Returns -1.0 off Linux/Android (e.g. the
|
|
182
|
+
// macOS cpp-test host); the caller resolves the sentinel.
|
|
183
|
+
double device_total_ram_mb() {
|
|
184
|
+
#if defined(__linux__)
|
|
185
|
+
const long pages = sysconf(_SC_PHYS_PAGES);
|
|
186
|
+
const long page_bytes = sysconf(_SC_PAGESIZE);
|
|
187
|
+
if (pages <= 0 || page_bytes <= 0) return -1.0;
|
|
188
|
+
return (double) pages * (double) page_bytes / (1024.0 * 1024.0);
|
|
189
|
+
#else
|
|
190
|
+
return -1.0;
|
|
191
|
+
#endif
|
|
192
|
+
}
|
|
193
|
+
|
|
88
194
|
double mat_mb(const cv::Mat& m) {
|
|
89
195
|
if (m.empty()) return 0.0;
|
|
90
196
|
return (double)(m.total() * m.elemSize()) / (1024.0 * 1024.0);
|
|
@@ -229,6 +335,100 @@ cv::Rect maxInscribedRectFromMask(const cv::Mat& mask) {
|
|
|
229
335
|
return bestRect;
|
|
230
336
|
}
|
|
231
337
|
|
|
338
|
+
// Issue 3 — post-stitch output validator. The confidence filter drops
|
|
339
|
+
// frames that don't REGISTER, but nothing validated the final OUTPUT: a
|
|
340
|
+
// frame that survived confidence yet landed geometrically disconnected
|
|
341
|
+
// shows up as a separate blob in the coverage mask (the "disjointed image
|
|
342
|
+
// frames in the output" users reported). Run connected-components on the
|
|
343
|
+
// coverage mask; if a meaningful fraction of the covered area lies OUTSIDE
|
|
344
|
+
// the largest blob, reject the stitch as LowQualityStitch so the host can
|
|
345
|
+
// prompt a retry rather than ship a broken panorama.
|
|
346
|
+
//
|
|
347
|
+
// Conservative by design: a coherent panorama is ONE connected blob, so a
|
|
348
|
+
// good capture never trips; the threshold lives in the pure, unit-tested
|
|
349
|
+
// retailens::stitchOutputIsDisjoint. A small morphological close first
|
|
350
|
+
// bridges sub-pixel seam gaps so a single panorama isn't mis-split, while
|
|
351
|
+
// being far too small to merge a genuinely-detached floating frame.
|
|
352
|
+
//
|
|
353
|
+
// Fails OPEN: an empty/unreadable mask returns Ok (never block a capture on
|
|
354
|
+
// a mask we couldn't analyse).
|
|
355
|
+
StitchErrorCode validateStitchOutput(const cv::Mat& panorama,
|
|
356
|
+
const cv::Mat& coverage,
|
|
357
|
+
int numFrames,
|
|
358
|
+
const LogFn& logFn,
|
|
359
|
+
std::string& outMessage) {
|
|
360
|
+
if (panorama.empty()) return StitchErrorCode::Ok; // handled elsewhere
|
|
361
|
+
// Build a binary coverage mask (same posture as choose_crop_rect).
|
|
362
|
+
cv::Mat mask;
|
|
363
|
+
const bool haveCoverage =
|
|
364
|
+
(!coverage.empty() && coverage.size() == panorama.size());
|
|
365
|
+
if (haveCoverage) {
|
|
366
|
+
cv::Mat cov1 = coverage;
|
|
367
|
+
if (coverage.channels() != 1) {
|
|
368
|
+
cv::cvtColor(coverage, cov1, cv::COLOR_BGR2GRAY);
|
|
369
|
+
}
|
|
370
|
+
cv::threshold(cov1, mask, 0, 255, cv::THRESH_BINARY);
|
|
371
|
+
} else {
|
|
372
|
+
cv::Mat gray;
|
|
373
|
+
cv::cvtColor(panorama, gray, cv::COLOR_BGR2GRAY);
|
|
374
|
+
cv::threshold(gray, mask, 0, 255, cv::THRESH_BINARY);
|
|
375
|
+
}
|
|
376
|
+
if (mask.empty() || mask.type() != CV_8UC1) return StitchErrorCode::Ok;
|
|
377
|
+
// Bridge thin seam gaps so a coherent pano isn't mis-split; the 5 px
|
|
378
|
+
// kernel is far smaller than the gap a detached frame leaves, so
|
|
379
|
+
// genuinely-separate blobs are NOT merged.
|
|
380
|
+
cv::Mat closed;
|
|
381
|
+
cv::morphologyEx(
|
|
382
|
+
mask, closed, cv::MORPH_CLOSE,
|
|
383
|
+
cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(5, 5)));
|
|
384
|
+
cv::Mat labels, stats, centroids;
|
|
385
|
+
const int n = cv::connectedComponentsWithStats(
|
|
386
|
+
closed, labels, stats, centroids, 8, CV_32S);
|
|
387
|
+
double totalArea = 0.0, largestArea = 0.0;
|
|
388
|
+
for (int i = 1; i < n; i++) { // skip background label 0
|
|
389
|
+
const double a = stats.at<int>(i, cv::CC_STAT_AREA);
|
|
390
|
+
totalArea += a;
|
|
391
|
+
if (a > largestArea) largestArea = a;
|
|
392
|
+
}
|
|
393
|
+
const double fragmentFraction =
|
|
394
|
+
(totalArea > 0.0) ? (1.0 - largestArea / totalArea) : 0.0;
|
|
395
|
+
log_info(logFn, "[stitch-bc]",
|
|
396
|
+
"step11d: validate output components=%d largest=%.0f total=%.0f "
|
|
397
|
+
"fragment=%.3f frames=%d",
|
|
398
|
+
n - 1, largestArea, totalArea, fragmentFraction, numFrames);
|
|
399
|
+
if (retailens::stitchOutputIsDisjoint(largestArea, totalArea, numFrames)) {
|
|
400
|
+
char buf[176];
|
|
401
|
+
std::snprintf(buf, sizeof(buf),
|
|
402
|
+
"stitch validation failed: disjoint output (%d "
|
|
403
|
+
"components, %.0f%% of coverage outside the main frame)",
|
|
404
|
+
n - 1, fragmentFraction * 100.0);
|
|
405
|
+
outMessage = buf;
|
|
406
|
+
return StitchErrorCode::LowQualityStitch;
|
|
407
|
+
}
|
|
408
|
+
// Utilization guard — the "black canvas". A coherent blob marooned in a
|
|
409
|
+
// mostly-empty canvas passes the disjoint check above (one blob), so guard
|
|
410
|
+
// the coverage-to-canvas ratio too (mask is the full panorama size).
|
|
411
|
+
{
|
|
412
|
+
const double canvasArea = (double)mask.cols * (double)mask.rows;
|
|
413
|
+
if (retailens::stitchOutputUnderutilized(totalArea, canvasArea,
|
|
414
|
+
numFrames)) {
|
|
415
|
+
const double util = canvasArea > 0.0 ? totalArea / canvasArea : 0.0;
|
|
416
|
+
log_info(logFn, "[stitch-bc]",
|
|
417
|
+
"step11d: REJECT degenerate canvas — utilization=%.3f%% "
|
|
418
|
+
"(content %.0f / canvas %dx%d)",
|
|
419
|
+
util * 100.0, totalArea, mask.cols, mask.rows);
|
|
420
|
+
char buf[200];
|
|
421
|
+
std::snprintf(buf, sizeof(buf),
|
|
422
|
+
"stitch validation failed: degenerate canvas "
|
|
423
|
+
"(content fills %.2f%% of the %dx%d panorama)",
|
|
424
|
+
util * 100.0, mask.cols, mask.rows);
|
|
425
|
+
outMessage = buf;
|
|
426
|
+
return StitchErrorCode::LowQualityStitch;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return StitchErrorCode::Ok;
|
|
430
|
+
}
|
|
431
|
+
|
|
232
432
|
// Pick the crop rectangle. Prefers the TRUE coverage mask from
|
|
233
433
|
// cv::Stitcher::resultMask() (0xFF where a frame painted, 0 where
|
|
234
434
|
// unfilled) so dark content is kept and only the never-covered wedges
|
|
@@ -297,77 +497,224 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
297
497
|
LogFn logFn);
|
|
298
498
|
|
|
299
499
|
|
|
500
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
501
|
+
// Degenerate-warp guard helpers (shared by every throw site in the manual
|
|
502
|
+
// pipeline's warp/compose stage). Centralising them keeps the error
|
|
503
|
+
// MESSAGE consistent across the four sites — the JS host classifies a
|
|
504
|
+
// stitch failure by substring (see src/camera/classifyStitchError.ts /
|
|
505
|
+
// cameraErrorMessages.ts → STITCH_CAMERA_PARAMS_FAIL "Please pan more
|
|
506
|
+
// slowly"), so every degenerate-warp throw MUST carry "degenerate camera
|
|
507
|
+
// params" + the stitchMode. Both predicates live in cpp/warp_guard.hpp
|
|
508
|
+
// (OpenCV-free + unit-tested); these builders add only the message + the
|
|
509
|
+
// cv::Exception envelope.
|
|
510
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
// Per-frame divergence: ONE warped frame's ROI exceeds kMaxWarpPixels
|
|
513
|
+
// (broken estimator/BA on degenerate input — low feature count, near-
|
|
514
|
+
// duplicate frames, motion-blurred rapid pan). stitchMode tells you which
|
|
515
|
+
// pipeline diverged: PANORAMA usually fails on translation-heavy input
|
|
516
|
+
// (homography + BA-Ray assume pure rotation); SCANS on low-texture / low-
|
|
517
|
+
// overlap input (affine needs enough matches).
|
|
518
|
+
static cv::Exception degenerateFrameException(
|
|
519
|
+
int width, int height, StitchMode mode, size_t frameIdx) {
|
|
520
|
+
const char* modeStr =
|
|
521
|
+
(mode == StitchMode::Scans) ? "scans" : "panorama";
|
|
522
|
+
return cv::Exception(
|
|
523
|
+
cv::Error::StsOutOfRange,
|
|
524
|
+
std::string("warpRoi too large (") + std::to_string(width) + "x"
|
|
525
|
+
+ std::to_string(height)
|
|
526
|
+
+ ") — estimator produced degenerate camera params on this frame "
|
|
527
|
+
+ "(stitchMode=" + modeStr + ", frameIdx="
|
|
528
|
+
+ std::to_string(frameIdx) + ")",
|
|
529
|
+
"stitchFramePathsManual", __FILE__, __LINE__);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Cumulative-canvas divergence: every per-frame ROI passed, but the UNION
|
|
533
|
+
// bounding box that blender->prepare() allocates exceeds kMaxCanvasPixels
|
|
534
|
+
// (a degenerate corner OFFSET blows the union to gigapixels while each
|
|
535
|
+
// frame's own extent stays small). This is the real crash-B net.
|
|
536
|
+
static cv::Exception degenerateCanvasException(
|
|
537
|
+
int64_t width, int64_t height, StitchMode mode, size_t frames) {
|
|
538
|
+
const char* modeStr =
|
|
539
|
+
(mode == StitchMode::Scans) ? "scans" : "panorama";
|
|
540
|
+
return cv::Exception(
|
|
541
|
+
cv::Error::StsOutOfRange,
|
|
542
|
+
std::string("panorama canvas too large (") + std::to_string(width)
|
|
543
|
+
+ "x" + std::to_string(height)
|
|
544
|
+
+ ") — estimator produced degenerate camera params across the "
|
|
545
|
+
+ "frame set (stitchMode=" + modeStr + ", frames="
|
|
546
|
+
+ std::to_string(frames) + ")",
|
|
547
|
+
"stitchFramePathsManual", __FILE__, __LINE__);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Bounding box over every positioned warp rect (corner + size) — exactly
|
|
551
|
+
// what cv::detail::Blender::prepare() allocates as its CV_16SC3 canvas.
|
|
552
|
+
// Computed in int64 so a degenerate corner offset (which can exceed the
|
|
553
|
+
// int32 range on its own) doesn't overflow before canvasExceedsGuard()
|
|
554
|
+
// gets to inspect it. Yields 0×0 for an empty frame set.
|
|
555
|
+
static void blendCanvasUnion(const std::vector<cv::Point>& corners,
|
|
556
|
+
const std::vector<cv::Size>& sizes,
|
|
557
|
+
int64_t& unionW, int64_t& unionH) {
|
|
558
|
+
if (corners.empty()) { unionW = 0; unionH = 0; return; }
|
|
559
|
+
// Seed from frame 0 (avoids any sentinel / <climits> dependency).
|
|
560
|
+
int64_t minX = corners[0].x;
|
|
561
|
+
int64_t minY = corners[0].y;
|
|
562
|
+
int64_t maxX = static_cast<int64_t>(corners[0].x) + sizes[0].width;
|
|
563
|
+
int64_t maxY = static_cast<int64_t>(corners[0].y) + sizes[0].height;
|
|
564
|
+
for (size_t i = 1; i < corners.size(); i++) {
|
|
565
|
+
const int64_t x0 = corners[i].x;
|
|
566
|
+
const int64_t y0 = corners[i].y;
|
|
567
|
+
const int64_t x1 = x0 + sizes[i].width;
|
|
568
|
+
const int64_t y1 = y0 + sizes[i].height;
|
|
569
|
+
if (x0 < minX) minX = x0;
|
|
570
|
+
if (y0 < minY) minY = y0;
|
|
571
|
+
if (x1 > maxX) maxX = x1;
|
|
572
|
+
if (y1 > maxY) maxY = y1;
|
|
573
|
+
}
|
|
574
|
+
unionW = maxX - minX;
|
|
575
|
+
unionH = maxY - minY;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
|
|
300
579
|
StitchResult stitchFramePaths(
|
|
301
580
|
const std::vector<std::string>& framePaths,
|
|
302
581
|
const std::string& outputPath,
|
|
303
582
|
const StitchConfig& config,
|
|
304
583
|
LogFn logFn)
|
|
305
584
|
{
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
585
|
+
// ── v0.16.1 — native-heap leak fix (one-time) — ANDROID ONLY ────────
|
|
586
|
+
// The ~7-9 MB/stitch LIVE native-heap creep is OpenCV core's OWN pooled
|
|
587
|
+
// scratch — TBB per-worker TLS — re-primed as the calling thread migrates
|
|
588
|
+
// across the Kotlin Dispatchers.Default pool. It is NOT app memory (every
|
|
589
|
+
// cv::Mat below is RAII / explicitly .release()'d) and NOT reclaimable by
|
|
590
|
+
// mallopt(M_PURGE) (those pools aren't serviced by bionic malloc).
|
|
591
|
+
// setNumThreads(1) removes the TBB worker pool so no per-worker scratch can
|
|
592
|
+
// accumulate. Stitches are serialized and the keyframes are small, so the
|
|
593
|
+
// single-threaded cost is minor. C++11 static-init is thread-safe; runs once.
|
|
313
594
|
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
595
|
+
// 2026-06-16 (audit) — ANDROID-GATED. The prebuilt iOS opencv2.xcframework
|
|
596
|
+
// uses the GCD parallel backend (NOT TBB — getBuildInformation reads
|
|
597
|
+
// "Parallel framework: GCD"), so there is no TBB pool to remove; pinning to
|
|
598
|
+
// 1 thread there is pure cost (serialized ORB/matcher/warp/blend + a
|
|
599
|
+
// single-core per-frame KeyframeGate) for ZERO memory benefit. Recovering
|
|
600
|
+
// iOS's multi-core path. setUseIPP(false) was dropped entirely — IPP is
|
|
601
|
+
// x86-only, a no-op on arm64 (both Android NDK and iOS).
|
|
602
|
+
#if defined(__ANDROID__)
|
|
603
|
+
static const bool s_cvTuned = []() {
|
|
604
|
+
cv::setNumThreads(1);
|
|
605
|
+
return true;
|
|
606
|
+
}();
|
|
607
|
+
(void)s_cvTuned;
|
|
608
|
+
#endif
|
|
609
|
+
|
|
610
|
+
// ── 2026-06-16 — per-stitch memory profiling (DEV; gated) ───────────
|
|
611
|
+
// Install the resident-memory probe (iOS phys_footprint; null on Android →
|
|
612
|
+
// /proc) for the whole stitch — including the retry + the high-level/manual
|
|
613
|
+
// impls below — so the OOM guards, phase logs, peak sampler + record all read
|
|
614
|
+
// the right source. The sampler runs across both attempts (peak = the worse
|
|
615
|
+
// of the two, which is the conservative OOM number). `finish()` stops the
|
|
616
|
+
// sampler + stamps the record onto whichever result we return.
|
|
617
|
+
const bool memProfiling = kMemProfilingCompiled && config.enableMemoryProfiling;
|
|
618
|
+
MemProbeScope memProbe(config.memProbeFn);
|
|
619
|
+
const std::string memSource =
|
|
620
|
+
memProfiling ? std::string(mem_source_label()) : std::string();
|
|
621
|
+
const double memBefore = memProfiling ? rss_mb() : -1.0;
|
|
622
|
+
std::atomic<double> peakMB{ memBefore };
|
|
623
|
+
PeakSampler sampler(memProfiling, peakMB);
|
|
624
|
+
auto finish = [&](StitchResult r) -> StitchResult {
|
|
625
|
+
sampler.stop();
|
|
626
|
+
if (memProfiling) {
|
|
627
|
+
const double memAfter = rss_mb();
|
|
628
|
+
r.memBeforeMB = memBefore;
|
|
629
|
+
r.memAfterMB = memAfter;
|
|
630
|
+
r.memPeakMB = std::max(peakMB.load(), std::max(memBefore, memAfter));
|
|
631
|
+
r.memSource = memSource;
|
|
632
|
+
// memFloorMB stays -1; the platform bridge fills it after its
|
|
633
|
+
// post-stitch reclaim (Android mallopt(M_PURGE) / iOS settle read).
|
|
634
|
+
char mbuf[96];
|
|
635
|
+
std::snprintf(mbuf, sizeof(mbuf),
|
|
636
|
+
"memBefore=%.1f;memPeak=%.1f;memAfter=%.1f;memSrc=%s",
|
|
637
|
+
r.memBeforeMB, r.memPeakMB, r.memAfterMB, memSource.c_str());
|
|
638
|
+
if (!r.debugSummary.empty()) r.debugSummary += ";";
|
|
639
|
+
r.debugSummary += mbuf;
|
|
640
|
+
}
|
|
641
|
+
return r;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
// 2026-06-16 — TWO-BRANCH fallback retry (only fires on a retryable failure;
|
|
645
|
+
// no happy-path cost; each attempt is a full independent run — memory does
|
|
646
|
+
// NOT stack, RAII frees between attempts):
|
|
647
|
+
//
|
|
648
|
+
// HIGH-LEVEL caller (Android default, useManualPipeline=false): retry once
|
|
649
|
+
// with the SPHERICAL warper — bounds the canvas on both axes (lower peak
|
|
650
|
+
// ~16% measured + rescues a marooned plane/cylindrical warp).
|
|
651
|
+
// MANUAL caller (iOS pre-Phase-2, useManualPipeline=true): the OLD
|
|
652
|
+
// PANORAMA↔SCANS mode-fallback is PRESERVED so iOS does not regress until
|
|
653
|
+
// its bridge is flipped to high-level (review #3/#4). The dispatcher
|
|
654
|
+
// guard routes SCANS→high-level affine; PANORAMA→manual (+ its own
|
|
655
|
+
// plane→spherical self-rescue).
|
|
656
|
+
//
|
|
657
|
+
// PreStitchMemoryAbort is excluded from worthRetrying (a headroom abort is
|
|
658
|
+
// independent of warper/mode — retrying won't help).
|
|
659
|
+
auto runOnce = [&](StitchMode modeOverride,
|
|
660
|
+
const std::string& warperOverride) -> StitchResult {
|
|
323
661
|
StitchConfig cfg = config;
|
|
324
662
|
cfg.stitchMode = modeOverride;
|
|
663
|
+
if (!warperOverride.empty()) cfg.warperType = warperOverride;
|
|
325
664
|
return stitchFramePathsImpl_(framePaths, outputPath, cfg, logFn);
|
|
326
665
|
};
|
|
327
|
-
StitchResult firstAttempt = runOnce(config.stitchMode);
|
|
666
|
+
StitchResult firstAttempt = runOnce(config.stitchMode, std::string());
|
|
667
|
+
firstAttempt.stitchModeUsed = config.stitchMode;
|
|
328
668
|
if (firstAttempt.errorCode == StitchErrorCode::Ok) {
|
|
329
|
-
firstAttempt
|
|
330
|
-
return firstAttempt;
|
|
669
|
+
return finish(firstAttempt);
|
|
331
670
|
}
|
|
332
|
-
|
|
333
|
-
// is something the opposite mode wouldn't fix (e.g. invalid
|
|
334
|
-
// argument count, file-read failure, OOM).
|
|
335
|
-
bool worthRetrying =
|
|
671
|
+
const bool worthRetrying =
|
|
336
672
|
firstAttempt.errorCode == StitchErrorCode::UnknownCvException
|
|
337
673
|
|| firstAttempt.errorCode == StitchErrorCode::HomographyEstimationFailed
|
|
338
674
|
|| firstAttempt.errorCode == StitchErrorCode::CameraParamsAdjustFailed
|
|
339
675
|
|| firstAttempt.errorCode == StitchErrorCode::WarpFailed
|
|
340
|
-
|| firstAttempt.errorCode == StitchErrorCode::EmptyPanorama
|
|
676
|
+
|| firstAttempt.errorCode == StitchErrorCode::EmptyPanorama
|
|
677
|
+
|| firstAttempt.errorCode == StitchErrorCode::LowQualityStitch;
|
|
341
678
|
if (!worthRetrying) {
|
|
342
|
-
firstAttempt
|
|
343
|
-
return firstAttempt;
|
|
679
|
+
return finish(firstAttempt);
|
|
344
680
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
681
|
+
// HIGH-LEVEL: spherical warper rescue.
|
|
682
|
+
if (!config.useManualPipeline
|
|
683
|
+
&& config.stitchMode == StitchMode::Panorama
|
|
684
|
+
&& config.warperType != "spherical") {
|
|
685
|
+
log_info(logFn, "[stitch-fallback]",
|
|
686
|
+
"high-level warper (%s) failed code=%d (%s) — retrying spherical",
|
|
687
|
+
config.warperType.c_str(),
|
|
688
|
+
static_cast<int>(firstAttempt.errorCode),
|
|
689
|
+
firstAttempt.errorMessage.c_str());
|
|
690
|
+
StitchResult sph = runOnce(config.stitchMode, "spherical");
|
|
691
|
+
sph.stitchModeUsed = config.stitchMode;
|
|
692
|
+
if (sph.errorCode == StitchErrorCode::Ok) {
|
|
693
|
+
log_info(logFn, "[stitch-fallback]", "spherical rescue succeeded");
|
|
694
|
+
return finish(sph);
|
|
695
|
+
}
|
|
696
|
+
log_info(logFn, "[stitch-fallback]",
|
|
697
|
+
"spherical rescue also failed code=%d — returning primary error",
|
|
698
|
+
static_cast<int>(sph.errorCode));
|
|
699
|
+
return finish(firstAttempt);
|
|
700
|
+
}
|
|
701
|
+
// MANUAL: preserved opposite-mode fallback (iOS no-regression).
|
|
702
|
+
if (config.useManualPipeline) {
|
|
703
|
+
const StitchMode fallbackMode =
|
|
704
|
+
(config.stitchMode == StitchMode::Panorama) ? StitchMode::Scans
|
|
705
|
+
: StitchMode::Panorama;
|
|
357
706
|
log_info(logFn, "[stitch-fallback]",
|
|
358
|
-
"
|
|
707
|
+
"manual primary mode (%s) failed code=%d — retrying %s",
|
|
708
|
+
config.stitchMode == StitchMode::Scans ? "scans" : "panorama",
|
|
709
|
+
static_cast<int>(firstAttempt.errorCode),
|
|
359
710
|
fallbackMode == StitchMode::Scans ? "scans" : "panorama");
|
|
360
|
-
|
|
711
|
+
StitchResult secondAttempt = runOnce(fallbackMode, std::string());
|
|
712
|
+
if (secondAttempt.errorCode == StitchErrorCode::Ok) {
|
|
713
|
+
secondAttempt.stitchModeUsed = fallbackMode;
|
|
714
|
+
return finish(secondAttempt);
|
|
715
|
+
}
|
|
361
716
|
}
|
|
362
|
-
|
|
363
|
-
// what the operator's chosen mode produced — more useful for
|
|
364
|
-
// diagnosis than the fallback's failure).
|
|
365
|
-
log_info(logFn, "[stitch-fallback]",
|
|
366
|
-
"fallback mode (%s) also failed with code=%d — returning primary error",
|
|
367
|
-
fallbackMode == StitchMode::Scans ? "scans" : "panorama",
|
|
368
|
-
static_cast<int>(secondAttempt.errorCode));
|
|
369
|
-
firstAttempt.stitchModeUsed = config.stitchMode;
|
|
370
|
-
return firstAttempt;
|
|
717
|
+
return finish(firstAttempt);
|
|
371
718
|
}
|
|
372
719
|
|
|
373
720
|
// 2026-05-22 (audit follow-up) — renamed inner entry point so the
|
|
@@ -384,14 +731,53 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
384
731
|
// useManualPipeline for the tradeoffs. Routing here keeps the
|
|
385
732
|
// call-site signature identical so existing bridges (iOS Obj-C++,
|
|
386
733
|
// Android JNI) don't need to know which path runs internally.
|
|
387
|
-
|
|
388
|
-
|
|
734
|
+
//
|
|
735
|
+
// 2026-06-16 — SCANS always uses the high-level pipeline: the manual path is
|
|
736
|
+
// homography-only (no affine matcher/estimator/warper), so SCANS+manual
|
|
737
|
+
// would silently run a homography stitch. The old mode-fallback enforced
|
|
738
|
+
// this in runOnce; now that runOnce is warper-only, enforce it here so a
|
|
739
|
+
// not-yet-migrated caller passing stitchMode=scans + useManualPipeline=true
|
|
740
|
+
// (e.g. iOS pre-Phase-2) still gets the correct affine SCANS path.
|
|
741
|
+
if (config.useManualPipeline && config.stitchMode != StitchMode::Scans) {
|
|
742
|
+
StitchResult r =
|
|
743
|
+
stitchFramePathsManual(framePaths, outputPath, config, logFn);
|
|
744
|
+
// 2026-06-15 — AUTO SPHERICAL FALLBACK. The manual pipeline defaults to
|
|
745
|
+
// the PLANE warper (flat, natural for narrow / 1x pans). Plane is
|
|
746
|
+
// unbounded, so a wide / off-axis pan can maroon content in a corner;
|
|
747
|
+
// validateStitchOutput rejects that as LowQualityStitch (the utilization
|
|
748
|
+
// / disjoint guard) BEFORE writing any file. Rather than fail, retry
|
|
749
|
+
// ONCE with the SPHERICAL warper, which bounds both axes — flat when
|
|
750
|
+
// plane works, bounded only when it doesn't. Skipped when the caller
|
|
751
|
+
// already asked for spherical, or the failure wasn't a quality rejection
|
|
752
|
+
// (OOM abort / read error won't be fixed by a different warper).
|
|
753
|
+
if (!r.success
|
|
754
|
+
&& r.errorCode == StitchErrorCode::LowQualityStitch
|
|
755
|
+
&& config.warperType != "spherical") {
|
|
756
|
+
log_info(logFn, "[stitch-bc]",
|
|
757
|
+
"manual '%s' marooned (LowQualityStitch) — retrying once "
|
|
758
|
+
"with spherical (bounded both axes)",
|
|
759
|
+
config.warperType.c_str());
|
|
760
|
+
StitchConfig sph = config;
|
|
761
|
+
sph.warperType = "spherical";
|
|
762
|
+
return stitchFramePathsManual(framePaths, outputPath, sph, logFn);
|
|
763
|
+
}
|
|
764
|
+
return r;
|
|
389
765
|
}
|
|
390
766
|
|
|
391
767
|
const auto t0 = std::chrono::steady_clock::now();
|
|
392
768
|
StitchResult result;
|
|
393
769
|
result.framesRequested = static_cast<int32_t>(framePaths.size());
|
|
394
770
|
|
|
771
|
+
// 2026-06-16 (review #1) — outer crash-catch ladder over the WHOLE high-level
|
|
772
|
+
// body. Now that high-level is the default pipeline, an OOM-class throw must
|
|
773
|
+
// NOT escape: cv::Stitcher PANORAMA internals (MultiBand pyramids, GraphCut,
|
|
774
|
+
// STL vectors) can throw std::bad_alloc (NOT a cv::Exception, so the narrow
|
|
775
|
+
// inner catch(cv::Exception&) misses it), and the post-stitch clone/crop/bake
|
|
776
|
+
// ops can throw cv::Exception(StsNoMem) OUTSIDE the inner catches — either
|
|
777
|
+
// would cross the JNI C-ABI → std::terminate/SIGABRT. Mirror the manual
|
|
778
|
+
// path's ladder so any throw becomes a clean StitchResult error (which also
|
|
779
|
+
// makes the spherical rescue eligible). The JNI adds a backstop too.
|
|
780
|
+
try {
|
|
395
781
|
if (framePaths.size() < 2) {
|
|
396
782
|
result.errorCode = StitchErrorCode::InvalidArgument;
|
|
397
783
|
result.errorMessage = "Need at least 2 frames to stitch (got " +
|
|
@@ -417,7 +803,9 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
417
803
|
config.captureOrientation.c_str(),
|
|
418
804
|
config.jpegQuality,
|
|
419
805
|
config.useInscribedRectCrop ? 1 : 0);
|
|
420
|
-
|
|
806
|
+
// Gate the [memstat] phase logs (compile flag + runtime settings.debug).
|
|
807
|
+
const bool memstat = kMemProfilingCompiled && config.enableMemoryProfiling;
|
|
808
|
+
log_memstat(logFn, memstat, "entry");
|
|
421
809
|
|
|
422
810
|
// ── 1. Load input frames ───────────────────────────────────────
|
|
423
811
|
std::vector<cv::Mat> images;
|
|
@@ -440,7 +828,7 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
440
828
|
}
|
|
441
829
|
log_info(logFn, "[dimstat]", "loaded %zu frames total_input_data=%.2f MB",
|
|
442
830
|
images.size(), totalInputMB);
|
|
443
|
-
|
|
831
|
+
log_memstat(logFn, memstat, "after_imread");
|
|
444
832
|
|
|
445
833
|
// ── 2. Configure cv::Stitcher ──────────────────────────────────
|
|
446
834
|
const cv::Stitcher::Mode cvMode = (config.stitchMode == StitchMode::Scans)
|
|
@@ -482,7 +870,17 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
482
870
|
if (config.seamEstimationResolMP > 0.0) {
|
|
483
871
|
stitcher->setSeamEstimationResol(config.seamEstimationResolMP);
|
|
484
872
|
}
|
|
485
|
-
|
|
873
|
+
// 2026-06-16 (high-level safety) — RAM-aware compositing resolution.
|
|
874
|
+
// cv::Stitcher composes the whole canvas at once (no STREAM mode), so the
|
|
875
|
+
// per-frame compose MP directly sizes the peak. 1.0 MP is fine on 6 GB+
|
|
876
|
+
// (measured peak ~0.7-0.9 GB on the A35); on lower-RAM devices clamp to
|
|
877
|
+
// 0.6 MP so the unguarded high-level path can't out-grow the per-process
|
|
878
|
+
// budget. An explicit caller override (compositingResolMP > 0) still wins.
|
|
879
|
+
double totalRamMB = (config.availableRamMB > 0.0)
|
|
880
|
+
? config.availableRamMB : device_total_ram_mb();
|
|
881
|
+
if (totalRamMB <= 0.0) totalRamMB = 4.0 * 1024.0; // conservative fallback
|
|
882
|
+
const double kHighLevelComposeFallbackMP =
|
|
883
|
+
(totalRamMB >= 5.0 * 1024.0) ? 1.0 : 0.6;
|
|
486
884
|
const double composeMP = (config.compositingResolMP > 0.0)
|
|
487
885
|
? config.compositingResolMP : kHighLevelComposeFallbackMP;
|
|
488
886
|
stitcher->setCompositingResol(composeMP);
|
|
@@ -503,7 +901,28 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
503
901
|
// with progressively lower thresholds [1.0 → 0.5 → 0.3] until
|
|
504
902
|
// every frame is retained or we hit the floor. SCANS skips the
|
|
505
903
|
// higher thresholds (its default is already 0.3).
|
|
506
|
-
|
|
904
|
+
// 2026-06-16 (high-level safety) — pre-stitch headroom abort. The
|
|
905
|
+
// high-level path has no STREAM fallback or canvas downscale, so if the
|
|
906
|
+
// process is ALREADY so close to its per-process kill ceiling that even a
|
|
907
|
+
// minimal stitch won't fit on top, abort cleanly (surfaced via onError)
|
|
908
|
+
// rather than letting cv::Stitcher march into an lmkd/jetsam kill. Reuses
|
|
909
|
+
// the manual path's headroom guard; rss_mb() is memProbeFn-backed so this
|
|
910
|
+
// works on iOS too (where /proc is absent). Skipped when rss is unknown.
|
|
911
|
+
{
|
|
912
|
+
const double startRssMB = rss_mb();
|
|
913
|
+
if (startRssMB >= 0.0
|
|
914
|
+
&& retailens::stitchExceedsMinimalHeadroom(startRssMB, totalRamMB)) {
|
|
915
|
+
result.errorCode = StitchErrorCode::PreStitchMemoryAbort;
|
|
916
|
+
result.errorMessage =
|
|
917
|
+
"Pre-stitch abort: insufficient memory headroom for high-level "
|
|
918
|
+
"stitch (rss=" + std::to_string(static_cast<int>(startRssMB)) +
|
|
919
|
+
"MB, budget=" + std::to_string(static_cast<int>(
|
|
920
|
+
retailens::perProcessMemoryBudgetMB(totalRamMB))) + "MB)";
|
|
921
|
+
log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
|
|
922
|
+
return result;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
log_memstat(logFn, memstat, "before_stitch");
|
|
507
926
|
const double kRetryThresholds[] = {1.0, 0.5, 0.3};
|
|
508
927
|
const int kNumAttempts = sizeof(kRetryThresholds) / sizeof(double);
|
|
509
928
|
cv::Mat panorama;
|
|
@@ -521,10 +940,14 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
521
940
|
"attempt %d/%d panoConfidenceThresh=%.2f",
|
|
522
941
|
finalAttempt, kNumAttempts, thresh);
|
|
523
942
|
try {
|
|
524
|
-
|
|
943
|
+
// 2026-06-16 (review #2) — TWO-PHASE: estimateTransform (registration
|
|
944
|
+
// + BA + leaveBiggestComponent at this threshold) here; composePanorama
|
|
945
|
+
// runs ONCE after the canvas-union guard below. This is the ONLY way
|
|
946
|
+
// to inspect the estimated canvas BEFORE the warp/blend allocates it.
|
|
947
|
+
status = stitcher->estimateTransform(images);
|
|
525
948
|
} catch (const cv::Exception& e) {
|
|
526
949
|
result.errorCode = StitchErrorCode::UnknownCvException;
|
|
527
|
-
result.errorMessage = std::string("Stitcher::
|
|
950
|
+
result.errorMessage = std::string("Stitcher::estimateTransform threw on attempt ") +
|
|
528
951
|
std::to_string(finalAttempt) + ": " + e.what();
|
|
529
952
|
log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
|
|
530
953
|
return result;
|
|
@@ -555,19 +978,156 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
555
978
|
}
|
|
556
979
|
if (status != cv::Stitcher::OK) {
|
|
557
980
|
result.errorCode = statusToErrorCode(status);
|
|
558
|
-
result.errorMessage = "Stitcher::
|
|
981
|
+
result.errorMessage = "Stitcher::estimateTransform failed at all " +
|
|
559
982
|
std::to_string(finalAttempt) + " thresholds, last status code " +
|
|
560
983
|
std::to_string(static_cast<int>(status));
|
|
561
984
|
log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
|
|
562
985
|
return result;
|
|
563
986
|
}
|
|
987
|
+
|
|
988
|
+
// 2026-06-16 (review #2) — degenerate-canvas guard BEFORE composePanorama.
|
|
989
|
+
// estimateTransform succeeded; composePanorama will now warp+blend a canvas
|
|
990
|
+
// whose size is set by the estimated focals/rotations. A divergent BA
|
|
991
|
+
// produces absurd focals → a gigapixel canvas → an lmkd/jetsam kill MID
|
|
992
|
+
// allocation that NO try/catch can intercept. Project the warp-canvas union
|
|
993
|
+
// from cameras() + the configured warper at the registration/WORK scale
|
|
994
|
+
// (conservative: a valid pano is well under 1 MP here, so a union over
|
|
995
|
+
// kMaxCanvasPixels (50 MP) is unambiguously degenerate) and abort cleanly
|
|
996
|
+
// before the allocation. Valid wide pans are bounded separately by the
|
|
997
|
+
// RAM-aware compositingResol above, so this only fires on a divergent
|
|
998
|
+
// estimate — and the abort routes to the outer wrapper's spherical rescue.
|
|
999
|
+
try {
|
|
1000
|
+
const std::vector<cv::detail::CameraParams> cams = stitcher->cameras();
|
|
1001
|
+
if (!cams.empty()) {
|
|
1002
|
+
std::vector<double> focals;
|
|
1003
|
+
focals.reserve(cams.size());
|
|
1004
|
+
for (const auto& c : cams) focals.push_back(c.focal);
|
|
1005
|
+
std::sort(focals.begin(), focals.end());
|
|
1006
|
+
const double warpScale = focals[focals.size() / 2]; // median focal
|
|
1007
|
+
cv::Ptr<cv::WarperCreator> wc = make_warper(config.warperType);
|
|
1008
|
+
if (wc) {
|
|
1009
|
+
cv::Ptr<cv::detail::RotationWarper> w =
|
|
1010
|
+
wc->create(static_cast<float>(warpScale));
|
|
1011
|
+
const double workScale = stitcher->workScale();
|
|
1012
|
+
int64_t minX = 0, minY = 0, maxX = 0, maxY = 0;
|
|
1013
|
+
bool seeded = false;
|
|
1014
|
+
for (size_t i = 0; i < cams.size() && i < images.size(); ++i) {
|
|
1015
|
+
cv::Mat K;
|
|
1016
|
+
cams[i].K().convertTo(K, CV_32F);
|
|
1017
|
+
const cv::Size workSz(
|
|
1018
|
+
std::max(1, (int)std::lround(images[i].cols * workScale)),
|
|
1019
|
+
std::max(1, (int)std::lround(images[i].rows * workScale)));
|
|
1020
|
+
const cv::Rect roi = w->warpRoi(workSz, K, cams[i].R);
|
|
1021
|
+
if (!seeded) {
|
|
1022
|
+
minX = roi.x; minY = roi.y;
|
|
1023
|
+
maxX = (int64_t)roi.x + roi.width;
|
|
1024
|
+
maxY = (int64_t)roi.y + roi.height;
|
|
1025
|
+
seeded = true;
|
|
1026
|
+
} else {
|
|
1027
|
+
minX = std::min<int64_t>(minX, roi.x);
|
|
1028
|
+
minY = std::min<int64_t>(minY, roi.y);
|
|
1029
|
+
maxX = std::max<int64_t>(maxX, (int64_t)roi.x + roi.width);
|
|
1030
|
+
maxY = std::max<int64_t>(maxY, (int64_t)roi.y + roi.height);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (seeded && canvasExceedsGuard(maxX - minX, maxY - minY)) {
|
|
1034
|
+
result.errorCode = StitchErrorCode::WarpFailed;
|
|
1035
|
+
result.errorMessage =
|
|
1036
|
+
"Degenerate high-level estimate: warp-canvas union " +
|
|
1037
|
+
std::to_string(maxX - minX) + "x" +
|
|
1038
|
+
std::to_string(maxY - minY) +
|
|
1039
|
+
" px (work scale) exceeds guard — aborting before "
|
|
1040
|
+
"composePanorama to avoid an OOM kill";
|
|
1041
|
+
log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
|
|
1042
|
+
return result; // → outer wrapper's spherical rescue (bounded)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// 2026-06-16 (review #2 + on-device data) — VALID-but-large canvas
|
|
1046
|
+
// budget. A wide PLANE pan peaked ~2520 MB on the 6 GB A35 (above
|
|
1047
|
+
// its red line; OOM on 4 GB) — not degenerate (passed the guard
|
|
1048
|
+
// above), just unbounded-plane-large. Project the COMPOSE-scale
|
|
1049
|
+
// canvas from the work-scale union (same geometry; resolution
|
|
1050
|
+
// ratio composeScale/workScale) and, if it exceeds the RAM canvas
|
|
1051
|
+
// budget, bound it BEFORE composePanorama: downscale
|
|
1052
|
+
// compositingResol when a modest (≤2×) shrink suffices, else route
|
|
1053
|
+
// to spherical (its geometry bounds the canvas at FULL resolution
|
|
1054
|
+
// — data: ~5× lower peak). This is the manual path's
|
|
1055
|
+
// composeCanvasBudgetMP downscale, ported to high-level.
|
|
1056
|
+
if (seeded && workScale > 0.0 && !images.empty()
|
|
1057
|
+
&& images[0].total() > 0) {
|
|
1058
|
+
const double fullArea =
|
|
1059
|
+
static_cast<double>(images[0].cols) * images[0].rows;
|
|
1060
|
+
const double composeScale =
|
|
1061
|
+
std::min(1.0, std::sqrt(composeMP * 1e6 / fullArea));
|
|
1062
|
+
const double ratioCS = composeScale / workScale;
|
|
1063
|
+
const double workCanvasMP =
|
|
1064
|
+
static_cast<double>(maxX - minX) * (maxY - minY) / 1e6;
|
|
1065
|
+
const double composeCanvasMP = workCanvasMP * ratioCS * ratioCS;
|
|
1066
|
+
const double canvasBudgetMP =
|
|
1067
|
+
retailens::composeCanvasBudgetMP(totalRamMB);
|
|
1068
|
+
if (composeCanvasMP > canvasBudgetMP) {
|
|
1069
|
+
const double over = composeCanvasMP / canvasBudgetMP;
|
|
1070
|
+
if (over <= 2.0 || config.warperType == "spherical") {
|
|
1071
|
+
const double targetMP = composeMP / over;
|
|
1072
|
+
stitcher->setCompositingResol(targetMP);
|
|
1073
|
+
log_info(logFn, "[stitch]",
|
|
1074
|
+
"canvas %.1f MP > budget %.1f MP (warp=%s) — "
|
|
1075
|
+
"downscaled compositingResol %.2f→%.2f MP",
|
|
1076
|
+
composeCanvasMP, canvasBudgetMP,
|
|
1077
|
+
config.warperType.c_str(), composeMP, targetMP);
|
|
1078
|
+
} else {
|
|
1079
|
+
// >2× over on plane/cylindrical → spherical bounds it
|
|
1080
|
+
// geometrically (full res) far better than a big shrink.
|
|
1081
|
+
result.errorCode = StitchErrorCode::WarpFailed;
|
|
1082
|
+
result.errorMessage =
|
|
1083
|
+
"high-level canvas " +
|
|
1084
|
+
std::to_string(static_cast<int>(composeCanvasMP)) +
|
|
1085
|
+
" MP >> budget " +
|
|
1086
|
+
std::to_string(static_cast<int>(canvasBudgetMP)) +
|
|
1087
|
+
" MP for warper '" + config.warperType +
|
|
1088
|
+
"' — routing to spherical (bounded geometry)";
|
|
1089
|
+
log_info(logFn, "[stitch]", "%s",
|
|
1090
|
+
result.errorMessage.c_str());
|
|
1091
|
+
return result; // → outer wrapper's spherical rescue
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
} catch (const cv::Exception& e) {
|
|
1098
|
+
// warpRoi can itself throw on a degenerate camera — treat as degenerate.
|
|
1099
|
+
result.errorCode = StitchErrorCode::WarpFailed;
|
|
1100
|
+
result.errorMessage =
|
|
1101
|
+
std::string("Degenerate high-level estimate (warpRoi threw): ") + e.what();
|
|
1102
|
+
log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
|
|
1103
|
+
return result;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Canvas bounded → compose.
|
|
1107
|
+
try {
|
|
1108
|
+
status = stitcher->composePanorama(panorama);
|
|
1109
|
+
} catch (const cv::Exception& e) {
|
|
1110
|
+
result.errorCode = StitchErrorCode::UnknownCvException;
|
|
1111
|
+
result.errorMessage =
|
|
1112
|
+
std::string("Stitcher::composePanorama threw: ") + e.what();
|
|
1113
|
+
log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
|
|
1114
|
+
return result;
|
|
1115
|
+
}
|
|
1116
|
+
if (status != cv::Stitcher::OK) {
|
|
1117
|
+
result.errorCode = statusToErrorCode(status);
|
|
1118
|
+
result.errorMessage = "Stitcher::composePanorama failed, status " +
|
|
1119
|
+
std::to_string(static_cast<int>(status));
|
|
1120
|
+
log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
|
|
1121
|
+
return result;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
564
1124
|
log_info(logFn, "[dimstat]",
|
|
565
1125
|
"post-stitch panorama %dx%d %dch data=%.2f MB"
|
|
566
1126
|
" (framesIncluded=%d/%zu, finalThresh=%.2f, attempts=%d)",
|
|
567
1127
|
panorama.cols, panorama.rows, panorama.channels(),
|
|
568
1128
|
mat_mb(panorama),
|
|
569
1129
|
framesIncluded, framePaths.size(), finalThreshold, finalAttempt);
|
|
570
|
-
|
|
1130
|
+
log_memstat(logFn, memstat, "after_stitch");
|
|
571
1131
|
|
|
572
1132
|
// ── 4. Crop (coverage-aware inscribed rect, or bbox) ───────────
|
|
573
1133
|
// Pull cv::Stitcher's coverage mask (0xFF filled / 0 unfilled). It is
|
|
@@ -580,6 +1140,26 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
580
1140
|
rm.copyTo(coverage); // download UMat → Mat
|
|
581
1141
|
}
|
|
582
1142
|
}
|
|
1143
|
+
|
|
1144
|
+
// 2026-06-16 (high-level safety) — validate the output BEFORE cropping/
|
|
1145
|
+
// writing. The manual path has had this since v0.16; the high-level path
|
|
1146
|
+
// shipped whatever cv::Stitcher produced. Rejects the black-canvas /
|
|
1147
|
+
// fragmented-output failures (utilization + disjoint guards) as
|
|
1148
|
+
// LowQualityStitch so the host can surface a "retry" instead of a broken
|
|
1149
|
+
// image. Fails open on an empty coverage mask.
|
|
1150
|
+
{
|
|
1151
|
+
std::string validateMessage;
|
|
1152
|
+
const StitchErrorCode validateCode = validateStitchOutput(
|
|
1153
|
+
panorama, coverage, framesIncluded, logFn, validateMessage);
|
|
1154
|
+
if (validateCode != StitchErrorCode::Ok) {
|
|
1155
|
+
result.errorCode = validateCode;
|
|
1156
|
+
result.errorMessage = validateMessage;
|
|
1157
|
+
log_error(logFn, "[stitch]", "high-level REJECTED — %s",
|
|
1158
|
+
validateMessage.c_str());
|
|
1159
|
+
return result;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
583
1163
|
cv::Mat cropMask;
|
|
584
1164
|
const cv::Rect cropRect = choose_crop_rect(
|
|
585
1165
|
panorama, coverage, config.useInscribedRectCrop, logFn, cropMask);
|
|
@@ -595,14 +1175,14 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
595
1175
|
mat_mb(cropped),
|
|
596
1176
|
config.useInscribedRectCrop ? 1 : 0,
|
|
597
1177
|
coverage.empty() ? 0 : 1);
|
|
598
|
-
|
|
1178
|
+
log_memstat(logFn, memstat, "after_crop");
|
|
599
1179
|
|
|
600
1180
|
// ── 5. Bake rotation per capture orientation ───────────────────
|
|
601
1181
|
cv::Mat final_image = bake_rotation(cropped, config.captureOrientation, logFn);
|
|
602
1182
|
log_info(logFn, "[dimstat]",
|
|
603
1183
|
"post-bake_rotation %dx%d data=%.2f MB",
|
|
604
1184
|
final_image.cols, final_image.rows, mat_mb(final_image));
|
|
605
|
-
|
|
1185
|
+
log_memstat(logFn, memstat, "after_bake_rotation");
|
|
606
1186
|
|
|
607
1187
|
// ── 6. Write JPEG ──────────────────────────────────────────────
|
|
608
1188
|
const int q = std::max(0, std::min(100, config.jpegQuality));
|
|
@@ -625,7 +1205,7 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
625
1205
|
log_info(logFn, "[stitch]",
|
|
626
1206
|
"output written: %s (%dx%d)",
|
|
627
1207
|
outputPath.c_str(), final_image.cols, final_image.rows);
|
|
628
|
-
|
|
1208
|
+
log_memstat(logFn, memstat, "after_imwrite");
|
|
629
1209
|
|
|
630
1210
|
// Best-effort coverage sidecar (<output>.coverage.png), bake-rotated
|
|
631
1211
|
// to align with the JPEG, for the debug harness. Never fails stitch.
|
|
@@ -649,7 +1229,36 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
649
1229
|
result.finalConfidenceThresh = finalThreshold;
|
|
650
1230
|
result.durationMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
651
1231
|
t1 - t0).count();
|
|
1232
|
+
// DEV overlay — cv::Stitcher owns its own compositing; for PANORAMA mode it
|
|
1233
|
+
// uses GraphCut seams + MultiBand blend by default, with the configured
|
|
1234
|
+
// warper. Surfaced on the preview in __DEV__. See StitchResult::debugSummary.
|
|
1235
|
+
result.debugSummary =
|
|
1236
|
+
std::string("pipe=highlevel;warp=") + config.warperType +
|
|
1237
|
+
";route=batch;seam=graphcut;blend=multiband";
|
|
652
1238
|
return result;
|
|
1239
|
+
} catch (const cv::Exception& e) {
|
|
1240
|
+
result.success = false;
|
|
1241
|
+
result.errorCode = StitchErrorCode::UnknownCvException;
|
|
1242
|
+
result.errorMessage =
|
|
1243
|
+
std::string("high-level cv::Exception (uncaught): ") + e.what();
|
|
1244
|
+
log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
|
|
1245
|
+
return result;
|
|
1246
|
+
} catch (const std::exception& e) {
|
|
1247
|
+
// std::bad_alloc from cv::Stitcher's STL internals lands here (it is NOT
|
|
1248
|
+
// a cv::Exception). Clean error instead of std::terminate.
|
|
1249
|
+
result.success = false;
|
|
1250
|
+
result.errorCode = StitchErrorCode::UnknownCvException;
|
|
1251
|
+
result.errorMessage =
|
|
1252
|
+
std::string("high-level std::exception (likely OOM): ") + e.what();
|
|
1253
|
+
log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
|
|
1254
|
+
return result;
|
|
1255
|
+
} catch (...) {
|
|
1256
|
+
result.success = false;
|
|
1257
|
+
result.errorCode = StitchErrorCode::UnknownCvException;
|
|
1258
|
+
result.errorMessage = "high-level unknown exception (uncaught)";
|
|
1259
|
+
log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
|
|
1260
|
+
return result;
|
|
1261
|
+
}
|
|
653
1262
|
}
|
|
654
1263
|
|
|
655
1264
|
|
|
@@ -780,23 +1389,49 @@ StitchResult stitchFramePathsManual(
|
|
|
780
1389
|
// MB threshold (real-device headroom) rather than 1200 MB (legacy
|
|
781
1390
|
// protection that caps a high-RAM device at low-RAM headroom).
|
|
782
1391
|
const double kAssumedTotalRAMGB = 4.0;
|
|
783
|
-
|
|
1392
|
+
// Single source of truth for device RAM, shared by the pre-stitch abort
|
|
1393
|
+
// AND the step-7.7 canvas budget below. Prefer the caller's value (iOS
|
|
1394
|
+
// plumbs NSProcessInfo.physicalMemory); else read it natively (Android
|
|
1395
|
+
// sets none); else fall back to the conservative 4 GB assumption.
|
|
1396
|
+
double totalRamMB = (config.availableRamMB > 0.0)
|
|
784
1397
|
? config.availableRamMB
|
|
785
|
-
: (
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1398
|
+
: device_total_ram_mb();
|
|
1399
|
+
if (totalRamMB <= 0.0) totalRamMB = kAssumedTotalRAMGB * 1024.0;
|
|
1400
|
+
|
|
1401
|
+
// Issue 6 — headroom-scoped pre-stitch gate (replaces the old flat
|
|
1402
|
+
// `max(700, ram×300)` RSS ceiling).
|
|
1403
|
+
//
|
|
1404
|
+
// The old check compared whole-process RSS against a flat fraction of
|
|
1405
|
+
// DEVICE RAM, so a memory-heavy HOST app could trip it even when the
|
|
1406
|
+
// stitch itself was tiny — and on trip it HARD-ABORTED with no attempt
|
|
1407
|
+
// to route to the lighter STREAM path. We can't isolate the stitch's
|
|
1408
|
+
// own allocation from the shared process RSS (OpenCV uses malloc; no
|
|
1409
|
+
// per-library accounting), so instead of a device ceiling we reason
|
|
1410
|
+
// about HEADROOM: estimate the per-process kill ceiling
|
|
1411
|
+
// (perProcessMemoryBudgetMB) and abort here ONLY when the process is
|
|
1412
|
+
// already so close to it that even a MINIMAL streaming stitch
|
|
1413
|
+
// (kMinStreamStitchMB) won't fit on top of the current footprint. That
|
|
1414
|
+
// makes this a genuine last resort scoped to the stitch's minimal
|
|
1415
|
+
// incremental demand — a heavy host with headroom remaining proceeds,
|
|
1416
|
+
// and everything in between is handled downstream by the step-8 STREAM
|
|
1417
|
+
// routing (now also headroom-aware — see lowBatchHeadroom there) and the
|
|
1418
|
+
// step-7.7 canvas-budget downscale, which size to what the stitch needs
|
|
1419
|
+
// rather than aborting.
|
|
1420
|
+
const double perProcessBudgetMB =
|
|
1421
|
+
retailens::perProcessMemoryBudgetMB(totalRamMB);
|
|
1422
|
+
if (retailens::stitchExceedsMinimalHeadroom(kStartResidentMB, totalRamMB)) {
|
|
789
1423
|
log_error(logFn, "[stitch-bc]",
|
|
790
|
-
"PRE-STITCH ABORT:
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
1424
|
+
"PRE-STITCH ABORT: rss=%.1fMB + minStitch=%.0fMB > "
|
|
1425
|
+
"perProcessBudget=%.1fMB (totalRamMB=%.0f) — no headroom for "
|
|
1426
|
+
"even a minimal streaming stitch",
|
|
1427
|
+
kStartResidentMB, retailens::kMinStreamStitchMB,
|
|
1428
|
+
perProcessBudgetMB, totalRamMB);
|
|
1429
|
+
// Sentinel return: success=false + stable code so both bridges see a
|
|
1430
|
+
// clean failure. Classified to STITCH_OOM in JS (classifyStitchError
|
|
1431
|
+
// matches "memory abort").
|
|
798
1432
|
result.errorCode = StitchErrorCode::PreStitchMemoryAbort;
|
|
799
|
-
result.errorMessage =
|
|
1433
|
+
result.errorMessage =
|
|
1434
|
+
"Pre-stitch memory abort: insufficient headroom for the stitch";
|
|
800
1435
|
// framesIncluded reflects best-known retained count at the
|
|
801
1436
|
// abort site — nothing has been loaded or matched yet.
|
|
802
1437
|
result.framesIncluded = 0;
|
|
@@ -846,10 +1481,15 @@ StitchResult stitchFramePathsManual(
|
|
|
846
1481
|
origCount, kMaxFramesForStitch);
|
|
847
1482
|
}
|
|
848
1483
|
|
|
849
|
-
// Load all input frames before invoking the stitcher. Memory cost
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
1484
|
+
// Load all input frames before invoking the stitcher. Memory cost is
|
|
1485
|
+
// N × decoded frame size. NOTE — keyframe resolution is PLATFORM-SPLIT
|
|
1486
|
+
// (verified 2026-06): on Android the keyframe JPEGs are pre-clamped to
|
|
1487
|
+
// ~640px long edge at encode time (YuvImageConverter; the
|
|
1488
|
+
// AR_KEYFRAME_MAX_LONG_EDGE guard fires on dimensions, so it covers the
|
|
1489
|
+
// NON-AR path too), so the resident footprint here is ~0.3 MP/frame
|
|
1490
|
+
// regardless of the chosen capture format. On iOS the keyframes are
|
|
1491
|
+
// written at NATIVE capture resolution (OpenCVKeyframeCollector, no
|
|
1492
|
+
// clamp), so the footprint scales with the selected video format.
|
|
853
1493
|
//
|
|
854
1494
|
// V12.13 — breadcrumb each load. If the landscape-only crash is
|
|
855
1495
|
// in cv::imread (e.g., decoding a JPEG produced by the new
|
|
@@ -1196,6 +1836,16 @@ StitchResult stitchFramePathsManual(
|
|
|
1196
1836
|
if (config.stitchMode == StitchMode::Scans && thresh > 0.31f) {
|
|
1197
1837
|
continue;
|
|
1198
1838
|
}
|
|
1839
|
+
// Panorama: skip the 0.3 floor. leaveBiggestComponent is
|
|
1840
|
+
// monotonic in the threshold, so 0.3 only ever FORCES IN a weak
|
|
1841
|
+
// boundary frame that survived neither 1.0 nor 0.5 — exactly the
|
|
1842
|
+
// frame BundleAdjusterRay can't refine, which then mis-places under
|
|
1843
|
+
// the unbounded plane warp and marooned the content in a corner
|
|
1844
|
+
// ("black canvas"). Drop it and let that frame be pruned. Scans
|
|
1845
|
+
// keeps 0.3 (it floors there by design — see the >0.31 skip above).
|
|
1846
|
+
if (config.stitchMode != StitchMode::Scans && thresh < 0.4f) {
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1199
1849
|
// Restore from backups before each attempt — leaveBiggest-
|
|
1200
1850
|
// Component mutated them last time. First attempt sees the
|
|
1201
1851
|
// originals (backup == current), subsequent attempts get a
|
|
@@ -1631,6 +2281,12 @@ StitchResult stitchFramePathsManual(
|
|
|
1631
2281
|
// blender / compose / crop) consumes the warper's OUTPUTS, so the
|
|
1632
2282
|
// swap is transparent. If even cylindrical diverges, the in-loop
|
|
1633
2283
|
// guard (step8b) still throws — the genuine-failure safety net.
|
|
2284
|
+
// The projection actually in use after the step7.6 fallback. The
|
|
2285
|
+
// fallback swaps `warper` but NOT warperCreator, so the step7.7 cap
|
|
2286
|
+
// below (which re-creates the warper at a smaller scale) must
|
|
2287
|
+
// re-create via THIS — otherwise it would silently revert
|
|
2288
|
+
// cylindrical→plane on exactly the wide pan the fallback rescued.
|
|
2289
|
+
std::string activeWarperType = config.warperType;
|
|
1634
2290
|
if (config.warperType != "cylindrical" && !composeFrames.empty()) {
|
|
1635
2291
|
bool wouldDiverge = false;
|
|
1636
2292
|
size_t divergeFrame = 0;
|
|
@@ -1646,14 +2302,186 @@ StitchResult stitchFramePathsManual(
|
|
|
1646
2302
|
break;
|
|
1647
2303
|
}
|
|
1648
2304
|
}
|
|
2305
|
+
// Issue 4 — quality-driven projection. PlaneWarper projects
|
|
2306
|
+
// ~tan(theta), so on a WIDE sweep the frames at the pan extremes
|
|
2307
|
+
// get visibly stretched/sheared — the "perspective at the ends"
|
|
2308
|
+
// users notice — even when the warp wouldn't OOM. Estimate the
|
|
2309
|
+
// total angular sweep from the bundle-adjusted camera optical
|
|
2310
|
+
// axes (first vs last frame); beyond kWidePanSweepDeg switch from
|
|
2311
|
+
// plane to the bounded cylindrical projection (~theta), which
|
|
2312
|
+
// keeps angular spacing uniform across the pan.
|
|
2313
|
+
//
|
|
2314
|
+
// The sweep angle (sweepDeg, below) is AXIS-AGNOSTIC — the 3D
|
|
2315
|
+
// angle between the first/last optical axes — so a Mode-A
|
|
2316
|
+
// landscape *vertical* pan trips this gate just as a horizontal
|
|
2317
|
+
// one does. cylindrical bounds only the HORIZONTAL angle; its
|
|
2318
|
+
// vertical axis is UNBOUNDED, so a vertical sweep's end frames
|
|
2319
|
+
// project to runaway coordinates and shear apart (fragmented
|
|
2320
|
+
// output — confirmed on-device 2026-06-14, a regression from
|
|
2321
|
+
// 6b11da0 vs the v0.6 plane baseline). Use SPHERICAL, which
|
|
2322
|
+
// bounds BOTH axes, so vertical AND horizontal wide pans stay
|
|
2323
|
+
// coherent. (Divergence guard + step-7.7 canvas budget cap are
|
|
2324
|
+
// unaffected — spherical is still a bounded projection.)
|
|
2325
|
+
constexpr double kWidePanSweepDeg = 45.0;
|
|
2326
|
+
const char* kWidePanWarper = "spherical";
|
|
2327
|
+
double sweepDeg = 0.0;
|
|
2328
|
+
if (cameras.size() >= 2) {
|
|
2329
|
+
auto opticalAxis = [](const cv::Mat& R) -> cv::Vec3d {
|
|
2330
|
+
cv::Mat Rd;
|
|
2331
|
+
R.convertTo(Rd, CV_64F);
|
|
2332
|
+
// Camera looks along +Z; world view dir = R·e_z = col 2.
|
|
2333
|
+
return cv::Vec3d(Rd.at<double>(0, 2),
|
|
2334
|
+
Rd.at<double>(1, 2),
|
|
2335
|
+
Rd.at<double>(2, 2));
|
|
2336
|
+
};
|
|
2337
|
+
const cv::Vec3d a0 = opticalAxis(cameras.front().R);
|
|
2338
|
+
const cv::Vec3d aN = opticalAxis(cameras.back().R);
|
|
2339
|
+
const double n0 = cv::norm(a0);
|
|
2340
|
+
const double nN = cv::norm(aN);
|
|
2341
|
+
if (n0 > 1e-9 && nN > 1e-9) {
|
|
2342
|
+
double c = a0.dot(aN) / (n0 * nN);
|
|
2343
|
+
c = std::max(-1.0, std::min(1.0, c));
|
|
2344
|
+
sweepDeg = std::acos(c) * 180.0 / CV_PI;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
const bool widePan = sweepDeg >= kWidePanSweepDeg;
|
|
2348
|
+
|
|
2349
|
+
// Only switch the warper when a frame's warp ACTUALLY blows past
|
|
2350
|
+
// the size guard (genuine divergence). The `widePan` sweep-angle
|
|
2351
|
+
// heuristic (>= kWidePanSweepDeg) was too aggressive — it fired on
|
|
2352
|
+
// a NORMAL moderate vertical Mode-A pan and switched away from the
|
|
2353
|
+
// PLANE warper that v0.6 used cleanly into the bounded-warper path,
|
|
2354
|
+
// which fragmented/doubled the output (confirmed on-device
|
|
2355
|
+
// 2026-06-14: stock cv::Stitcher AND v0.6 both stitch the exact
|
|
2356
|
+
// same frames cleanly on the default/plane warper; only the
|
|
2357
|
+
// post-v0.6 sweep-triggered switch broke it). Keep the divergence
|
|
2358
|
+
// guard (the real OOM/garbage protection) + spherical for that
|
|
2359
|
+
// case; `widePan` stays only for the diagnostic log below.
|
|
1649
2360
|
if (wouldDiverge) {
|
|
1650
2361
|
log_info(logFn, "[stitch-bc]",
|
|
1651
|
-
"step7.6: '%s'
|
|
1652
|
-
"
|
|
1653
|
-
config.warperType.c_str(),
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
2362
|
+
"step7.6: switching '%s' -> %s (diverge=%d wide=%d "
|
|
2363
|
+
"sweep=%.1fdeg, frame %zu) for a bounded projection",
|
|
2364
|
+
config.warperType.c_str(), kWidePanWarper,
|
|
2365
|
+
wouldDiverge ? 1 : 0, widePan ? 1 : 0, sweepDeg,
|
|
2366
|
+
divergeFrame);
|
|
2367
|
+
if (auto bounded = make_warper(kWidePanWarper)) {
|
|
2368
|
+
warper = bounded->create(warpedScale);
|
|
2369
|
+
activeWarperType = kWidePanWarper;
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// Post-cap projected canvas megapixels — drives the step-8 path
|
|
2375
|
+
// choice (wide canvases route to the low-memory STREAM+feather path).
|
|
2376
|
+
// Set inside step 7.7 below.
|
|
2377
|
+
double composeCanvasMpFinal = 0.0;
|
|
2378
|
+
// Post-cap BATCH held-set = Σ of every warped frame's area. This —
|
|
2379
|
+
// not the union — is the real driver of BATCH blend memory (N warped
|
|
2380
|
+
// frames + N exposure-comp UMat copies + MultiBand pyramids, all held
|
|
2381
|
+
// at once). A SMALL union with big/overlapping frames (e.g. the ~6×
|
|
2382
|
+
// higher-res AR keyframes) can still blow a huge held-set, so step 8's
|
|
2383
|
+
// STREAM route keys on this too, not just the union. Set in step 7.7.
|
|
2384
|
+
double composeHeldSetMpFinal = 0.0;
|
|
2385
|
+
// Step 7.7: RAM-aware output-canvas budget cap (wide-pan blend-OOM
|
|
2386
|
+
// fix). A VALID but wide pan produces a large UNION canvas, and the
|
|
2387
|
+
// BATCH + MultiBand blend peak scales with it (on a 6 GB A35 a
|
|
2388
|
+
// ~70 MP union hit ~2.97 GB RSS and was lmkd-killed mid-blend, never
|
|
2389
|
+
// reaching step11). Unlike the degenerate-warp guards (per-frame
|
|
2390
|
+
// 100 MP / cumulative 50 MP), this is a capture we want to COMPLETE,
|
|
2391
|
+
// not reject — so cap the canvas to a memory budget by reducing
|
|
2392
|
+
// compose scale, yielding a slightly-lower-res but complete pano.
|
|
2393
|
+
// warpRoi() here is corner-only/cheap (no pixel warp yet) and reuses
|
|
2394
|
+
// the EXACT union math blender->prepare() will allocate, so the probe
|
|
2395
|
+
// predicts the real canvas. No-op for normal panos: the budget floor
|
|
2396
|
+
// (12 MP) exceeds the widest valid 360° pano (~9 MP), so the 13
|
|
2397
|
+
// bounded captures see byte-identical behavior.
|
|
2398
|
+
if (!composeFrames.empty()) {
|
|
2399
|
+
std::vector<cv::Point> capCorners(composeFrames.size());
|
|
2400
|
+
std::vector<cv::Size> capSizes(composeFrames.size());
|
|
2401
|
+
bool capOk = true;
|
|
2402
|
+
for (size_t i = 0; i < composeFrames.size(); i++) {
|
|
2403
|
+
if (composeFrames[i].empty()) { capOk = false; break; }
|
|
2404
|
+
cv::Mat capK;
|
|
2405
|
+
cameras[i].K().convertTo(capK, CV_32F);
|
|
2406
|
+
const cv::Rect r = warper->warpRoi(
|
|
2407
|
+
composeFrames[i].size(), capK, cameras[i].R);
|
|
2408
|
+
capCorners[i] = r.tl();
|
|
2409
|
+
capSizes[i] = r.size();
|
|
2410
|
+
}
|
|
2411
|
+
if (capOk) {
|
|
2412
|
+
int64_t cw = 0, ch = 0;
|
|
2413
|
+
blendCanvasUnion(capCorners, capSizes, cw, ch);
|
|
2414
|
+
const double canvasMP = (double)cw * (double)ch / 1e6;
|
|
2415
|
+
const double budgetMP = composeCanvasBudgetMP(totalRamMB);
|
|
2416
|
+
const double downscale =
|
|
2417
|
+
canvasDownscaleForBudget(canvasMP, budgetMP);
|
|
2418
|
+
composeCanvasMpFinal = canvasMP * downscale * downscale;
|
|
2419
|
+
double heldSetMpRaw = 0.0;
|
|
2420
|
+
for (const auto& s : capSizes) {
|
|
2421
|
+
heldSetMpRaw += (double)s.width * (double)s.height / 1e6;
|
|
2422
|
+
}
|
|
2423
|
+
composeHeldSetMpFinal = heldSetMpRaw * downscale * downscale;
|
|
2424
|
+
// Always-on probe — confirms the RAM read (totalRamMB), the
|
|
2425
|
+
// budget, the active projection, and whether the cap fired.
|
|
2426
|
+
// Used to calibrate kBlendBytesPerUnionPx from real traces.
|
|
2427
|
+
log_info(logFn, "[stitch-bc]",
|
|
2428
|
+
"step7.7: canvas probe union=%lldx%lld (%.1f MP) "
|
|
2429
|
+
"budget=%.1f MP totalRamMB=%.0f warper=%s downscale=%.3f",
|
|
2430
|
+
(long long)cw, (long long)ch, canvasMP, budgetMP,
|
|
2431
|
+
totalRamMB, activeWarperType.c_str(), downscale);
|
|
2432
|
+
if (downscale < 1.0) {
|
|
2433
|
+
log_info(logFn, "[stitch-bc]",
|
|
2434
|
+
"step7.7: CAPPED downscale=%.3fx (canvasMP %.1f -> "
|
|
2435
|
+
"~%.1f, budget %.1f) — re-resizing composeFrames",
|
|
2436
|
+
downscale, canvasMP,
|
|
2437
|
+
canvasMP * downscale * downscale, budgetMP);
|
|
2438
|
+
// Co-scale EVERY quantity warpRoi depends on so the post-
|
|
2439
|
+
// cap canvas actually lands at ~budget: warpedScale,
|
|
2440
|
+
// compose_scale (read by the step9 seam aspect), and each
|
|
2441
|
+
// camera's intrinsics (focal/ppx/ppy — NOT R; mirrors the
|
|
2442
|
+
// step6 compose rescale; K() rebuilds on demand).
|
|
2443
|
+
warpedScale = (float)(warpedScale * downscale);
|
|
2444
|
+
compose_scale *= downscale;
|
|
2445
|
+
for (auto& cam : cameras) {
|
|
2446
|
+
cam.focal *= downscale;
|
|
2447
|
+
cam.ppx *= downscale;
|
|
2448
|
+
cam.ppy *= downscale;
|
|
2449
|
+
}
|
|
2450
|
+
// Re-resize composeFrames in place at the new scale.
|
|
2451
|
+
// INTER_LINEAR + pre-allocated dst + try/catch mirror the
|
|
2452
|
+
// step7c recycled-mmap SIGSEGV stability fix; on failure,
|
|
2453
|
+
// break out to the same failure handler step7c uses.
|
|
2454
|
+
try {
|
|
2455
|
+
for (size_t i = 0; i < composeFrames.size(); i++) {
|
|
2456
|
+
if (composeFrames[i].empty()) continue;
|
|
2457
|
+
const int nw = std::max(1,
|
|
2458
|
+
(int)std::round(composeFrames[i].cols * downscale));
|
|
2459
|
+
const int nh = std::max(1,
|
|
2460
|
+
(int)std::round(composeFrames[i].rows * downscale));
|
|
2461
|
+
cv::Mat resized(nh, nw, composeFrames[i].type());
|
|
2462
|
+
cv::resize(composeFrames[i], resized,
|
|
2463
|
+
resized.size(), 0, 0, cv::INTER_LINEAR);
|
|
2464
|
+
composeFrames[i] = resized;
|
|
2465
|
+
}
|
|
2466
|
+
} catch (const cv::Exception& e) {
|
|
2467
|
+
log_error(logFn, "[stitch-bc]",
|
|
2468
|
+
"step7.7: compose re-resize threw: %s", e.what());
|
|
2469
|
+
capturedErrorCode = StitchErrorCode::ComposeResizeFailed;
|
|
2470
|
+
capturedErrorMessage =
|
|
2471
|
+
std::string("Canvas-cap resize failed: ") + e.what();
|
|
2472
|
+
result.framesIncluded =
|
|
2473
|
+
static_cast<int32_t>(cameras.size());
|
|
2474
|
+
failedInsidePool = true;
|
|
2475
|
+
break;
|
|
2476
|
+
}
|
|
2477
|
+
// Re-create the warper at the new scale via the ACTIVE
|
|
2478
|
+
// projection (plane, or the step7.6 cylindrical fallback).
|
|
2479
|
+
if (auto w = make_warper(activeWarperType)) {
|
|
2480
|
+
warper = w->create(warpedScale);
|
|
2481
|
+
}
|
|
2482
|
+
log_info(logFn, "[stitch-bc]",
|
|
2483
|
+
"step7.7: cap applied new warpedScale=%.2f "
|
|
2484
|
+
"compose_scale=%.3f", warpedScale, compose_scale);
|
|
1657
2485
|
}
|
|
1658
2486
|
}
|
|
1659
2487
|
}
|
|
@@ -1679,7 +2507,62 @@ StitchResult stitchFramePathsManual(
|
|
|
1679
2507
|
// Both paths feed the SAME blender (selected per caller's
|
|
1680
2508
|
// blenderType). Final blend happens after either path
|
|
1681
2509
|
// completes.
|
|
1682
|
-
|
|
2510
|
+
// Wide-canvas low-memory routing. BATCH + MultiBand holds every
|
|
2511
|
+
// warped frame at once + N exposure-comp UMat copies + builds
|
|
2512
|
+
// Laplacian pyramids; on a 6 GB device a ~28 MP canvas peaked ~3 GB
|
|
2513
|
+
// in the blend/exposure stage and was lmkd-killed — even after the
|
|
2514
|
+
// step-9 cappedSeamAspect fix bounded the seam finder. Above
|
|
2515
|
+
// kLowMemCanvasMP, force the STREAM path (one warped frame at a time,
|
|
2516
|
+
// no held set, no exposure copies, no GraphCut) + the FEATHER blender
|
|
2517
|
+
// (single-pass, no pyramids) so a wide pan COMPLETES at full
|
|
2518
|
+
// resolution instead of OOMing. Below it, keep BATCH + MultiBand +
|
|
2519
|
+
// GraphCut for the crisp seams typical small-canvas captures get.
|
|
2520
|
+
// 2026-06-15 — RAM-gated STREAM-routing caps. On high-RAM devices
|
|
2521
|
+
// (≥5 GB physical → 6 GB+ nominal; Android sysconf reads a few hundred
|
|
2522
|
+
// MB under the marketing figure, so the gate is 5000 not 6000) raise the
|
|
2523
|
+
// caps so wide pans STAY on the sharp BATCH (GraphCut + MultiBand) path
|
|
2524
|
+
// instead of dropping to STREAM+feather (softer). Low-RAM devices keep
|
|
2525
|
+
// the conservative 10/15 MP thresholds. The lowHeadroom trigger below
|
|
2526
|
+
// still backstops ACTUAL memory pressure regardless of these static
|
|
2527
|
+
// caps, so raising them only lifts the pre-emptive ceiling, not the
|
|
2528
|
+
// safety net (a memory-pressured 6 GB device still routes to STREAM).
|
|
2529
|
+
const bool kHighRamDevice = totalRamMB >= 5000.0;
|
|
2530
|
+
const double kLowMemCanvasMP = kHighRamDevice ? 16.0 : 10.0;
|
|
2531
|
+
// Held-set guard: BATCH stayed safe at Σ-warped-area ≲13 MP but a
|
|
2532
|
+
// 6-frame AR pan with a 9.6 MP union (under kLowMemCanvasMP) yet a
|
|
2533
|
+
// ~32 MP held-set hit 3.6 GB and was lmkd-killed. Route to STREAM on
|
|
2534
|
+
// EITHER axis so a small-union/large-held-set capture (bigger or
|
|
2535
|
+
// heavily-overlapping frames, e.g. high-res AR keyframes) can't slip
|
|
2536
|
+
// into BATCH. 15 MP sits safely between the observed safe (≲13) and
|
|
2537
|
+
// fatal (~32) held-sets.
|
|
2538
|
+
const double kMaxBatchHeldSetMP = kHighRamDevice ? 22.0 : 15.0;
|
|
2539
|
+
// Issue 6 — headroom-aware routing. In addition to the fixed
|
|
2540
|
+
// canvas/held-set MP thresholds (which bound the stitch's OWN size),
|
|
2541
|
+
// route to STREAM when the process's CURRENT free headroom is thin —
|
|
2542
|
+
// i.e. whatever else is resident (host app, RN, residual buffers)
|
|
2543
|
+
// leaves little room for BATCH's multiband spike. This is the
|
|
2544
|
+
// "route, don't abort" half of Issue 6: under memory pressure we drop
|
|
2545
|
+
// to the lighter STREAM+feather path instead of risking an OOM (or a
|
|
2546
|
+
// hard pre-stitch abort). It only ever makes routing MORE
|
|
2547
|
+
// conservative, so it can't cause an OOM the fixed thresholds avoided.
|
|
2548
|
+
const double rssAtRouteMB = rss_mb();
|
|
2549
|
+
const bool lowHeadroom =
|
|
2550
|
+
retailens::lowBatchHeadroom(rssAtRouteMB, totalRamMB);
|
|
2551
|
+
const bool lowMemCanvas =
|
|
2552
|
+
composeCanvasMpFinal > kLowMemCanvasMP
|
|
2553
|
+
|| composeHeldSetMpFinal > kMaxBatchHeldSetMP
|
|
2554
|
+
|| lowHeadroom;
|
|
2555
|
+
const bool useSeam =
|
|
2556
|
+
(config.seamFinderType == "graphcut") && !lowMemCanvas;
|
|
2557
|
+
if (lowMemCanvas) {
|
|
2558
|
+
log_info(logFn, "[stitch-bc]",
|
|
2559
|
+
"step8: union=%.1f MP held-set=%.1f MP rss=%.0fMB "
|
|
2560
|
+
"budget=%.0fMB (union>%.1f or held>%.1f or lowHeadroom=%d)"
|
|
2561
|
+
" — routing to STREAM+feather",
|
|
2562
|
+
composeCanvasMpFinal, composeHeldSetMpFinal, rssAtRouteMB,
|
|
2563
|
+
perProcessBudgetMB, kLowMemCanvasMP, kMaxBatchHeldSetMP,
|
|
2564
|
+
lowHeadroom ? 1 : 0);
|
|
2565
|
+
}
|
|
1683
2566
|
log_info(logFn, "[BatchStitcher]",
|
|
1684
2567
|
"step8: %s",
|
|
1685
2568
|
useSeam ? "BATCH (warp-all + seam + feed)"
|
|
@@ -1687,6 +2570,21 @@ StitchResult stitchFramePathsManual(
|
|
|
1687
2570
|
log_info(logFn, "[stitch-bc]",
|
|
1688
2571
|
"step8 enter: %s", useSeam ? "BATCH" : "STREAM");
|
|
1689
2572
|
|
|
2573
|
+
// DEV overlay (2026-06-14) — record the choices made for THIS output so
|
|
2574
|
+
// the preview can show them in __DEV__. `warp` is the configured warper
|
|
2575
|
+
// (a divergence-only switch to spherical is rare + logged separately);
|
|
2576
|
+
// route/seam/blend are the decisions just resolved above. See
|
|
2577
|
+
// StitchResult::debugSummary.
|
|
2578
|
+
{
|
|
2579
|
+
const bool useFeather =
|
|
2580
|
+
(config.blenderType == "feather") || lowMemCanvas;
|
|
2581
|
+
result.debugSummary =
|
|
2582
|
+
std::string("pipe=manual;warp=") + config.warperType +
|
|
2583
|
+
";route=" + (useSeam ? "batch" : "stream") +
|
|
2584
|
+
";seam=" + (useSeam ? "graphcut" : "none") +
|
|
2585
|
+
";blend=" + (useFeather ? "feather" : "multiband");
|
|
2586
|
+
}
|
|
2587
|
+
|
|
1690
2588
|
// Build the blender once — both paths feed into it.
|
|
1691
2589
|
//
|
|
1692
2590
|
// The "u != 0" UMat assertion we previously hit when running
|
|
@@ -1697,7 +2595,10 @@ StitchResult stitchFramePathsManual(
|
|
|
1697
2595
|
// stitch, per-frame Mat releases, plus this stream path for
|
|
1698
2596
|
// low-mem devices), both should run cleanly.
|
|
1699
2597
|
cv::Ptr<cv::detail::Blender> blender;
|
|
1700
|
-
if (config.blenderType == "feather") {
|
|
2598
|
+
if (config.blenderType == "feather" || lowMemCanvas) {
|
|
2599
|
+
// FEATHER for the wide-canvas low-memory path (lowMemCanvas) too —
|
|
2600
|
+
// MultiBand's pyramids are the dominant blend allocation we're
|
|
2601
|
+
// avoiding.
|
|
1701
2602
|
blender = cv::detail::Blender::createDefault(
|
|
1702
2603
|
cv::detail::Blender::FEATHER, false);
|
|
1703
2604
|
auto fb = blender.dynamicCast<cv::detail::FeatherBlender>();
|
|
@@ -1739,16 +2640,20 @@ StitchResult stitchFramePathsManual(
|
|
|
1739
2640
|
cv::Mat K;
|
|
1740
2641
|
cameras[i].K().convertTo(K, CV_32F);
|
|
1741
2642
|
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
//
|
|
1745
|
-
|
|
2643
|
+
// 2026-06-16 (audit #5/L4) — warp composeFrames[i] DIRECTLY.
|
|
2644
|
+
// The old V12.14.6 `freshInput = composeFrames[i].clone()` (a
|
|
2645
|
+
// full-res malloc+memcpy+free per frame, as a recycled-mmap
|
|
2646
|
+
// defense) is disproven by the STREAM warp path below, which
|
|
2647
|
+
// passes the frame raw with no clone. warp READS the input +
|
|
2648
|
+
// writes a SEPARATE output Mat, and composeFrames[i] is
|
|
2649
|
+
// released just after — so the copy bought nothing. Removes N
|
|
2650
|
+
// full-res copies per BATCH stitch.
|
|
1746
2651
|
|
|
1747
2652
|
// V12.14.6 — pre-allocate output Mats via warpRoi() so
|
|
1748
2653
|
// cv::remap doesn't need to call create() internally
|
|
1749
2654
|
// (the suspect path that crashed in cv::resize too).
|
|
1750
2655
|
cv::Rect roi = warper->warpRoi(
|
|
1751
|
-
|
|
2656
|
+
composeFrames[i].size(), K, cameras[i].R);
|
|
1752
2657
|
// 2026-05-18 (Issue #1 guard): cv::Stitcher's estimator
|
|
1753
2658
|
// + BA can produce wildly wrong camera parameters on
|
|
1754
2659
|
// degenerate input (low feature count, near-duplicate
|
|
@@ -1776,36 +2681,19 @@ StitchResult stitchFramePathsManual(
|
|
|
1776
2681
|
i, roi.width, roi.height,
|
|
1777
2682
|
(long long)roiPixels,
|
|
1778
2683
|
(long long)kMaxWarpPixels);
|
|
1779
|
-
//
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
//
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
// translation-heavy input (homography + BA-Ray
|
|
1786
|
-
// assume pure rotation); SCANS usually fails on
|
|
1787
|
-
// low-texture or low-overlap input (affine needs
|
|
1788
|
-
// enough matches).
|
|
1789
|
-
const char* modeStr =
|
|
1790
|
-
(config.stitchMode == StitchMode::Scans) ? "scans" : "panorama";
|
|
1791
|
-
throw cv::Exception(
|
|
1792
|
-
cv::Error::StsOutOfRange,
|
|
1793
|
-
std::string("warpRoi too large (")
|
|
1794
|
-
+ std::to_string(roi.width) + "x"
|
|
1795
|
-
+ std::to_string(roi.height)
|
|
1796
|
-
+ ") — estimator produced degenerate "
|
|
1797
|
-
+ "camera params on this frame (stitchMode="
|
|
1798
|
-
+ modeStr + ", frameIdx="
|
|
1799
|
-
+ std::to_string(i) + ")",
|
|
1800
|
-
"stitchFramePathsManual",
|
|
1801
|
-
__FILE__, __LINE__);
|
|
2684
|
+
// Message + envelope built by the shared helper so
|
|
2685
|
+
// all four degenerate-warp throw sites stay in sync
|
|
2686
|
+
// (see degenerateFrameException above). Lands in the
|
|
2687
|
+
// step8b catch below → WarpFailed.
|
|
2688
|
+
throw degenerateFrameException(
|
|
2689
|
+
roi.width, roi.height, config.stitchMode, i);
|
|
1802
2690
|
}
|
|
1803
|
-
imagesWarped[i].create(roi.size(),
|
|
2691
|
+
imagesWarped[i].create(roi.size(), composeFrames[i].type());
|
|
1804
2692
|
masksWarped[i].create(roi.size(), CV_8U);
|
|
1805
2693
|
|
|
1806
|
-
cv::Mat mask(
|
|
2694
|
+
cv::Mat mask(composeFrames[i].size(), CV_8U, cv::Scalar(255));
|
|
1807
2695
|
corners[i] = warper->warp(
|
|
1808
|
-
|
|
2696
|
+
composeFrames[i], K, cameras[i].R, cv::INTER_LINEAR,
|
|
1809
2697
|
cv::BORDER_CONSTANT, imagesWarped[i]);
|
|
1810
2698
|
warper->warp(mask, K, cameras[i].R, cv::INTER_NEAREST,
|
|
1811
2699
|
cv::BORDER_CONSTANT, masksWarped[i]);
|
|
@@ -1875,14 +2763,33 @@ StitchResult stitchFramePathsManual(
|
|
|
1875
2763
|
// Aspect from compose scale → seam scale (the rescale we
|
|
1876
2764
|
// apply to existing compose-scale data, not the original).
|
|
1877
2765
|
double seam_compose_aspect = seam_scale / compose_scale;
|
|
2766
|
+
// BUGFIX (wide-pan GraphCut OOM): the aspect above is derived from
|
|
2767
|
+
// the INPUT frame size (origMp), but the resize below is applied to
|
|
2768
|
+
// the WARPED images, which span the whole canvas and can be many×
|
|
2769
|
+
// larger (a ~0.3 MP frame warps across a multi-MP canvas on a wide
|
|
2770
|
+
// pan). Left uncapped, GraphCut ran on multi-MP seam images and
|
|
2771
|
+
// its per-pixel max-flow graph exploded to GBs (a 19 MP-canvas
|
|
2772
|
+
// capture was lmkd-killed here — 3.16 GB RSS + 2.1 GB swap). Re-cap
|
|
2773
|
+
// against the LARGEST warped frame so every seam image is ≤ SEAM_MP,
|
|
2774
|
+
// which is what cv::Stitcher's seam_est_resol actually targets.
|
|
2775
|
+
double maxWarpedMp = 0.0;
|
|
2776
|
+
for (size_t i = 0; i < N; i++) {
|
|
2777
|
+
maxWarpedMp = std::max(
|
|
2778
|
+
maxWarpedMp,
|
|
2779
|
+
(double)sizes[i].width * (double)sizes[i].height / 1e6);
|
|
2780
|
+
}
|
|
2781
|
+
seam_compose_aspect =
|
|
2782
|
+
cappedSeamAspect(seam_compose_aspect, maxWarpedMp, SEAM_MP);
|
|
1878
2783
|
{
|
|
1879
2784
|
auto _t = std::chrono::steady_clock::now();
|
|
1880
2785
|
double _ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
1881
2786
|
_t - t0).count();
|
|
1882
2787
|
log_info(logFn, "[BatchStitcher]",
|
|
1883
|
-
"step9: graph-cut seam finder "
|
|
1884
|
-
"
|
|
1885
|
-
seam_compose_aspect,
|
|
2788
|
+
"step9: graph-cut seam finder (maxWarpedMP=%.1f "
|
|
2789
|
+
"compose→seam aspect=%.4f → seamMP≈%.2f, t+%.0fms)",
|
|
2790
|
+
maxWarpedMp, seam_compose_aspect,
|
|
2791
|
+
maxWarpedMp * seam_compose_aspect * seam_compose_aspect,
|
|
2792
|
+
_ms);
|
|
1886
2793
|
}
|
|
1887
2794
|
auto _seamStart = std::chrono::steady_clock::now();
|
|
1888
2795
|
log_info(logFn, "[stitch-bc]",
|
|
@@ -1965,17 +2872,51 @@ StitchResult stitchFramePathsManual(
|
|
|
1965
2872
|
auto compensator = cv::detail::ExposureCompensator::createDefault(
|
|
1966
2873
|
cv::detail::ExposureCompensator::GAIN_BLOCKS);
|
|
1967
2874
|
{
|
|
2875
|
+
// 2026-06-16 (audit #1) — feed the compensator ZERO-COPY views,
|
|
2876
|
+
// not deep copies. With HAVE_OPENCL undefined (both platforms)
|
|
2877
|
+
// getUMat(ACCESS_READ) shares the backing Mat buffer (no memcpy,
|
|
2878
|
+
// no GPU transfer); feed() takes const& and only READS pixels to
|
|
2879
|
+
// solve gains, so the output is byte-identical. The old copyTo
|
|
2880
|
+
// loop DOUBLED the full-res warped held-set (~60-90 MB transient)
|
|
2881
|
+
// at the exact BATCH peak the canvas budget is tuned around —
|
|
2882
|
+
// imagesWarped[]/masksWarped[] are still live here (released in
|
|
2883
|
+
// the feed loop below).
|
|
1968
2884
|
std::vector<cv::UMat> compImgs(N), compMasks(N);
|
|
1969
2885
|
for (size_t i = 0; i < N; i++) {
|
|
1970
|
-
imagesWarped[i].
|
|
1971
|
-
masksWarped[i].
|
|
2886
|
+
compImgs[i] = imagesWarped[i].getUMat(cv::ACCESS_READ);
|
|
2887
|
+
compMasks[i] = masksWarped[i].getUMat(cv::ACCESS_READ);
|
|
1972
2888
|
}
|
|
1973
2889
|
compensator->feed(corners, compImgs, compMasks);
|
|
1974
2890
|
}
|
|
1975
2891
|
|
|
1976
|
-
//
|
|
1977
|
-
|
|
2892
|
+
// Layer-2 guard (cumulative canvas): the union of all positioned
|
|
2893
|
+
// warp rects is exactly what blender->prepare() allocates as its
|
|
2894
|
+
// CV_16SC3 accumulator. Every per-frame extent passed the
|
|
2895
|
+
// step8b guard above, but a single degenerate corner OFFSET can
|
|
2896
|
+
// still blow this union to gigapixels — the real crash-B path
|
|
2897
|
+
// (51 MB → 3.7 GB on one rapid pan). Guard BEFORE prepare().
|
|
2898
|
+
int64_t canvasW = 0, canvasH = 0;
|
|
2899
|
+
blendCanvasUnion(corners, sizes, canvasW, canvasH);
|
|
2900
|
+
if (canvasExceedsGuard(canvasW, canvasH)) {
|
|
2901
|
+
log_error(logFn, "[stitch-bc]",
|
|
2902
|
+
"step10a: blend canvas degenerate "
|
|
2903
|
+
"(%lldx%lld px) — treating as warp failure",
|
|
2904
|
+
(long long)canvasW, (long long)canvasH);
|
|
2905
|
+
throw degenerateCanvasException(
|
|
2906
|
+
canvasW, canvasH, config.stitchMode, N);
|
|
2907
|
+
}
|
|
2908
|
+
// Feed the blender, releasing each frame as we go. Log the union
|
|
2909
|
+
// + RSS: the union here MUST equal the step7.7 post-cap probe — a
|
|
2910
|
+
// mismatch means a co-scaled quantity was missed. step10a2
|
|
2911
|
+
// isolates the persistent MultiBand accumulator (~the term the
|
|
2912
|
+
// canvas budget bounds).
|
|
2913
|
+
log_info(logFn, "[stitch-bc]",
|
|
2914
|
+
"step10a: blender->prepare union=%lldx%lld (%.1f MP) mem=%.1fMB",
|
|
2915
|
+
(long long)canvasW, (long long)canvasH,
|
|
2916
|
+
(double)canvasW * (double)canvasH / 1e6, rss_mb());
|
|
1978
2917
|
blender->prepare(corners, sizes);
|
|
2918
|
+
log_info(logFn, "[stitch-bc]",
|
|
2919
|
+
"step10a2: prepared mem=%.1fMB", rss_mb());
|
|
1979
2920
|
log_info(logFn, "[stitch-bc]",
|
|
1980
2921
|
"step10b: feeding blender (N=%zu)", N);
|
|
1981
2922
|
for (size_t i = 0; i < N; i++) {
|
|
@@ -2005,6 +2946,20 @@ StitchResult stitchFramePathsManual(
|
|
|
2005
2946
|
for (size_t i = 0; i < N; i++) {
|
|
2006
2947
|
cv::Mat K;
|
|
2007
2948
|
cameras[i].K().convertTo(K, CV_32F);
|
|
2949
|
+
// Layer-1 guard (STREAM): probe the cheap warpRoi BEFORE the
|
|
2950
|
+
// real mask warp below. Unlike BATCH, the STREAM path had no
|
|
2951
|
+
// per-frame net, so a degenerate ROI would OOM inside
|
|
2952
|
+
// warper->warp()'s buildMaps/remap allocation right here.
|
|
2953
|
+
const cv::Rect probe = warper->warpRoi(
|
|
2954
|
+
composeFrames[i].size(), K, cameras[i].R);
|
|
2955
|
+
if (warpRoiExceedsGuard(probe.width, probe.height)) {
|
|
2956
|
+
log_error(logFn, "[stitch-bc]",
|
|
2957
|
+
"step8b(stream): warpRoi degenerate for frame "
|
|
2958
|
+
"%zu (%dx%d) — treating as warp failure",
|
|
2959
|
+
i, probe.width, probe.height);
|
|
2960
|
+
throw degenerateFrameException(
|
|
2961
|
+
probe.width, probe.height, config.stitchMode, i);
|
|
2962
|
+
}
|
|
2008
2963
|
cv::Mat mask(composeFrames[i].size(), CV_8U, cv::Scalar(255));
|
|
2009
2964
|
cv::Mat tmpMaskWarped;
|
|
2010
2965
|
corners[i] = warper->warp(
|
|
@@ -2018,6 +2973,20 @@ StitchResult stitchFramePathsManual(
|
|
|
2018
2973
|
// ~40-50 MB lower peak vs the BATCH path at 1.0 MP × 8
|
|
2019
2974
|
// frames — the difference between staying under iOS' jetsam
|
|
2020
2975
|
// threshold on a 2 GB device and getting WatchdogTermination.
|
|
2976
|
+
// Layer-2 guard (cumulative canvas) — see the BATCH path for the
|
|
2977
|
+
// rationale. Same union check before the STREAM prepare().
|
|
2978
|
+
{
|
|
2979
|
+
int64_t canvasW = 0, canvasH = 0;
|
|
2980
|
+
blendCanvasUnion(corners, sizes, canvasW, canvasH);
|
|
2981
|
+
if (canvasExceedsGuard(canvasW, canvasH)) {
|
|
2982
|
+
log_error(logFn, "[stitch-bc]",
|
|
2983
|
+
"step10(stream): blend canvas degenerate "
|
|
2984
|
+
"(%lldx%lld px) — treating as warp failure",
|
|
2985
|
+
(long long)canvasW, (long long)canvasH);
|
|
2986
|
+
throw degenerateCanvasException(
|
|
2987
|
+
canvasW, canvasH, config.stitchMode, N);
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2021
2990
|
blender->prepare(corners, sizes);
|
|
2022
2991
|
for (size_t i = 0; i < N; i++) {
|
|
2023
2992
|
cv::Mat K;
|
|
@@ -2060,6 +3029,27 @@ StitchResult stitchFramePathsManual(
|
|
|
2060
3029
|
"step11c: panorama 8U conversion done (panorama=%dx%d) mem=%.1fMB",
|
|
2061
3030
|
panorama.cols, panorama.rows, rss_mb());
|
|
2062
3031
|
|
|
3032
|
+
// Issue 3 — post-stitch validation. Reject a disjoint / fragmented
|
|
3033
|
+
// output (frames that survived confidence but didn't fuse into one
|
|
3034
|
+
// panorama) so the host gets a clean failure (→ STITCH_LOW_QUALITY,
|
|
3035
|
+
// "try again") instead of a broken image. Fails open on an
|
|
3036
|
+
// unreadable mask. Capture the failure into the strong locals +
|
|
3037
|
+
// break out of the do/while(0) like the catch paths do.
|
|
3038
|
+
{
|
|
3039
|
+
std::string validateMessage;
|
|
3040
|
+
const StitchErrorCode validateCode = validateStitchOutput(
|
|
3041
|
+
panorama, panoramaMask,
|
|
3042
|
+
static_cast<int>(cameras.size()), logFn, validateMessage);
|
|
3043
|
+
if (validateCode != StitchErrorCode::Ok) {
|
|
3044
|
+
log_error(logFn, "[stitch-bc]",
|
|
3045
|
+
"step11d: REJECTED — %s", validateMessage.c_str());
|
|
3046
|
+
capturedErrorCode = validateCode;
|
|
3047
|
+
capturedErrorMessage = validateMessage;
|
|
3048
|
+
failedInsidePool = true;
|
|
3049
|
+
break;
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
|
|
2063
3053
|
// Record retained-frame count for telemetry. In the high-level
|
|
2064
3054
|
// path this comes from stitcher->component().size() after retry;
|
|
2065
3055
|
// in the manual path it's whatever leaveBiggestComponent kept
|