react-native-image-stitcher 0.16.0 → 0.16.2
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 +80 -0
- package/README.md +41 -44
- package/android/build.gradle +34 -0
- package/android/src/main/cpp/image_stitcher_jni.cpp +52 -7
- package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +135 -59
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
- package/cpp/keyframe_gate.cpp +54 -15
- package/cpp/keyframe_gate.hpp +33 -0
- package/cpp/stitcher.cpp +481 -87
- package/cpp/stitcher.hpp +52 -0
- package/dist/camera/Camera.d.ts +13 -0
- package/dist/camera/Camera.js +9 -64
- package/dist/camera/CaptureFrameCounterOverlay.js +12 -1
- package/dist/camera/CaptureMemoryPill.d.ts +15 -7
- package/dist/camera/CaptureMemoryPill.js +34 -9
- package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
- package/dist/camera/PanoramaBandOverlay.js +9 -3
- package/dist/camera/PanoramaSettings.js +22 -25
- package/dist/camera/RectCropPreview.d.ts +3 -29
- package/dist/camera/RectCropPreview.js +20 -130
- package/dist/stitching/incremental.d.ts +29 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
- package/dist/stitching/useIncrementalStitcher.js +7 -1
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +103 -26
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +21 -1
- package/package.json +1 -1
- package/src/camera/Camera.tsx +21 -70
- package/src/camera/CaptureFrameCounterOverlay.tsx +15 -1
- package/src/camera/CaptureMemoryPill.tsx +33 -9
- package/src/camera/PanoramaBandOverlay.tsx +9 -1
- package/src/camera/PanoramaSettings.ts +22 -25
- package/src/camera/RectCropPreview.tsx +38 -220
- package/src/stitching/incremental.ts +29 -0
- package/src/stitching/useIncrementalStitcher.ts +13 -0
- package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
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,80 @@ 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
|
+
|
|
88
177
|
// Total physical RAM in MB, read natively. The Android JNI bridge sets no
|
|
89
178
|
// availableRamMB, so without this a 6 GB device is mis-treated as the 4 GB
|
|
90
179
|
// fallback (and the step-7.7 canvas budget + pre-stitch abort under-size).
|
|
@@ -493,81 +582,139 @@ StitchResult stitchFramePaths(
|
|
|
493
582
|
const StitchConfig& config,
|
|
494
583
|
LogFn logFn)
|
|
495
584
|
{
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
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.
|
|
594
|
+
//
|
|
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):
|
|
503
647
|
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
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 {
|
|
513
661
|
StitchConfig cfg = config;
|
|
514
662
|
cfg.stitchMode = modeOverride;
|
|
515
|
-
|
|
516
|
-
// → AffineBasedEstimator → BundleAdjusterAffinePartial → AffineWarper),
|
|
517
|
-
// which only the high-level cv::Stitcher provides — the manual pipeline
|
|
518
|
-
// is homography-only (an affine matcher was tried + reverted in fix-11
|
|
519
|
-
// for incoherence). So force the high-level path for any SCANS attempt
|
|
520
|
-
// (primary or fallback); PANORAMA keeps the host's pipeline choice
|
|
521
|
-
// (manual by default — the proven-robust wide-pan path).
|
|
522
|
-
if (modeOverride == StitchMode::Scans) {
|
|
523
|
-
cfg.useManualPipeline = false;
|
|
524
|
-
}
|
|
663
|
+
if (!warperOverride.empty()) cfg.warperType = warperOverride;
|
|
525
664
|
return stitchFramePathsImpl_(framePaths, outputPath, cfg, logFn);
|
|
526
665
|
};
|
|
527
|
-
StitchResult firstAttempt = runOnce(config.stitchMode);
|
|
666
|
+
StitchResult firstAttempt = runOnce(config.stitchMode, std::string());
|
|
667
|
+
firstAttempt.stitchModeUsed = config.stitchMode;
|
|
528
668
|
if (firstAttempt.errorCode == StitchErrorCode::Ok) {
|
|
529
|
-
firstAttempt
|
|
530
|
-
return firstAttempt;
|
|
669
|
+
return finish(firstAttempt);
|
|
531
670
|
}
|
|
532
|
-
|
|
533
|
-
// is something the opposite mode wouldn't fix (e.g. invalid
|
|
534
|
-
// argument count, file-read failure, OOM).
|
|
535
|
-
bool worthRetrying =
|
|
671
|
+
const bool worthRetrying =
|
|
536
672
|
firstAttempt.errorCode == StitchErrorCode::UnknownCvException
|
|
537
673
|
|| firstAttempt.errorCode == StitchErrorCode::HomographyEstimationFailed
|
|
538
674
|
|| firstAttempt.errorCode == StitchErrorCode::CameraParamsAdjustFailed
|
|
539
675
|
|| firstAttempt.errorCode == StitchErrorCode::WarpFailed
|
|
540
|
-
|| firstAttempt.errorCode == StitchErrorCode::EmptyPanorama
|
|
676
|
+
|| firstAttempt.errorCode == StitchErrorCode::EmptyPanorama
|
|
677
|
+
|| firstAttempt.errorCode == StitchErrorCode::LowQualityStitch;
|
|
541
678
|
if (!worthRetrying) {
|
|
542
|
-
firstAttempt
|
|
543
|
-
return firstAttempt;
|
|
679
|
+
return finish(firstAttempt);
|
|
544
680
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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;
|
|
557
706
|
log_info(logFn, "[stitch-fallback]",
|
|
558
|
-
"
|
|
707
|
+
"manual primary mode (%s) failed code=%d — retrying %s",
|
|
708
|
+
config.stitchMode == StitchMode::Scans ? "scans" : "panorama",
|
|
709
|
+
static_cast<int>(firstAttempt.errorCode),
|
|
559
710
|
fallbackMode == StitchMode::Scans ? "scans" : "panorama");
|
|
560
|
-
|
|
711
|
+
StitchResult secondAttempt = runOnce(fallbackMode, std::string());
|
|
712
|
+
if (secondAttempt.errorCode == StitchErrorCode::Ok) {
|
|
713
|
+
secondAttempt.stitchModeUsed = fallbackMode;
|
|
714
|
+
return finish(secondAttempt);
|
|
715
|
+
}
|
|
561
716
|
}
|
|
562
|
-
|
|
563
|
-
// what the operator's chosen mode produced — more useful for
|
|
564
|
-
// diagnosis than the fallback's failure).
|
|
565
|
-
log_info(logFn, "[stitch-fallback]",
|
|
566
|
-
"fallback mode (%s) also failed with code=%d — returning primary error",
|
|
567
|
-
fallbackMode == StitchMode::Scans ? "scans" : "panorama",
|
|
568
|
-
static_cast<int>(secondAttempt.errorCode));
|
|
569
|
-
firstAttempt.stitchModeUsed = config.stitchMode;
|
|
570
|
-
return firstAttempt;
|
|
717
|
+
return finish(firstAttempt);
|
|
571
718
|
}
|
|
572
719
|
|
|
573
720
|
// 2026-05-22 (audit follow-up) — renamed inner entry point so the
|
|
@@ -584,7 +731,14 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
584
731
|
// useManualPipeline for the tradeoffs. Routing here keeps the
|
|
585
732
|
// call-site signature identical so existing bridges (iOS Obj-C++,
|
|
586
733
|
// Android JNI) don't need to know which path runs internally.
|
|
587
|
-
|
|
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) {
|
|
588
742
|
StitchResult r =
|
|
589
743
|
stitchFramePathsManual(framePaths, outputPath, config, logFn);
|
|
590
744
|
// 2026-06-15 — AUTO SPHERICAL FALLBACK. The manual pipeline defaults to
|
|
@@ -614,6 +768,16 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
614
768
|
StitchResult result;
|
|
615
769
|
result.framesRequested = static_cast<int32_t>(framePaths.size());
|
|
616
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 {
|
|
617
781
|
if (framePaths.size() < 2) {
|
|
618
782
|
result.errorCode = StitchErrorCode::InvalidArgument;
|
|
619
783
|
result.errorMessage = "Need at least 2 frames to stitch (got " +
|
|
@@ -639,7 +803,9 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
639
803
|
config.captureOrientation.c_str(),
|
|
640
804
|
config.jpegQuality,
|
|
641
805
|
config.useInscribedRectCrop ? 1 : 0);
|
|
642
|
-
|
|
806
|
+
// Gate the [memstat] phase logs (compile flag + runtime settings.debug).
|
|
807
|
+
const bool memstat = kMemProfilingCompiled && config.enableMemoryProfiling;
|
|
808
|
+
log_memstat(logFn, memstat, "entry");
|
|
643
809
|
|
|
644
810
|
// ── 1. Load input frames ───────────────────────────────────────
|
|
645
811
|
std::vector<cv::Mat> images;
|
|
@@ -662,7 +828,7 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
662
828
|
}
|
|
663
829
|
log_info(logFn, "[dimstat]", "loaded %zu frames total_input_data=%.2f MB",
|
|
664
830
|
images.size(), totalInputMB);
|
|
665
|
-
|
|
831
|
+
log_memstat(logFn, memstat, "after_imread");
|
|
666
832
|
|
|
667
833
|
// ── 2. Configure cv::Stitcher ──────────────────────────────────
|
|
668
834
|
const cv::Stitcher::Mode cvMode = (config.stitchMode == StitchMode::Scans)
|
|
@@ -704,7 +870,17 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
704
870
|
if (config.seamEstimationResolMP > 0.0) {
|
|
705
871
|
stitcher->setSeamEstimationResol(config.seamEstimationResolMP);
|
|
706
872
|
}
|
|
707
|
-
|
|
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;
|
|
708
884
|
const double composeMP = (config.compositingResolMP > 0.0)
|
|
709
885
|
? config.compositingResolMP : kHighLevelComposeFallbackMP;
|
|
710
886
|
stitcher->setCompositingResol(composeMP);
|
|
@@ -725,7 +901,28 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
725
901
|
// with progressively lower thresholds [1.0 → 0.5 → 0.3] until
|
|
726
902
|
// every frame is retained or we hit the floor. SCANS skips the
|
|
727
903
|
// higher thresholds (its default is already 0.3).
|
|
728
|
-
|
|
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");
|
|
729
926
|
const double kRetryThresholds[] = {1.0, 0.5, 0.3};
|
|
730
927
|
const int kNumAttempts = sizeof(kRetryThresholds) / sizeof(double);
|
|
731
928
|
cv::Mat panorama;
|
|
@@ -743,10 +940,14 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
743
940
|
"attempt %d/%d panoConfidenceThresh=%.2f",
|
|
744
941
|
finalAttempt, kNumAttempts, thresh);
|
|
745
942
|
try {
|
|
746
|
-
|
|
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);
|
|
747
948
|
} catch (const cv::Exception& e) {
|
|
748
949
|
result.errorCode = StitchErrorCode::UnknownCvException;
|
|
749
|
-
result.errorMessage = std::string("Stitcher::
|
|
950
|
+
result.errorMessage = std::string("Stitcher::estimateTransform threw on attempt ") +
|
|
750
951
|
std::to_string(finalAttempt) + ": " + e.what();
|
|
751
952
|
log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
|
|
752
953
|
return result;
|
|
@@ -777,19 +978,156 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
777
978
|
}
|
|
778
979
|
if (status != cv::Stitcher::OK) {
|
|
779
980
|
result.errorCode = statusToErrorCode(status);
|
|
780
|
-
result.errorMessage = "Stitcher::
|
|
981
|
+
result.errorMessage = "Stitcher::estimateTransform failed at all " +
|
|
781
982
|
std::to_string(finalAttempt) + " thresholds, last status code " +
|
|
782
983
|
std::to_string(static_cast<int>(status));
|
|
783
984
|
log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
|
|
784
985
|
return result;
|
|
785
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
|
+
|
|
786
1124
|
log_info(logFn, "[dimstat]",
|
|
787
1125
|
"post-stitch panorama %dx%d %dch data=%.2f MB"
|
|
788
1126
|
" (framesIncluded=%d/%zu, finalThresh=%.2f, attempts=%d)",
|
|
789
1127
|
panorama.cols, panorama.rows, panorama.channels(),
|
|
790
1128
|
mat_mb(panorama),
|
|
791
1129
|
framesIncluded, framePaths.size(), finalThreshold, finalAttempt);
|
|
792
|
-
|
|
1130
|
+
log_memstat(logFn, memstat, "after_stitch");
|
|
793
1131
|
|
|
794
1132
|
// ── 4. Crop (coverage-aware inscribed rect, or bbox) ───────────
|
|
795
1133
|
// Pull cv::Stitcher's coverage mask (0xFF filled / 0 unfilled). It is
|
|
@@ -802,6 +1140,26 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
802
1140
|
rm.copyTo(coverage); // download UMat → Mat
|
|
803
1141
|
}
|
|
804
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
|
+
|
|
805
1163
|
cv::Mat cropMask;
|
|
806
1164
|
const cv::Rect cropRect = choose_crop_rect(
|
|
807
1165
|
panorama, coverage, config.useInscribedRectCrop, logFn, cropMask);
|
|
@@ -817,14 +1175,14 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
817
1175
|
mat_mb(cropped),
|
|
818
1176
|
config.useInscribedRectCrop ? 1 : 0,
|
|
819
1177
|
coverage.empty() ? 0 : 1);
|
|
820
|
-
|
|
1178
|
+
log_memstat(logFn, memstat, "after_crop");
|
|
821
1179
|
|
|
822
1180
|
// ── 5. Bake rotation per capture orientation ───────────────────
|
|
823
1181
|
cv::Mat final_image = bake_rotation(cropped, config.captureOrientation, logFn);
|
|
824
1182
|
log_info(logFn, "[dimstat]",
|
|
825
1183
|
"post-bake_rotation %dx%d data=%.2f MB",
|
|
826
1184
|
final_image.cols, final_image.rows, mat_mb(final_image));
|
|
827
|
-
|
|
1185
|
+
log_memstat(logFn, memstat, "after_bake_rotation");
|
|
828
1186
|
|
|
829
1187
|
// ── 6. Write JPEG ──────────────────────────────────────────────
|
|
830
1188
|
const int q = std::max(0, std::min(100, config.jpegQuality));
|
|
@@ -847,7 +1205,7 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
847
1205
|
log_info(logFn, "[stitch]",
|
|
848
1206
|
"output written: %s (%dx%d)",
|
|
849
1207
|
outputPath.c_str(), final_image.cols, final_image.rows);
|
|
850
|
-
|
|
1208
|
+
log_memstat(logFn, memstat, "after_imwrite");
|
|
851
1209
|
|
|
852
1210
|
// Best-effort coverage sidecar (<output>.coverage.png), bake-rotated
|
|
853
1211
|
// to align with the JPEG, for the debug harness. Never fails stitch.
|
|
@@ -878,6 +1236,29 @@ static StitchResult stitchFramePathsImpl_(
|
|
|
878
1236
|
std::string("pipe=highlevel;warp=") + config.warperType +
|
|
879
1237
|
";route=batch;seam=graphcut;blend=multiband";
|
|
880
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
|
+
}
|
|
881
1262
|
}
|
|
882
1263
|
|
|
883
1264
|
|
|
@@ -2259,16 +2640,20 @@ StitchResult stitchFramePathsManual(
|
|
|
2259
2640
|
cv::Mat K;
|
|
2260
2641
|
cameras[i].K().convertTo(K, CV_32F);
|
|
2261
2642
|
|
|
2262
|
-
//
|
|
2263
|
-
//
|
|
2264
|
-
//
|
|
2265
|
-
|
|
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.
|
|
2266
2651
|
|
|
2267
2652
|
// V12.14.6 — pre-allocate output Mats via warpRoi() so
|
|
2268
2653
|
// cv::remap doesn't need to call create() internally
|
|
2269
2654
|
// (the suspect path that crashed in cv::resize too).
|
|
2270
2655
|
cv::Rect roi = warper->warpRoi(
|
|
2271
|
-
|
|
2656
|
+
composeFrames[i].size(), K, cameras[i].R);
|
|
2272
2657
|
// 2026-05-18 (Issue #1 guard): cv::Stitcher's estimator
|
|
2273
2658
|
// + BA can produce wildly wrong camera parameters on
|
|
2274
2659
|
// degenerate input (low feature count, near-duplicate
|
|
@@ -2303,12 +2688,12 @@ StitchResult stitchFramePathsManual(
|
|
|
2303
2688
|
throw degenerateFrameException(
|
|
2304
2689
|
roi.width, roi.height, config.stitchMode, i);
|
|
2305
2690
|
}
|
|
2306
|
-
imagesWarped[i].create(roi.size(),
|
|
2691
|
+
imagesWarped[i].create(roi.size(), composeFrames[i].type());
|
|
2307
2692
|
masksWarped[i].create(roi.size(), CV_8U);
|
|
2308
2693
|
|
|
2309
|
-
cv::Mat mask(
|
|
2694
|
+
cv::Mat mask(composeFrames[i].size(), CV_8U, cv::Scalar(255));
|
|
2310
2695
|
corners[i] = warper->warp(
|
|
2311
|
-
|
|
2696
|
+
composeFrames[i], K, cameras[i].R, cv::INTER_LINEAR,
|
|
2312
2697
|
cv::BORDER_CONSTANT, imagesWarped[i]);
|
|
2313
2698
|
warper->warp(mask, K, cameras[i].R, cv::INTER_NEAREST,
|
|
2314
2699
|
cv::BORDER_CONSTANT, masksWarped[i]);
|
|
@@ -2487,10 +2872,19 @@ StitchResult stitchFramePathsManual(
|
|
|
2487
2872
|
auto compensator = cv::detail::ExposureCompensator::createDefault(
|
|
2488
2873
|
cv::detail::ExposureCompensator::GAIN_BLOCKS);
|
|
2489
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).
|
|
2490
2884
|
std::vector<cv::UMat> compImgs(N), compMasks(N);
|
|
2491
2885
|
for (size_t i = 0; i < N; i++) {
|
|
2492
|
-
imagesWarped[i].
|
|
2493
|
-
masksWarped[i].
|
|
2886
|
+
compImgs[i] = imagesWarped[i].getUMat(cv::ACCESS_READ);
|
|
2887
|
+
compMasks[i] = masksWarped[i].getUMat(cv::ACCESS_READ);
|
|
2494
2888
|
}
|
|
2495
2889
|
compensator->feed(corners, compImgs, compMasks);
|
|
2496
2890
|
}
|