react-native-image-stitcher 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +147 -1
  2. package/README.md +116 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
  4. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
  6. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  8. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  9. package/cpp/crop_quad.cpp +162 -0
  10. package/cpp/crop_quad.hpp +163 -0
  11. package/cpp/stitcher.cpp +651 -55
  12. package/cpp/stitcher.hpp +10 -0
  13. package/cpp/warp_guard.hpp +212 -0
  14. package/dist/camera/Camera.d.ts +196 -12
  15. package/dist/camera/Camera.js +629 -35
  16. package/dist/camera/CameraView.js +62 -5
  17. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  18. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  19. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  20. package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
  21. package/dist/camera/CaptureMemoryPill.d.ts +9 -1
  22. package/dist/camera/CaptureMemoryPill.js +3 -3
  23. package/dist/camera/CapturePreview.js +2 -1
  24. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  25. package/dist/camera/CaptureStatusOverlay.js +22 -5
  26. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  27. package/dist/camera/LateralMotionModal.d.ts +85 -0
  28. package/dist/camera/LateralMotionModal.js +134 -0
  29. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  30. package/dist/camera/PanHowToOverlay.js +222 -0
  31. package/dist/camera/PanoramaSettings.d.ts +8 -6
  32. package/dist/camera/PanoramaSettings.js +26 -5
  33. package/dist/camera/PanoramaSettingsModal.js +4 -4
  34. package/dist/camera/RectCropPreview.d.ts +161 -0
  35. package/dist/camera/RectCropPreview.js +480 -0
  36. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  37. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  38. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  39. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  40. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  41. package/dist/camera/cameraErrorMessages.js +26 -10
  42. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  43. package/dist/camera/cameraGuidanceCopy.js +80 -0
  44. package/dist/camera/captureCountdown.d.ts +52 -0
  45. package/dist/camera/captureCountdown.js +76 -0
  46. package/dist/camera/captureWarnings.d.ts +90 -0
  47. package/dist/camera/captureWarnings.js +108 -0
  48. package/dist/camera/classifyStitchError.d.ts +30 -0
  49. package/dist/camera/classifyStitchError.js +42 -0
  50. package/dist/camera/cropGeometry.d.ts +136 -0
  51. package/dist/camera/cropGeometry.js +223 -0
  52. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  53. package/dist/camera/displayDecodeImageProps.js +29 -0
  54. package/dist/camera/guidanceGraphics.d.ts +58 -0
  55. package/dist/camera/guidanceGraphics.js +280 -0
  56. package/dist/camera/guidanceTokens.d.ts +54 -0
  57. package/dist/camera/guidanceTokens.js +58 -0
  58. package/dist/camera/panModeGate.d.ts +54 -0
  59. package/dist/camera/panModeGate.js +62 -0
  60. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  61. package/dist/camera/pickCaptureFormat.js +85 -0
  62. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  63. package/dist/camera/stitchDebugInfo.js +55 -0
  64. package/dist/camera/usePanMotion.d.ts +250 -0
  65. package/dist/camera/usePanMotion.js +451 -0
  66. package/dist/index.d.ts +24 -3
  67. package/dist/index.js +33 -2
  68. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  69. package/dist/stitching/computeInscribedRect.js +55 -0
  70. package/dist/stitching/cropQuad.d.ts +78 -0
  71. package/dist/stitching/cropQuad.js +116 -0
  72. package/dist/stitching/incremental.d.ts +45 -0
  73. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
  74. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  75. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  76. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  77. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
  78. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  79. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  80. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  81. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  82. package/package.json +5 -1
  83. package/src/camera/Camera.tsx +994 -47
  84. package/src/camera/CameraView.tsx +75 -5
  85. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  86. package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
  87. package/src/camera/CaptureMemoryPill.tsx +17 -3
  88. package/src/camera/CapturePreview.tsx +5 -0
  89. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  90. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  91. package/src/camera/LateralMotionModal.tsx +199 -0
  92. package/src/camera/PanHowToOverlay.tsx +246 -0
  93. package/src/camera/PanoramaSettings.ts +34 -11
  94. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  95. package/src/camera/RectCropPreview.tsx +820 -0
  96. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  97. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  98. package/src/camera/cameraErrorMessages.ts +39 -2
  99. package/src/camera/cameraGuidanceCopy.ts +145 -0
  100. package/src/camera/captureCountdown.ts +83 -0
  101. package/src/camera/captureWarnings.ts +190 -0
  102. package/src/camera/classifyStitchError.ts +68 -0
  103. package/src/camera/cropGeometry.ts +268 -0
  104. package/src/camera/displayDecodeImageProps.ts +25 -0
  105. package/src/camera/guidanceGraphics.tsx +347 -0
  106. package/src/camera/guidanceTokens.ts +57 -0
  107. package/src/camera/panModeGate.ts +81 -0
  108. package/src/camera/pickCaptureFormat.ts +130 -0
  109. package/src/camera/stitchDebugInfo.ts +71 -0
  110. package/src/camera/usePanMotion.ts +667 -0
  111. package/src/index.ts +66 -3
  112. package/src/stitching/computeInscribedRect.ts +81 -0
  113. package/src/stitching/cropQuad.ts +167 -0
  114. package/src/stitching/incremental.ts +45 -0
  115. package/cpp/tests/CMakeLists.txt +0 -104
  116. package/cpp/tests/README.md +0 -86
  117. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  118. package/cpp/tests/pose_test.cpp +0 -74
  119. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  120. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  121. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  122. package/cpp/tests/warp_guard_test.cpp +0 -48
  123. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  124. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  125. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  126. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  127. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  128. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  129. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  130. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  131. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  132. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  133. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
package/cpp/stitcher.cpp CHANGED
@@ -85,6 +85,23 @@ double rss_mb() {
85
85
  return (double) resident_pages * (double) page_bytes / (1024.0 * 1024.0);
86
86
  }
87
87
 
88
+ // Total physical RAM in MB, read natively. The Android JNI bridge sets no
89
+ // availableRamMB, so without this a 6 GB device is mis-treated as the 4 GB
90
+ // fallback (and the step-7.7 canvas budget + pre-stitch abort under-size).
91
+ // _SC_PHYS_PAGES is TOTAL and stable across runs (unlike _SC_AVPHYS_PAGES,
92
+ // which is free RAM and varies). Returns -1.0 off Linux/Android (e.g. the
93
+ // macOS cpp-test host); the caller resolves the sentinel.
94
+ double device_total_ram_mb() {
95
+ #if defined(__linux__)
96
+ const long pages = sysconf(_SC_PHYS_PAGES);
97
+ const long page_bytes = sysconf(_SC_PAGESIZE);
98
+ if (pages <= 0 || page_bytes <= 0) return -1.0;
99
+ return (double) pages * (double) page_bytes / (1024.0 * 1024.0);
100
+ #else
101
+ return -1.0;
102
+ #endif
103
+ }
104
+
88
105
  double mat_mb(const cv::Mat& m) {
89
106
  if (m.empty()) return 0.0;
90
107
  return (double)(m.total() * m.elemSize()) / (1024.0 * 1024.0);
@@ -229,6 +246,100 @@ cv::Rect maxInscribedRectFromMask(const cv::Mat& mask) {
229
246
  return bestRect;
230
247
  }
231
248
 
249
+ // Issue 3 — post-stitch output validator. The confidence filter drops
250
+ // frames that don't REGISTER, but nothing validated the final OUTPUT: a
251
+ // frame that survived confidence yet landed geometrically disconnected
252
+ // shows up as a separate blob in the coverage mask (the "disjointed image
253
+ // frames in the output" users reported). Run connected-components on the
254
+ // coverage mask; if a meaningful fraction of the covered area lies OUTSIDE
255
+ // the largest blob, reject the stitch as LowQualityStitch so the host can
256
+ // prompt a retry rather than ship a broken panorama.
257
+ //
258
+ // Conservative by design: a coherent panorama is ONE connected blob, so a
259
+ // good capture never trips; the threshold lives in the pure, unit-tested
260
+ // retailens::stitchOutputIsDisjoint. A small morphological close first
261
+ // bridges sub-pixel seam gaps so a single panorama isn't mis-split, while
262
+ // being far too small to merge a genuinely-detached floating frame.
263
+ //
264
+ // Fails OPEN: an empty/unreadable mask returns Ok (never block a capture on
265
+ // a mask we couldn't analyse).
266
+ StitchErrorCode validateStitchOutput(const cv::Mat& panorama,
267
+ const cv::Mat& coverage,
268
+ int numFrames,
269
+ const LogFn& logFn,
270
+ std::string& outMessage) {
271
+ if (panorama.empty()) return StitchErrorCode::Ok; // handled elsewhere
272
+ // Build a binary coverage mask (same posture as choose_crop_rect).
273
+ cv::Mat mask;
274
+ const bool haveCoverage =
275
+ (!coverage.empty() && coverage.size() == panorama.size());
276
+ if (haveCoverage) {
277
+ cv::Mat cov1 = coverage;
278
+ if (coverage.channels() != 1) {
279
+ cv::cvtColor(coverage, cov1, cv::COLOR_BGR2GRAY);
280
+ }
281
+ cv::threshold(cov1, mask, 0, 255, cv::THRESH_BINARY);
282
+ } else {
283
+ cv::Mat gray;
284
+ cv::cvtColor(panorama, gray, cv::COLOR_BGR2GRAY);
285
+ cv::threshold(gray, mask, 0, 255, cv::THRESH_BINARY);
286
+ }
287
+ if (mask.empty() || mask.type() != CV_8UC1) return StitchErrorCode::Ok;
288
+ // Bridge thin seam gaps so a coherent pano isn't mis-split; the 5 px
289
+ // kernel is far smaller than the gap a detached frame leaves, so
290
+ // genuinely-separate blobs are NOT merged.
291
+ cv::Mat closed;
292
+ cv::morphologyEx(
293
+ mask, closed, cv::MORPH_CLOSE,
294
+ cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(5, 5)));
295
+ cv::Mat labels, stats, centroids;
296
+ const int n = cv::connectedComponentsWithStats(
297
+ closed, labels, stats, centroids, 8, CV_32S);
298
+ double totalArea = 0.0, largestArea = 0.0;
299
+ for (int i = 1; i < n; i++) { // skip background label 0
300
+ const double a = stats.at<int>(i, cv::CC_STAT_AREA);
301
+ totalArea += a;
302
+ if (a > largestArea) largestArea = a;
303
+ }
304
+ const double fragmentFraction =
305
+ (totalArea > 0.0) ? (1.0 - largestArea / totalArea) : 0.0;
306
+ log_info(logFn, "[stitch-bc]",
307
+ "step11d: validate output components=%d largest=%.0f total=%.0f "
308
+ "fragment=%.3f frames=%d",
309
+ n - 1, largestArea, totalArea, fragmentFraction, numFrames);
310
+ if (retailens::stitchOutputIsDisjoint(largestArea, totalArea, numFrames)) {
311
+ char buf[176];
312
+ std::snprintf(buf, sizeof(buf),
313
+ "stitch validation failed: disjoint output (%d "
314
+ "components, %.0f%% of coverage outside the main frame)",
315
+ n - 1, fragmentFraction * 100.0);
316
+ outMessage = buf;
317
+ return StitchErrorCode::LowQualityStitch;
318
+ }
319
+ // Utilization guard — the "black canvas". A coherent blob marooned in a
320
+ // mostly-empty canvas passes the disjoint check above (one blob), so guard
321
+ // the coverage-to-canvas ratio too (mask is the full panorama size).
322
+ {
323
+ const double canvasArea = (double)mask.cols * (double)mask.rows;
324
+ if (retailens::stitchOutputUnderutilized(totalArea, canvasArea,
325
+ numFrames)) {
326
+ const double util = canvasArea > 0.0 ? totalArea / canvasArea : 0.0;
327
+ log_info(logFn, "[stitch-bc]",
328
+ "step11d: REJECT degenerate canvas — utilization=%.3f%% "
329
+ "(content %.0f / canvas %dx%d)",
330
+ util * 100.0, totalArea, mask.cols, mask.rows);
331
+ char buf[200];
332
+ std::snprintf(buf, sizeof(buf),
333
+ "stitch validation failed: degenerate canvas "
334
+ "(content fills %.2f%% of the %dx%d panorama)",
335
+ util * 100.0, mask.cols, mask.rows);
336
+ outMessage = buf;
337
+ return StitchErrorCode::LowQualityStitch;
338
+ }
339
+ }
340
+ return StitchErrorCode::Ok;
341
+ }
342
+
232
343
  // Pick the crop rectangle. Prefers the TRUE coverage mask from
233
344
  // cv::Stitcher::resultMask() (0xFF where a frame painted, 0 where
234
345
  // unfilled) so dark content is kept and only the never-covered wedges
@@ -297,6 +408,85 @@ static StitchResult stitchFramePathsImpl_(
297
408
  LogFn logFn);
298
409
 
299
410
 
411
+ // ─────────────────────────────────────────────────────────────────────
412
+ // Degenerate-warp guard helpers (shared by every throw site in the manual
413
+ // pipeline's warp/compose stage). Centralising them keeps the error
414
+ // MESSAGE consistent across the four sites — the JS host classifies a
415
+ // stitch failure by substring (see src/camera/classifyStitchError.ts /
416
+ // cameraErrorMessages.ts → STITCH_CAMERA_PARAMS_FAIL "Please pan more
417
+ // slowly"), so every degenerate-warp throw MUST carry "degenerate camera
418
+ // params" + the stitchMode. Both predicates live in cpp/warp_guard.hpp
419
+ // (OpenCV-free + unit-tested); these builders add only the message + the
420
+ // cv::Exception envelope.
421
+ // ─────────────────────────────────────────────────────────────────────
422
+
423
+ // Per-frame divergence: ONE warped frame's ROI exceeds kMaxWarpPixels
424
+ // (broken estimator/BA on degenerate input — low feature count, near-
425
+ // duplicate frames, motion-blurred rapid pan). stitchMode tells you which
426
+ // pipeline diverged: PANORAMA usually fails on translation-heavy input
427
+ // (homography + BA-Ray assume pure rotation); SCANS on low-texture / low-
428
+ // overlap input (affine needs enough matches).
429
+ static cv::Exception degenerateFrameException(
430
+ int width, int height, StitchMode mode, size_t frameIdx) {
431
+ const char* modeStr =
432
+ (mode == StitchMode::Scans) ? "scans" : "panorama";
433
+ return cv::Exception(
434
+ cv::Error::StsOutOfRange,
435
+ std::string("warpRoi too large (") + std::to_string(width) + "x"
436
+ + std::to_string(height)
437
+ + ") — estimator produced degenerate camera params on this frame "
438
+ + "(stitchMode=" + modeStr + ", frameIdx="
439
+ + std::to_string(frameIdx) + ")",
440
+ "stitchFramePathsManual", __FILE__, __LINE__);
441
+ }
442
+
443
+ // Cumulative-canvas divergence: every per-frame ROI passed, but the UNION
444
+ // bounding box that blender->prepare() allocates exceeds kMaxCanvasPixels
445
+ // (a degenerate corner OFFSET blows the union to gigapixels while each
446
+ // frame's own extent stays small). This is the real crash-B net.
447
+ static cv::Exception degenerateCanvasException(
448
+ int64_t width, int64_t height, StitchMode mode, size_t frames) {
449
+ const char* modeStr =
450
+ (mode == StitchMode::Scans) ? "scans" : "panorama";
451
+ return cv::Exception(
452
+ cv::Error::StsOutOfRange,
453
+ std::string("panorama canvas too large (") + std::to_string(width)
454
+ + "x" + std::to_string(height)
455
+ + ") — estimator produced degenerate camera params across the "
456
+ + "frame set (stitchMode=" + modeStr + ", frames="
457
+ + std::to_string(frames) + ")",
458
+ "stitchFramePathsManual", __FILE__, __LINE__);
459
+ }
460
+
461
+ // Bounding box over every positioned warp rect (corner + size) — exactly
462
+ // what cv::detail::Blender::prepare() allocates as its CV_16SC3 canvas.
463
+ // Computed in int64 so a degenerate corner offset (which can exceed the
464
+ // int32 range on its own) doesn't overflow before canvasExceedsGuard()
465
+ // gets to inspect it. Yields 0×0 for an empty frame set.
466
+ static void blendCanvasUnion(const std::vector<cv::Point>& corners,
467
+ const std::vector<cv::Size>& sizes,
468
+ int64_t& unionW, int64_t& unionH) {
469
+ if (corners.empty()) { unionW = 0; unionH = 0; return; }
470
+ // Seed from frame 0 (avoids any sentinel / <climits> dependency).
471
+ int64_t minX = corners[0].x;
472
+ int64_t minY = corners[0].y;
473
+ int64_t maxX = static_cast<int64_t>(corners[0].x) + sizes[0].width;
474
+ int64_t maxY = static_cast<int64_t>(corners[0].y) + sizes[0].height;
475
+ for (size_t i = 1; i < corners.size(); i++) {
476
+ const int64_t x0 = corners[i].x;
477
+ const int64_t y0 = corners[i].y;
478
+ const int64_t x1 = x0 + sizes[i].width;
479
+ const int64_t y1 = y0 + sizes[i].height;
480
+ if (x0 < minX) minX = x0;
481
+ if (y0 < minY) minY = y0;
482
+ if (x1 > maxX) maxX = x1;
483
+ if (y1 > maxY) maxY = y1;
484
+ }
485
+ unionW = maxX - minX;
486
+ unionH = maxY - minY;
487
+ }
488
+
489
+
300
490
  StitchResult stitchFramePaths(
301
491
  const std::vector<std::string>& framePaths,
302
492
  const std::string& outputPath,
@@ -322,6 +512,16 @@ StitchResult stitchFramePaths(
322
512
  auto runOnce = [&](StitchMode modeOverride) -> StitchResult {
323
513
  StitchConfig cfg = config;
324
514
  cfg.stitchMode = modeOverride;
515
+ // SCANS needs the COHERENT affine pipeline (AffineBestOf2NearestMatcher
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
+ }
325
525
  return stitchFramePathsImpl_(framePaths, outputPath, cfg, logFn);
326
526
  };
327
527
  StitchResult firstAttempt = runOnce(config.stitchMode);
@@ -385,7 +585,29 @@ static StitchResult stitchFramePathsImpl_(
385
585
  // call-site signature identical so existing bridges (iOS Obj-C++,
386
586
  // Android JNI) don't need to know which path runs internally.
387
587
  if (config.useManualPipeline) {
388
- return stitchFramePathsManual(framePaths, outputPath, config, logFn);
588
+ StitchResult r =
589
+ stitchFramePathsManual(framePaths, outputPath, config, logFn);
590
+ // 2026-06-15 — AUTO SPHERICAL FALLBACK. The manual pipeline defaults to
591
+ // the PLANE warper (flat, natural for narrow / 1x pans). Plane is
592
+ // unbounded, so a wide / off-axis pan can maroon content in a corner;
593
+ // validateStitchOutput rejects that as LowQualityStitch (the utilization
594
+ // / disjoint guard) BEFORE writing any file. Rather than fail, retry
595
+ // ONCE with the SPHERICAL warper, which bounds both axes — flat when
596
+ // plane works, bounded only when it doesn't. Skipped when the caller
597
+ // already asked for spherical, or the failure wasn't a quality rejection
598
+ // (OOM abort / read error won't be fixed by a different warper).
599
+ if (!r.success
600
+ && r.errorCode == StitchErrorCode::LowQualityStitch
601
+ && config.warperType != "spherical") {
602
+ log_info(logFn, "[stitch-bc]",
603
+ "manual '%s' marooned (LowQualityStitch) — retrying once "
604
+ "with spherical (bounded both axes)",
605
+ config.warperType.c_str());
606
+ StitchConfig sph = config;
607
+ sph.warperType = "spherical";
608
+ return stitchFramePathsManual(framePaths, outputPath, sph, logFn);
609
+ }
610
+ return r;
389
611
  }
390
612
 
391
613
  const auto t0 = std::chrono::steady_clock::now();
@@ -649,6 +871,12 @@ static StitchResult stitchFramePathsImpl_(
649
871
  result.finalConfidenceThresh = finalThreshold;
650
872
  result.durationMs = std::chrono::duration_cast<std::chrono::milliseconds>(
651
873
  t1 - t0).count();
874
+ // DEV overlay — cv::Stitcher owns its own compositing; for PANORAMA mode it
875
+ // uses GraphCut seams + MultiBand blend by default, with the configured
876
+ // warper. Surfaced on the preview in __DEV__. See StitchResult::debugSummary.
877
+ result.debugSummary =
878
+ std::string("pipe=highlevel;warp=") + config.warperType +
879
+ ";route=batch;seam=graphcut;blend=multiband";
652
880
  return result;
653
881
  }
654
882
 
@@ -780,23 +1008,49 @@ StitchResult stitchFramePathsManual(
780
1008
  // MB threshold (real-device headroom) rather than 1200 MB (legacy
781
1009
  // protection that caps a high-RAM device at low-RAM headroom).
782
1010
  const double kAssumedTotalRAMGB = 4.0;
783
- const double availableRamMB = (config.availableRamMB > 0.0)
1011
+ // Single source of truth for device RAM, shared by the pre-stitch abort
1012
+ // AND the step-7.7 canvas budget below. Prefer the caller's value (iOS
1013
+ // plumbs NSProcessInfo.physicalMemory); else read it natively (Android
1014
+ // sets none); else fall back to the conservative 4 GB assumption.
1015
+ double totalRamMB = (config.availableRamMB > 0.0)
784
1016
  ? 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) {
1017
+ : device_total_ram_mb();
1018
+ if (totalRamMB <= 0.0) totalRamMB = kAssumedTotalRAMGB * 1024.0;
1019
+
1020
+ // Issue 6 — headroom-scoped pre-stitch gate (replaces the old flat
1021
+ // `max(700, ram×300)` RSS ceiling).
1022
+ //
1023
+ // The old check compared whole-process RSS against a flat fraction of
1024
+ // DEVICE RAM, so a memory-heavy HOST app could trip it even when the
1025
+ // stitch itself was tiny — and on trip it HARD-ABORTED with no attempt
1026
+ // to route to the lighter STREAM path. We can't isolate the stitch's
1027
+ // own allocation from the shared process RSS (OpenCV uses malloc; no
1028
+ // per-library accounting), so instead of a device ceiling we reason
1029
+ // about HEADROOM: estimate the per-process kill ceiling
1030
+ // (perProcessMemoryBudgetMB) and abort here ONLY when the process is
1031
+ // already so close to it that even a MINIMAL streaming stitch
1032
+ // (kMinStreamStitchMB) won't fit on top of the current footprint. That
1033
+ // makes this a genuine last resort scoped to the stitch's minimal
1034
+ // incremental demand — a heavy host with headroom remaining proceeds,
1035
+ // and everything in between is handled downstream by the step-8 STREAM
1036
+ // routing (now also headroom-aware — see lowBatchHeadroom there) and the
1037
+ // step-7.7 canvas-budget downscale, which size to what the stitch needs
1038
+ // rather than aborting.
1039
+ const double perProcessBudgetMB =
1040
+ retailens::perProcessMemoryBudgetMB(totalRamMB);
1041
+ if (retailens::stitchExceedsMinimalHeadroom(kStartResidentMB, totalRamMB)) {
789
1042
  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.
1043
+ "PRE-STITCH ABORT: rss=%.1fMB + minStitch=%.0fMB > "
1044
+ "perProcessBudget=%.1fMB (totalRamMB=%.0f) — no headroom for "
1045
+ "even a minimal streaming stitch",
1046
+ kStartResidentMB, retailens::kMinStreamStitchMB,
1047
+ perProcessBudgetMB, totalRamMB);
1048
+ // Sentinel return: success=false + stable code so both bridges see a
1049
+ // clean failure. Classified to STITCH_OOM in JS (classifyStitchError
1050
+ // matches "memory abort").
798
1051
  result.errorCode = StitchErrorCode::PreStitchMemoryAbort;
799
- result.errorMessage = "Pre-stitch memory abort";
1052
+ result.errorMessage =
1053
+ "Pre-stitch memory abort: insufficient headroom for the stitch";
800
1054
  // framesIncluded reflects best-known retained count at the
801
1055
  // abort site — nothing has been loaded or matched yet.
802
1056
  result.framesIncluded = 0;
@@ -846,10 +1100,15 @@ StitchResult stitchFramePathsManual(
846
1100
  origCount, kMaxFramesForStitch);
847
1101
  }
848
1102
 
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).
1103
+ // Load all input frames before invoking the stitcher. Memory cost is
1104
+ // N × decoded frame size. NOTEkeyframe resolution is PLATFORM-SPLIT
1105
+ // (verified 2026-06): on Android the keyframe JPEGs are pre-clamped to
1106
+ // ~640px long edge at encode time (YuvImageConverter; the
1107
+ // AR_KEYFRAME_MAX_LONG_EDGE guard fires on dimensions, so it covers the
1108
+ // NON-AR path too), so the resident footprint here is ~0.3 MP/frame
1109
+ // regardless of the chosen capture format. On iOS the keyframes are
1110
+ // written at NATIVE capture resolution (OpenCVKeyframeCollector, no
1111
+ // clamp), so the footprint scales with the selected video format.
853
1112
  //
854
1113
  // V12.13 — breadcrumb each load. If the landscape-only crash is
855
1114
  // in cv::imread (e.g., decoding a JPEG produced by the new
@@ -1196,6 +1455,16 @@ StitchResult stitchFramePathsManual(
1196
1455
  if (config.stitchMode == StitchMode::Scans && thresh > 0.31f) {
1197
1456
  continue;
1198
1457
  }
1458
+ // Panorama: skip the 0.3 floor. leaveBiggestComponent is
1459
+ // monotonic in the threshold, so 0.3 only ever FORCES IN a weak
1460
+ // boundary frame that survived neither 1.0 nor 0.5 — exactly the
1461
+ // frame BundleAdjusterRay can't refine, which then mis-places under
1462
+ // the unbounded plane warp and marooned the content in a corner
1463
+ // ("black canvas"). Drop it and let that frame be pruned. Scans
1464
+ // keeps 0.3 (it floors there by design — see the >0.31 skip above).
1465
+ if (config.stitchMode != StitchMode::Scans && thresh < 0.4f) {
1466
+ continue;
1467
+ }
1199
1468
  // Restore from backups before each attempt — leaveBiggest-
1200
1469
  // Component mutated them last time. First attempt sees the
1201
1470
  // originals (backup == current), subsequent attempts get a
@@ -1631,6 +1900,12 @@ StitchResult stitchFramePathsManual(
1631
1900
  // blender / compose / crop) consumes the warper's OUTPUTS, so the
1632
1901
  // swap is transparent. If even cylindrical diverges, the in-loop
1633
1902
  // guard (step8b) still throws — the genuine-failure safety net.
1903
+ // The projection actually in use after the step7.6 fallback. The
1904
+ // fallback swaps `warper` but NOT warperCreator, so the step7.7 cap
1905
+ // below (which re-creates the warper at a smaller scale) must
1906
+ // re-create via THIS — otherwise it would silently revert
1907
+ // cylindrical→plane on exactly the wide pan the fallback rescued.
1908
+ std::string activeWarperType = config.warperType;
1634
1909
  if (config.warperType != "cylindrical" && !composeFrames.empty()) {
1635
1910
  bool wouldDiverge = false;
1636
1911
  size_t divergeFrame = 0;
@@ -1646,14 +1921,186 @@ StitchResult stitchFramePathsManual(
1646
1921
  break;
1647
1922
  }
1648
1923
  }
1924
+ // Issue 4 — quality-driven projection. PlaneWarper projects
1925
+ // ~tan(theta), so on a WIDE sweep the frames at the pan extremes
1926
+ // get visibly stretched/sheared — the "perspective at the ends"
1927
+ // users notice — even when the warp wouldn't OOM. Estimate the
1928
+ // total angular sweep from the bundle-adjusted camera optical
1929
+ // axes (first vs last frame); beyond kWidePanSweepDeg switch from
1930
+ // plane to the bounded cylindrical projection (~theta), which
1931
+ // keeps angular spacing uniform across the pan.
1932
+ //
1933
+ // The sweep angle (sweepDeg, below) is AXIS-AGNOSTIC — the 3D
1934
+ // angle between the first/last optical axes — so a Mode-A
1935
+ // landscape *vertical* pan trips this gate just as a horizontal
1936
+ // one does. cylindrical bounds only the HORIZONTAL angle; its
1937
+ // vertical axis is UNBOUNDED, so a vertical sweep's end frames
1938
+ // project to runaway coordinates and shear apart (fragmented
1939
+ // output — confirmed on-device 2026-06-14, a regression from
1940
+ // 6b11da0 vs the v0.6 plane baseline). Use SPHERICAL, which
1941
+ // bounds BOTH axes, so vertical AND horizontal wide pans stay
1942
+ // coherent. (Divergence guard + step-7.7 canvas budget cap are
1943
+ // unaffected — spherical is still a bounded projection.)
1944
+ constexpr double kWidePanSweepDeg = 45.0;
1945
+ const char* kWidePanWarper = "spherical";
1946
+ double sweepDeg = 0.0;
1947
+ if (cameras.size() >= 2) {
1948
+ auto opticalAxis = [](const cv::Mat& R) -> cv::Vec3d {
1949
+ cv::Mat Rd;
1950
+ R.convertTo(Rd, CV_64F);
1951
+ // Camera looks along +Z; world view dir = R·e_z = col 2.
1952
+ return cv::Vec3d(Rd.at<double>(0, 2),
1953
+ Rd.at<double>(1, 2),
1954
+ Rd.at<double>(2, 2));
1955
+ };
1956
+ const cv::Vec3d a0 = opticalAxis(cameras.front().R);
1957
+ const cv::Vec3d aN = opticalAxis(cameras.back().R);
1958
+ const double n0 = cv::norm(a0);
1959
+ const double nN = cv::norm(aN);
1960
+ if (n0 > 1e-9 && nN > 1e-9) {
1961
+ double c = a0.dot(aN) / (n0 * nN);
1962
+ c = std::max(-1.0, std::min(1.0, c));
1963
+ sweepDeg = std::acos(c) * 180.0 / CV_PI;
1964
+ }
1965
+ }
1966
+ const bool widePan = sweepDeg >= kWidePanSweepDeg;
1967
+
1968
+ // Only switch the warper when a frame's warp ACTUALLY blows past
1969
+ // the size guard (genuine divergence). The `widePan` sweep-angle
1970
+ // heuristic (>= kWidePanSweepDeg) was too aggressive — it fired on
1971
+ // a NORMAL moderate vertical Mode-A pan and switched away from the
1972
+ // PLANE warper that v0.6 used cleanly into the bounded-warper path,
1973
+ // which fragmented/doubled the output (confirmed on-device
1974
+ // 2026-06-14: stock cv::Stitcher AND v0.6 both stitch the exact
1975
+ // same frames cleanly on the default/plane warper; only the
1976
+ // post-v0.6 sweep-triggered switch broke it). Keep the divergence
1977
+ // guard (the real OOM/garbage protection) + spherical for that
1978
+ // case; `widePan` stays only for the diagnostic log below.
1649
1979
  if (wouldDiverge) {
1650
1980
  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);
1981
+ "step7.6: switching '%s' -> %s (diverge=%d wide=%d "
1982
+ "sweep=%.1fdeg, frame %zu) for a bounded projection",
1983
+ config.warperType.c_str(), kWidePanWarper,
1984
+ wouldDiverge ? 1 : 0, widePan ? 1 : 0, sweepDeg,
1985
+ divergeFrame);
1986
+ if (auto bounded = make_warper(kWidePanWarper)) {
1987
+ warper = bounded->create(warpedScale);
1988
+ activeWarperType = kWidePanWarper;
1989
+ }
1990
+ }
1991
+ }
1992
+
1993
+ // Post-cap projected canvas megapixels — drives the step-8 path
1994
+ // choice (wide canvases route to the low-memory STREAM+feather path).
1995
+ // Set inside step 7.7 below.
1996
+ double composeCanvasMpFinal = 0.0;
1997
+ // Post-cap BATCH held-set = Σ of every warped frame's area. This —
1998
+ // not the union — is the real driver of BATCH blend memory (N warped
1999
+ // frames + N exposure-comp UMat copies + MultiBand pyramids, all held
2000
+ // at once). A SMALL union with big/overlapping frames (e.g. the ~6×
2001
+ // higher-res AR keyframes) can still blow a huge held-set, so step 8's
2002
+ // STREAM route keys on this too, not just the union. Set in step 7.7.
2003
+ double composeHeldSetMpFinal = 0.0;
2004
+ // Step 7.7: RAM-aware output-canvas budget cap (wide-pan blend-OOM
2005
+ // fix). A VALID but wide pan produces a large UNION canvas, and the
2006
+ // BATCH + MultiBand blend peak scales with it (on a 6 GB A35 a
2007
+ // ~70 MP union hit ~2.97 GB RSS and was lmkd-killed mid-blend, never
2008
+ // reaching step11). Unlike the degenerate-warp guards (per-frame
2009
+ // 100 MP / cumulative 50 MP), this is a capture we want to COMPLETE,
2010
+ // not reject — so cap the canvas to a memory budget by reducing
2011
+ // compose scale, yielding a slightly-lower-res but complete pano.
2012
+ // warpRoi() here is corner-only/cheap (no pixel warp yet) and reuses
2013
+ // the EXACT union math blender->prepare() will allocate, so the probe
2014
+ // predicts the real canvas. No-op for normal panos: the budget floor
2015
+ // (12 MP) exceeds the widest valid 360° pano (~9 MP), so the 13
2016
+ // bounded captures see byte-identical behavior.
2017
+ if (!composeFrames.empty()) {
2018
+ std::vector<cv::Point> capCorners(composeFrames.size());
2019
+ std::vector<cv::Size> capSizes(composeFrames.size());
2020
+ bool capOk = true;
2021
+ for (size_t i = 0; i < composeFrames.size(); i++) {
2022
+ if (composeFrames[i].empty()) { capOk = false; break; }
2023
+ cv::Mat capK;
2024
+ cameras[i].K().convertTo(capK, CV_32F);
2025
+ const cv::Rect r = warper->warpRoi(
2026
+ composeFrames[i].size(), capK, cameras[i].R);
2027
+ capCorners[i] = r.tl();
2028
+ capSizes[i] = r.size();
2029
+ }
2030
+ if (capOk) {
2031
+ int64_t cw = 0, ch = 0;
2032
+ blendCanvasUnion(capCorners, capSizes, cw, ch);
2033
+ const double canvasMP = (double)cw * (double)ch / 1e6;
2034
+ const double budgetMP = composeCanvasBudgetMP(totalRamMB);
2035
+ const double downscale =
2036
+ canvasDownscaleForBudget(canvasMP, budgetMP);
2037
+ composeCanvasMpFinal = canvasMP * downscale * downscale;
2038
+ double heldSetMpRaw = 0.0;
2039
+ for (const auto& s : capSizes) {
2040
+ heldSetMpRaw += (double)s.width * (double)s.height / 1e6;
2041
+ }
2042
+ composeHeldSetMpFinal = heldSetMpRaw * downscale * downscale;
2043
+ // Always-on probe — confirms the RAM read (totalRamMB), the
2044
+ // budget, the active projection, and whether the cap fired.
2045
+ // Used to calibrate kBlendBytesPerUnionPx from real traces.
2046
+ log_info(logFn, "[stitch-bc]",
2047
+ "step7.7: canvas probe union=%lldx%lld (%.1f MP) "
2048
+ "budget=%.1f MP totalRamMB=%.0f warper=%s downscale=%.3f",
2049
+ (long long)cw, (long long)ch, canvasMP, budgetMP,
2050
+ totalRamMB, activeWarperType.c_str(), downscale);
2051
+ if (downscale < 1.0) {
2052
+ log_info(logFn, "[stitch-bc]",
2053
+ "step7.7: CAPPED downscale=%.3fx (canvasMP %.1f -> "
2054
+ "~%.1f, budget %.1f) — re-resizing composeFrames",
2055
+ downscale, canvasMP,
2056
+ canvasMP * downscale * downscale, budgetMP);
2057
+ // Co-scale EVERY quantity warpRoi depends on so the post-
2058
+ // cap canvas actually lands at ~budget: warpedScale,
2059
+ // compose_scale (read by the step9 seam aspect), and each
2060
+ // camera's intrinsics (focal/ppx/ppy — NOT R; mirrors the
2061
+ // step6 compose rescale; K() rebuilds on demand).
2062
+ warpedScale = (float)(warpedScale * downscale);
2063
+ compose_scale *= downscale;
2064
+ for (auto& cam : cameras) {
2065
+ cam.focal *= downscale;
2066
+ cam.ppx *= downscale;
2067
+ cam.ppy *= downscale;
2068
+ }
2069
+ // Re-resize composeFrames in place at the new scale.
2070
+ // INTER_LINEAR + pre-allocated dst + try/catch mirror the
2071
+ // step7c recycled-mmap SIGSEGV stability fix; on failure,
2072
+ // break out to the same failure handler step7c uses.
2073
+ try {
2074
+ for (size_t i = 0; i < composeFrames.size(); i++) {
2075
+ if (composeFrames[i].empty()) continue;
2076
+ const int nw = std::max(1,
2077
+ (int)std::round(composeFrames[i].cols * downscale));
2078
+ const int nh = std::max(1,
2079
+ (int)std::round(composeFrames[i].rows * downscale));
2080
+ cv::Mat resized(nh, nw, composeFrames[i].type());
2081
+ cv::resize(composeFrames[i], resized,
2082
+ resized.size(), 0, 0, cv::INTER_LINEAR);
2083
+ composeFrames[i] = resized;
2084
+ }
2085
+ } catch (const cv::Exception& e) {
2086
+ log_error(logFn, "[stitch-bc]",
2087
+ "step7.7: compose re-resize threw: %s", e.what());
2088
+ capturedErrorCode = StitchErrorCode::ComposeResizeFailed;
2089
+ capturedErrorMessage =
2090
+ std::string("Canvas-cap resize failed: ") + e.what();
2091
+ result.framesIncluded =
2092
+ static_cast<int32_t>(cameras.size());
2093
+ failedInsidePool = true;
2094
+ break;
2095
+ }
2096
+ // Re-create the warper at the new scale via the ACTIVE
2097
+ // projection (plane, or the step7.6 cylindrical fallback).
2098
+ if (auto w = make_warper(activeWarperType)) {
2099
+ warper = w->create(warpedScale);
2100
+ }
2101
+ log_info(logFn, "[stitch-bc]",
2102
+ "step7.7: cap applied new warpedScale=%.2f "
2103
+ "compose_scale=%.3f", warpedScale, compose_scale);
1657
2104
  }
1658
2105
  }
1659
2106
  }
@@ -1679,7 +2126,62 @@ StitchResult stitchFramePathsManual(
1679
2126
  // Both paths feed the SAME blender (selected per caller's
1680
2127
  // blenderType). Final blend happens after either path
1681
2128
  // completes.
1682
- const bool useSeam = (config.seamFinderType == "graphcut");
2129
+ // Wide-canvas low-memory routing. BATCH + MultiBand holds every
2130
+ // warped frame at once + N exposure-comp UMat copies + builds
2131
+ // Laplacian pyramids; on a 6 GB device a ~28 MP canvas peaked ~3 GB
2132
+ // in the blend/exposure stage and was lmkd-killed — even after the
2133
+ // step-9 cappedSeamAspect fix bounded the seam finder. Above
2134
+ // kLowMemCanvasMP, force the STREAM path (one warped frame at a time,
2135
+ // no held set, no exposure copies, no GraphCut) + the FEATHER blender
2136
+ // (single-pass, no pyramids) so a wide pan COMPLETES at full
2137
+ // resolution instead of OOMing. Below it, keep BATCH + MultiBand +
2138
+ // GraphCut for the crisp seams typical small-canvas captures get.
2139
+ // 2026-06-15 — RAM-gated STREAM-routing caps. On high-RAM devices
2140
+ // (≥5 GB physical → 6 GB+ nominal; Android sysconf reads a few hundred
2141
+ // MB under the marketing figure, so the gate is 5000 not 6000) raise the
2142
+ // caps so wide pans STAY on the sharp BATCH (GraphCut + MultiBand) path
2143
+ // instead of dropping to STREAM+feather (softer). Low-RAM devices keep
2144
+ // the conservative 10/15 MP thresholds. The lowHeadroom trigger below
2145
+ // still backstops ACTUAL memory pressure regardless of these static
2146
+ // caps, so raising them only lifts the pre-emptive ceiling, not the
2147
+ // safety net (a memory-pressured 6 GB device still routes to STREAM).
2148
+ const bool kHighRamDevice = totalRamMB >= 5000.0;
2149
+ const double kLowMemCanvasMP = kHighRamDevice ? 16.0 : 10.0;
2150
+ // Held-set guard: BATCH stayed safe at Σ-warped-area ≲13 MP but a
2151
+ // 6-frame AR pan with a 9.6 MP union (under kLowMemCanvasMP) yet a
2152
+ // ~32 MP held-set hit 3.6 GB and was lmkd-killed. Route to STREAM on
2153
+ // EITHER axis so a small-union/large-held-set capture (bigger or
2154
+ // heavily-overlapping frames, e.g. high-res AR keyframes) can't slip
2155
+ // into BATCH. 15 MP sits safely between the observed safe (≲13) and
2156
+ // fatal (~32) held-sets.
2157
+ const double kMaxBatchHeldSetMP = kHighRamDevice ? 22.0 : 15.0;
2158
+ // Issue 6 — headroom-aware routing. In addition to the fixed
2159
+ // canvas/held-set MP thresholds (which bound the stitch's OWN size),
2160
+ // route to STREAM when the process's CURRENT free headroom is thin —
2161
+ // i.e. whatever else is resident (host app, RN, residual buffers)
2162
+ // leaves little room for BATCH's multiband spike. This is the
2163
+ // "route, don't abort" half of Issue 6: under memory pressure we drop
2164
+ // to the lighter STREAM+feather path instead of risking an OOM (or a
2165
+ // hard pre-stitch abort). It only ever makes routing MORE
2166
+ // conservative, so it can't cause an OOM the fixed thresholds avoided.
2167
+ const double rssAtRouteMB = rss_mb();
2168
+ const bool lowHeadroom =
2169
+ retailens::lowBatchHeadroom(rssAtRouteMB, totalRamMB);
2170
+ const bool lowMemCanvas =
2171
+ composeCanvasMpFinal > kLowMemCanvasMP
2172
+ || composeHeldSetMpFinal > kMaxBatchHeldSetMP
2173
+ || lowHeadroom;
2174
+ const bool useSeam =
2175
+ (config.seamFinderType == "graphcut") && !lowMemCanvas;
2176
+ if (lowMemCanvas) {
2177
+ log_info(logFn, "[stitch-bc]",
2178
+ "step8: union=%.1f MP held-set=%.1f MP rss=%.0fMB "
2179
+ "budget=%.0fMB (union>%.1f or held>%.1f or lowHeadroom=%d)"
2180
+ " — routing to STREAM+feather",
2181
+ composeCanvasMpFinal, composeHeldSetMpFinal, rssAtRouteMB,
2182
+ perProcessBudgetMB, kLowMemCanvasMP, kMaxBatchHeldSetMP,
2183
+ lowHeadroom ? 1 : 0);
2184
+ }
1683
2185
  log_info(logFn, "[BatchStitcher]",
1684
2186
  "step8: %s",
1685
2187
  useSeam ? "BATCH (warp-all + seam + feed)"
@@ -1687,6 +2189,21 @@ StitchResult stitchFramePathsManual(
1687
2189
  log_info(logFn, "[stitch-bc]",
1688
2190
  "step8 enter: %s", useSeam ? "BATCH" : "STREAM");
1689
2191
 
2192
+ // DEV overlay (2026-06-14) — record the choices made for THIS output so
2193
+ // the preview can show them in __DEV__. `warp` is the configured warper
2194
+ // (a divergence-only switch to spherical is rare + logged separately);
2195
+ // route/seam/blend are the decisions just resolved above. See
2196
+ // StitchResult::debugSummary.
2197
+ {
2198
+ const bool useFeather =
2199
+ (config.blenderType == "feather") || lowMemCanvas;
2200
+ result.debugSummary =
2201
+ std::string("pipe=manual;warp=") + config.warperType +
2202
+ ";route=" + (useSeam ? "batch" : "stream") +
2203
+ ";seam=" + (useSeam ? "graphcut" : "none") +
2204
+ ";blend=" + (useFeather ? "feather" : "multiband");
2205
+ }
2206
+
1690
2207
  // Build the blender once — both paths feed into it.
1691
2208
  //
1692
2209
  // The "u != 0" UMat assertion we previously hit when running
@@ -1697,7 +2214,10 @@ StitchResult stitchFramePathsManual(
1697
2214
  // stitch, per-frame Mat releases, plus this stream path for
1698
2215
  // low-mem devices), both should run cleanly.
1699
2216
  cv::Ptr<cv::detail::Blender> blender;
1700
- if (config.blenderType == "feather") {
2217
+ if (config.blenderType == "feather" || lowMemCanvas) {
2218
+ // FEATHER for the wide-canvas low-memory path (lowMemCanvas) too —
2219
+ // MultiBand's pyramids are the dominant blend allocation we're
2220
+ // avoiding.
1701
2221
  blender = cv::detail::Blender::createDefault(
1702
2222
  cv::detail::Blender::FEATHER, false);
1703
2223
  auto fb = blender.dynamicCast<cv::detail::FeatherBlender>();
@@ -1776,29 +2296,12 @@ StitchResult stitchFramePathsManual(
1776
2296
  i, roi.width, roi.height,
1777
2297
  (long long)roiPixels,
1778
2298
  (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__);
2299
+ // Message + envelope built by the shared helper so
2300
+ // all four degenerate-warp throw sites stay in sync
2301
+ // (see degenerateFrameException above). Lands in the
2302
+ // step8b catch below WarpFailed.
2303
+ throw degenerateFrameException(
2304
+ roi.width, roi.height, config.stitchMode, i);
1802
2305
  }
1803
2306
  imagesWarped[i].create(roi.size(), freshInput.type());
1804
2307
  masksWarped[i].create(roi.size(), CV_8U);
@@ -1875,14 +2378,33 @@ StitchResult stitchFramePathsManual(
1875
2378
  // Aspect from compose scale → seam scale (the rescale we
1876
2379
  // apply to existing compose-scale data, not the original).
1877
2380
  double seam_compose_aspect = seam_scale / compose_scale;
2381
+ // BUGFIX (wide-pan GraphCut OOM): the aspect above is derived from
2382
+ // the INPUT frame size (origMp), but the resize below is applied to
2383
+ // the WARPED images, which span the whole canvas and can be many×
2384
+ // larger (a ~0.3 MP frame warps across a multi-MP canvas on a wide
2385
+ // pan). Left uncapped, GraphCut ran on multi-MP seam images and
2386
+ // its per-pixel max-flow graph exploded to GBs (a 19 MP-canvas
2387
+ // capture was lmkd-killed here — 3.16 GB RSS + 2.1 GB swap). Re-cap
2388
+ // against the LARGEST warped frame so every seam image is ≤ SEAM_MP,
2389
+ // which is what cv::Stitcher's seam_est_resol actually targets.
2390
+ double maxWarpedMp = 0.0;
2391
+ for (size_t i = 0; i < N; i++) {
2392
+ maxWarpedMp = std::max(
2393
+ maxWarpedMp,
2394
+ (double)sizes[i].width * (double)sizes[i].height / 1e6);
2395
+ }
2396
+ seam_compose_aspect =
2397
+ cappedSeamAspect(seam_compose_aspect, maxWarpedMp, SEAM_MP);
1878
2398
  {
1879
2399
  auto _t = std::chrono::steady_clock::now();
1880
2400
  double _ms = std::chrono::duration_cast<std::chrono::milliseconds>(
1881
2401
  _t - t0).count();
1882
2402
  log_info(logFn, "[BatchStitcher]",
1883
- "step9: graph-cut seam finder "
1884
- "(compose→seam aspect = %.3f, t+%.0fms)",
1885
- seam_compose_aspect, _ms);
2403
+ "step9: graph-cut seam finder (maxWarpedMP=%.1f "
2404
+ "compose→seam aspect=%.4f seamMP≈%.2f, t+%.0fms)",
2405
+ maxWarpedMp, seam_compose_aspect,
2406
+ maxWarpedMp * seam_compose_aspect * seam_compose_aspect,
2407
+ _ms);
1886
2408
  }
1887
2409
  auto _seamStart = std::chrono::steady_clock::now();
1888
2410
  log_info(logFn, "[stitch-bc]",
@@ -1973,9 +2495,34 @@ StitchResult stitchFramePathsManual(
1973
2495
  compensator->feed(corners, compImgs, compMasks);
1974
2496
  }
1975
2497
 
1976
- // Feed the blender, releasing each frame as we go.
1977
- log_info(logFn, "[stitch-bc]", "step10a: blender->prepare");
2498
+ // Layer-2 guard (cumulative canvas): the union of all positioned
2499
+ // warp rects is exactly what blender->prepare() allocates as its
2500
+ // CV_16SC3 accumulator. Every per-frame extent passed the
2501
+ // step8b guard above, but a single degenerate corner OFFSET can
2502
+ // still blow this union to gigapixels — the real crash-B path
2503
+ // (51 MB → 3.7 GB on one rapid pan). Guard BEFORE prepare().
2504
+ int64_t canvasW = 0, canvasH = 0;
2505
+ blendCanvasUnion(corners, sizes, canvasW, canvasH);
2506
+ if (canvasExceedsGuard(canvasW, canvasH)) {
2507
+ log_error(logFn, "[stitch-bc]",
2508
+ "step10a: blend canvas degenerate "
2509
+ "(%lldx%lld px) — treating as warp failure",
2510
+ (long long)canvasW, (long long)canvasH);
2511
+ throw degenerateCanvasException(
2512
+ canvasW, canvasH, config.stitchMode, N);
2513
+ }
2514
+ // Feed the blender, releasing each frame as we go. Log the union
2515
+ // + RSS: the union here MUST equal the step7.7 post-cap probe — a
2516
+ // mismatch means a co-scaled quantity was missed. step10a2
2517
+ // isolates the persistent MultiBand accumulator (~the term the
2518
+ // canvas budget bounds).
2519
+ log_info(logFn, "[stitch-bc]",
2520
+ "step10a: blender->prepare union=%lldx%lld (%.1f MP) mem=%.1fMB",
2521
+ (long long)canvasW, (long long)canvasH,
2522
+ (double)canvasW * (double)canvasH / 1e6, rss_mb());
1978
2523
  blender->prepare(corners, sizes);
2524
+ log_info(logFn, "[stitch-bc]",
2525
+ "step10a2: prepared mem=%.1fMB", rss_mb());
1979
2526
  log_info(logFn, "[stitch-bc]",
1980
2527
  "step10b: feeding blender (N=%zu)", N);
1981
2528
  for (size_t i = 0; i < N; i++) {
@@ -2005,6 +2552,20 @@ StitchResult stitchFramePathsManual(
2005
2552
  for (size_t i = 0; i < N; i++) {
2006
2553
  cv::Mat K;
2007
2554
  cameras[i].K().convertTo(K, CV_32F);
2555
+ // Layer-1 guard (STREAM): probe the cheap warpRoi BEFORE the
2556
+ // real mask warp below. Unlike BATCH, the STREAM path had no
2557
+ // per-frame net, so a degenerate ROI would OOM inside
2558
+ // warper->warp()'s buildMaps/remap allocation right here.
2559
+ const cv::Rect probe = warper->warpRoi(
2560
+ composeFrames[i].size(), K, cameras[i].R);
2561
+ if (warpRoiExceedsGuard(probe.width, probe.height)) {
2562
+ log_error(logFn, "[stitch-bc]",
2563
+ "step8b(stream): warpRoi degenerate for frame "
2564
+ "%zu (%dx%d) — treating as warp failure",
2565
+ i, probe.width, probe.height);
2566
+ throw degenerateFrameException(
2567
+ probe.width, probe.height, config.stitchMode, i);
2568
+ }
2008
2569
  cv::Mat mask(composeFrames[i].size(), CV_8U, cv::Scalar(255));
2009
2570
  cv::Mat tmpMaskWarped;
2010
2571
  corners[i] = warper->warp(
@@ -2018,6 +2579,20 @@ StitchResult stitchFramePathsManual(
2018
2579
  // ~40-50 MB lower peak vs the BATCH path at 1.0 MP × 8
2019
2580
  // frames — the difference between staying under iOS' jetsam
2020
2581
  // threshold on a 2 GB device and getting WatchdogTermination.
2582
+ // Layer-2 guard (cumulative canvas) — see the BATCH path for the
2583
+ // rationale. Same union check before the STREAM prepare().
2584
+ {
2585
+ int64_t canvasW = 0, canvasH = 0;
2586
+ blendCanvasUnion(corners, sizes, canvasW, canvasH);
2587
+ if (canvasExceedsGuard(canvasW, canvasH)) {
2588
+ log_error(logFn, "[stitch-bc]",
2589
+ "step10(stream): blend canvas degenerate "
2590
+ "(%lldx%lld px) — treating as warp failure",
2591
+ (long long)canvasW, (long long)canvasH);
2592
+ throw degenerateCanvasException(
2593
+ canvasW, canvasH, config.stitchMode, N);
2594
+ }
2595
+ }
2021
2596
  blender->prepare(corners, sizes);
2022
2597
  for (size_t i = 0; i < N; i++) {
2023
2598
  cv::Mat K;
@@ -2060,6 +2635,27 @@ StitchResult stitchFramePathsManual(
2060
2635
  "step11c: panorama 8U conversion done (panorama=%dx%d) mem=%.1fMB",
2061
2636
  panorama.cols, panorama.rows, rss_mb());
2062
2637
 
2638
+ // Issue 3 — post-stitch validation. Reject a disjoint / fragmented
2639
+ // output (frames that survived confidence but didn't fuse into one
2640
+ // panorama) so the host gets a clean failure (→ STITCH_LOW_QUALITY,
2641
+ // "try again") instead of a broken image. Fails open on an
2642
+ // unreadable mask. Capture the failure into the strong locals +
2643
+ // break out of the do/while(0) like the catch paths do.
2644
+ {
2645
+ std::string validateMessage;
2646
+ const StitchErrorCode validateCode = validateStitchOutput(
2647
+ panorama, panoramaMask,
2648
+ static_cast<int>(cameras.size()), logFn, validateMessage);
2649
+ if (validateCode != StitchErrorCode::Ok) {
2650
+ log_error(logFn, "[stitch-bc]",
2651
+ "step11d: REJECTED — %s", validateMessage.c_str());
2652
+ capturedErrorCode = validateCode;
2653
+ capturedErrorMessage = validateMessage;
2654
+ failedInsidePool = true;
2655
+ break;
2656
+ }
2657
+ }
2658
+
2063
2659
  // Record retained-frame count for telemetry. In the high-level
2064
2660
  // path this comes from stitcher->component().size() after retry;
2065
2661
  // in the manual path it's whatever leaveBiggestComponent kept