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.
Files changed (146) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/README.md +131 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +154 -13
  4. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  5. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  6. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +220 -87
  7. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  9. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  10. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  11. package/cpp/crop_quad.cpp +162 -0
  12. package/cpp/crop_quad.hpp +163 -0
  13. package/cpp/keyframe_gate.cpp +54 -15
  14. package/cpp/keyframe_gate.hpp +33 -0
  15. package/cpp/stitcher.cpp +1122 -132
  16. package/cpp/stitcher.hpp +62 -0
  17. package/cpp/warp_guard.hpp +212 -0
  18. package/dist/camera/Camera.d.ts +209 -12
  19. package/dist/camera/Camera.js +575 -36
  20. package/dist/camera/CameraView.js +35 -16
  21. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  22. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  23. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  24. package/dist/camera/CaptureFrameCounterOverlay.js +153 -0
  25. package/dist/camera/CaptureMemoryPill.d.ts +24 -8
  26. package/dist/camera/CaptureMemoryPill.js +37 -12
  27. package/dist/camera/CapturePreview.js +2 -1
  28. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  29. package/dist/camera/CaptureStatusOverlay.js +22 -5
  30. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  31. package/dist/camera/LateralMotionModal.d.ts +85 -0
  32. package/dist/camera/LateralMotionModal.js +134 -0
  33. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  34. package/dist/camera/PanHowToOverlay.js +222 -0
  35. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  36. package/dist/camera/PanoramaBandOverlay.js +9 -3
  37. package/dist/camera/PanoramaSettings.d.ts +8 -6
  38. package/dist/camera/PanoramaSettings.js +19 -1
  39. package/dist/camera/PanoramaSettingsModal.js +4 -4
  40. package/dist/camera/RectCropPreview.d.ts +135 -0
  41. package/dist/camera/RectCropPreview.js +370 -0
  42. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  43. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  44. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  45. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  46. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  47. package/dist/camera/cameraErrorMessages.js +26 -10
  48. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  49. package/dist/camera/cameraGuidanceCopy.js +80 -0
  50. package/dist/camera/captureCountdown.d.ts +52 -0
  51. package/dist/camera/captureCountdown.js +76 -0
  52. package/dist/camera/captureWarnings.d.ts +90 -0
  53. package/dist/camera/captureWarnings.js +108 -0
  54. package/dist/camera/classifyStitchError.d.ts +30 -0
  55. package/dist/camera/classifyStitchError.js +42 -0
  56. package/dist/camera/cropGeometry.d.ts +136 -0
  57. package/dist/camera/cropGeometry.js +223 -0
  58. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  59. package/dist/camera/displayDecodeImageProps.js +29 -0
  60. package/dist/camera/guidanceGraphics.d.ts +58 -0
  61. package/dist/camera/guidanceGraphics.js +280 -0
  62. package/dist/camera/guidanceTokens.d.ts +54 -0
  63. package/dist/camera/guidanceTokens.js +58 -0
  64. package/dist/camera/panModeGate.d.ts +54 -0
  65. package/dist/camera/panModeGate.js +62 -0
  66. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  67. package/dist/camera/pickCaptureFormat.js +85 -0
  68. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  69. package/dist/camera/stitchDebugInfo.js +55 -0
  70. package/dist/camera/usePanMotion.d.ts +250 -0
  71. package/dist/camera/usePanMotion.js +451 -0
  72. package/dist/index.d.ts +24 -3
  73. package/dist/index.js +33 -2
  74. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  75. package/dist/stitching/computeInscribedRect.js +55 -0
  76. package/dist/stitching/cropQuad.d.ts +78 -0
  77. package/dist/stitching/cropQuad.js +116 -0
  78. package/dist/stitching/incremental.d.ts +74 -0
  79. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  80. package/dist/stitching/useIncrementalStitcher.js +7 -1
  81. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +154 -29
  82. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  83. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  84. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  85. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  86. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  87. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +211 -7
  88. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  89. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  90. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  91. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  92. package/package.json +5 -1
  93. package/src/camera/Camera.tsx +945 -47
  94. package/src/camera/CameraView.tsx +48 -16
  95. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  96. package/src/camera/CaptureFrameCounterOverlay.tsx +197 -0
  97. package/src/camera/CaptureMemoryPill.tsx +50 -12
  98. package/src/camera/CapturePreview.tsx +5 -0
  99. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  100. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  101. package/src/camera/LateralMotionModal.tsx +199 -0
  102. package/src/camera/PanHowToOverlay.tsx +246 -0
  103. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  104. package/src/camera/PanoramaSettings.ts +27 -7
  105. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  106. package/src/camera/RectCropPreview.tsx +638 -0
  107. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  108. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  109. package/src/camera/cameraErrorMessages.ts +39 -2
  110. package/src/camera/cameraGuidanceCopy.ts +145 -0
  111. package/src/camera/captureCountdown.ts +83 -0
  112. package/src/camera/captureWarnings.ts +190 -0
  113. package/src/camera/classifyStitchError.ts +68 -0
  114. package/src/camera/cropGeometry.ts +268 -0
  115. package/src/camera/displayDecodeImageProps.ts +25 -0
  116. package/src/camera/guidanceGraphics.tsx +347 -0
  117. package/src/camera/guidanceTokens.ts +57 -0
  118. package/src/camera/panModeGate.ts +81 -0
  119. package/src/camera/pickCaptureFormat.ts +130 -0
  120. package/src/camera/stitchDebugInfo.ts +71 -0
  121. package/src/camera/usePanMotion.ts +667 -0
  122. package/src/index.ts +66 -3
  123. package/src/stitching/computeInscribedRect.ts +81 -0
  124. package/src/stitching/cropQuad.ts +167 -0
  125. package/src/stitching/incremental.ts +74 -0
  126. package/src/stitching/useIncrementalStitcher.ts +13 -0
  127. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
  128. package/cpp/tests/CMakeLists.txt +0 -104
  129. package/cpp/tests/README.md +0 -86
  130. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  131. package/cpp/tests/pose_test.cpp +0 -74
  132. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  133. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  134. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  135. package/cpp/tests/warp_guard_test.cpp +0 -48
  136. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  137. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  138. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  139. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  140. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  141. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  142. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  143. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  144. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  145. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  146. 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
- // Read /proc/self/statm to get RSS in MB. Cheap (~20 µs). Used at
73
- // pipeline phase boundaries to correlate logged peak memory with the
74
- // staging-resolution + retry decisions. Returns -1 on read failure
75
- // (e.g., procfs not mounted never happens on iOS/Android but we
76
- // guard for portability).
77
- double rss_mb() {
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
- // 2026-05-22 (audit follow-up) — mode-fallback retry. When the
307
- // configured stitchMode produces degenerate camera params (the
308
- // "warpRoi too large" crash users hit on translation-heavy
309
- // captures stitched as PANORAMA, or low-texture inputs stitched
310
- // as SCANS), automatically retry once with the OPPOSITE mode
311
- // before giving up. Symmetric: PANORAMA-then-SCANS or
312
- // SCANS-then-PANORAMA depending on configured mode.
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
- // Why this is safe to enable unconditionally:
315
- // - The retry only fires on a failed attempt (no perf hit on
316
- // happy paths).
317
- // - Both modes share the load-images and write-output stages,
318
- // so the per-frame I/O cost isn't duplicated — only the
319
- // estimator/BA/warp middle is re-run.
320
- // - Result reflects whichever mode succeeded (returned via
321
- // StitchResult.stitchModeUsed, populated below).
322
- auto runOnce = [&](StitchMode modeOverride) -> StitchResult {
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.stitchModeUsed = config.stitchMode;
330
- return firstAttempt;
669
+ return finish(firstAttempt);
331
670
  }
332
- // First attempt failed. Try the opposite mode unless the error
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.stitchModeUsed = config.stitchMode;
343
- return firstAttempt;
679
+ return finish(firstAttempt);
344
680
  }
345
- StitchMode fallbackMode =
346
- (config.stitchMode == StitchMode::Panorama) ? StitchMode::Scans
347
- : StitchMode::Panorama;
348
- log_info(logFn, "[stitch-fallback]",
349
- "primary mode (%s) failed with code=%d msg=%s — retrying with %s",
350
- config.stitchMode == StitchMode::Scans ? "scans" : "panorama",
351
- static_cast<int>(firstAttempt.errorCode),
352
- firstAttempt.errorMessage.c_str(),
353
- fallbackMode == StitchMode::Scans ? "scans" : "panorama");
354
- StitchResult secondAttempt = runOnce(fallbackMode);
355
- if (secondAttempt.errorCode == StitchErrorCode::Ok) {
356
- secondAttempt.stitchModeUsed = fallbackMode;
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
- "fallback mode (%s) succeeded",
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
- return secondAttempt;
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
- // Both attempts failed. Return the FIRST attempt's error (it's
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
- if (config.useManualPipeline) {
388
- return stitchFramePathsManual(framePaths, outputPath, config, logFn);
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
- log_info(logFn, "[memstat]", "phase=entry rss=%.1f MB", rss_mb());
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
- log_info(logFn, "[memstat]", "phase=after_imread rss=%.1f MB", rss_mb());
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
- const double kHighLevelComposeFallbackMP = 1.0;
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
- log_info(logFn, "[memstat]", "phase=before_stitch rss=%.1f MB", rss_mb());
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
- status = stitcher->stitch(images, panorama);
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::stitch threw on attempt ") +
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::stitch failed at all " +
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
- log_info(logFn, "[memstat]", "phase=after_stitch rss=%.1f MB", rss_mb());
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
- log_info(logFn, "[memstat]", "phase=after_crop rss=%.1f MB", rss_mb());
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
- log_info(logFn, "[memstat]", "phase=after_bake_rotation rss=%.1f MB", rss_mb());
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
- log_info(logFn, "[memstat]", "phase=after_imwrite rss=%.1f MB", rss_mb());
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
- const double availableRamMB = (config.availableRamMB > 0.0)
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
- : (kAssumedTotalRAMGB * 1024.0);
786
- const double availableRamGB = availableRamMB / 1024.0;
787
- const double kPreStitchAbortMB = std::max(700.0, availableRamGB * 300.0);
788
- if (kStartResidentMB > kPreStitchAbortMB) {
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: mem=%.1fMB > %.1fMB threshold (totalRamMB=%.0f)",
791
- kStartResidentMB, kPreStitchAbortMB, availableRamMB);
792
- // V16 fix-attempt 9 — sentinel return. See validPairs<1 site
793
- // below for the full root-cause analysis. In the iOS original
794
- // this returned an empty RNStitchResult; here we return
795
- // a StitchResult with success=false + a stable error code so
796
- // both bridges see a clean failure rather than an
797
- // ambiguous "output written but zero pixels" surface.
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 = "Pre-stitch memory abort";
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
- // is N × frame size — for typical shelf captures (~2048×1536 RGB,
851
- // ~9 MB / frame raw, but cv::imread decodes JPEG so resident
852
- // footprint is bounded by the original sensor resolution).
1484
+ // Load all input frames before invoking the stitcher. Memory cost is
1485
+ // N × decoded frame size. NOTEkeyframe 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' warp diverges at frame %zu (>%lld MP "
1652
- "guard) -- falling back to cylindrical projection",
1653
- config.warperType.c_str(), divergeFrame,
1654
- (long long)(kMaxWarpPixels / 1000000));
1655
- if (auto cyl = make_warper("cylindrical")) {
1656
- warper = cyl->create(warpedScale);
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
- const bool useSeam = (config.seamFinderType == "graphcut");
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
- // V12.14.6 clone input to break any recycled-mmap
1743
- // link to prior captures' allocations. cv::Mat::clone
1744
- // forces a fresh memcpy into a freshly-allocated buffer.
1745
- cv::Mat freshInput = composeFrames[i].clone();
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
- freshInput.size(), K, cameras[i].R);
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
- // 2026-05-22 (audit follow-up) include
1780
- // stitchMode + frame index in the error message
1781
- // so the JS host can correlate the failure with
1782
- // operator behaviour. Pre-fix the error said
1783
- // nothing about which pipeline diverged. The
1784
- // value tells you: PANORAMA usually fails on
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(), freshInput.type());
2691
+ imagesWarped[i].create(roi.size(), composeFrames[i].type());
1804
2692
  masksWarped[i].create(roi.size(), CV_8U);
1805
2693
 
1806
- cv::Mat mask(freshInput.size(), CV_8U, cv::Scalar(255));
2694
+ cv::Mat mask(composeFrames[i].size(), CV_8U, cv::Scalar(255));
1807
2695
  corners[i] = warper->warp(
1808
- freshInput, K, cameras[i].R, cv::INTER_LINEAR,
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
- "(compose→seam aspect = %.3f, t+%.0fms)",
1885
- seam_compose_aspect, _ms);
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].copyTo(compImgs[i]);
1971
- masksWarped[i].copyTo(compMasks[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
- // Feed the blender, releasing each frame as we go.
1977
- log_info(logFn, "[stitch-bc]", "step10a: blender->prepare");
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