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.
- package/CHANGELOG.md +147 -1
- package/README.md +116 -5
- package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
- package/cpp/crop_quad.cpp +162 -0
- package/cpp/crop_quad.hpp +163 -0
- package/cpp/stitcher.cpp +651 -55
- package/cpp/stitcher.hpp +10 -0
- package/cpp/warp_guard.hpp +212 -0
- package/dist/camera/Camera.d.ts +196 -12
- package/dist/camera/Camera.js +629 -35
- package/dist/camera/CameraView.js +62 -5
- package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
- package/dist/camera/CaptureCountdownOverlay.js +239 -0
- package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
- package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
- package/dist/camera/CaptureMemoryPill.d.ts +9 -1
- package/dist/camera/CaptureMemoryPill.js +3 -3
- package/dist/camera/CapturePreview.js +2 -1
- package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
- package/dist/camera/CaptureStatusOverlay.js +22 -5
- package/dist/camera/CaptureThumbnailStrip.js +2 -1
- package/dist/camera/LateralMotionModal.d.ts +85 -0
- package/dist/camera/LateralMotionModal.js +134 -0
- package/dist/camera/PanHowToOverlay.d.ts +76 -0
- package/dist/camera/PanHowToOverlay.js +222 -0
- package/dist/camera/PanoramaSettings.d.ts +8 -6
- package/dist/camera/PanoramaSettings.js +26 -5
- package/dist/camera/PanoramaSettingsModal.js +4 -4
- package/dist/camera/RectCropPreview.d.ts +161 -0
- package/dist/camera/RectCropPreview.js +480 -0
- package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
- package/dist/camera/RotateToLandscapePrompt.js +138 -0
- package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
- package/dist/camera/buildPanoramaInitialSettings.js +9 -0
- package/dist/camera/cameraErrorMessages.d.ts +30 -1
- package/dist/camera/cameraErrorMessages.js +26 -10
- package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
- package/dist/camera/cameraGuidanceCopy.js +80 -0
- package/dist/camera/captureCountdown.d.ts +52 -0
- package/dist/camera/captureCountdown.js +76 -0
- package/dist/camera/captureWarnings.d.ts +90 -0
- package/dist/camera/captureWarnings.js +108 -0
- package/dist/camera/classifyStitchError.d.ts +30 -0
- package/dist/camera/classifyStitchError.js +42 -0
- package/dist/camera/cropGeometry.d.ts +136 -0
- package/dist/camera/cropGeometry.js +223 -0
- package/dist/camera/displayDecodeImageProps.d.ts +25 -0
- package/dist/camera/displayDecodeImageProps.js +29 -0
- package/dist/camera/guidanceGraphics.d.ts +58 -0
- package/dist/camera/guidanceGraphics.js +280 -0
- package/dist/camera/guidanceTokens.d.ts +54 -0
- package/dist/camera/guidanceTokens.js +58 -0
- package/dist/camera/panModeGate.d.ts +54 -0
- package/dist/camera/panModeGate.js +62 -0
- package/dist/camera/pickCaptureFormat.d.ts +71 -0
- package/dist/camera/pickCaptureFormat.js +85 -0
- package/dist/camera/stitchDebugInfo.d.ts +27 -0
- package/dist/camera/stitchDebugInfo.js +55 -0
- package/dist/camera/usePanMotion.d.ts +250 -0
- package/dist/camera/usePanMotion.js +451 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +33 -2
- package/dist/stitching/computeInscribedRect.d.ts +40 -0
- package/dist/stitching/computeInscribedRect.js +55 -0
- package/dist/stitching/cropQuad.d.ts +78 -0
- package/dist/stitching/cropQuad.js +116 -0
- package/dist/stitching/incremental.d.ts +45 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
- package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
- package/package.json +5 -1
- package/src/camera/Camera.tsx +994 -47
- package/src/camera/CameraView.tsx +75 -5
- package/src/camera/CaptureCountdownOverlay.tsx +272 -0
- package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
- package/src/camera/CaptureMemoryPill.tsx +17 -3
- package/src/camera/CapturePreview.tsx +5 -0
- package/src/camera/CaptureStatusOverlay.tsx +35 -7
- package/src/camera/CaptureThumbnailStrip.tsx +4 -0
- package/src/camera/LateralMotionModal.tsx +199 -0
- package/src/camera/PanHowToOverlay.tsx +246 -0
- package/src/camera/PanoramaSettings.ts +34 -11
- package/src/camera/PanoramaSettingsModal.tsx +4 -4
- package/src/camera/RectCropPreview.tsx +820 -0
- package/src/camera/RotateToLandscapePrompt.tsx +188 -0
- package/src/camera/buildPanoramaInitialSettings.ts +30 -1
- package/src/camera/cameraErrorMessages.ts +39 -2
- package/src/camera/cameraGuidanceCopy.ts +145 -0
- package/src/camera/captureCountdown.ts +83 -0
- package/src/camera/captureWarnings.ts +190 -0
- package/src/camera/classifyStitchError.ts +68 -0
- package/src/camera/cropGeometry.ts +268 -0
- package/src/camera/displayDecodeImageProps.ts +25 -0
- package/src/camera/guidanceGraphics.tsx +347 -0
- package/src/camera/guidanceTokens.ts +57 -0
- package/src/camera/panModeGate.ts +81 -0
- package/src/camera/pickCaptureFormat.ts +130 -0
- package/src/camera/stitchDebugInfo.ts +71 -0
- package/src/camera/usePanMotion.ts +667 -0
- package/src/index.ts +66 -3
- package/src/stitching/computeInscribedRect.ts +81 -0
- package/src/stitching/cropQuad.ts +167 -0
- package/src/stitching/incremental.ts +45 -0
- package/cpp/tests/CMakeLists.txt +0 -104
- package/cpp/tests/README.md +0 -86
- package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
- package/cpp/tests/pose_test.cpp +0 -74
- package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
- package/cpp/tests/stubs/jsi/jsi.h +0 -33
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
- package/cpp/tests/warp_guard_test.cpp +0 -48
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
- package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
- package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
- package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
- package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
- package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
- package/src/camera/__tests__/useContentRotation.test.ts +0 -89
- package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
- package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
package/cpp/stitcher.cpp
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
: (
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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:
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
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 =
|
|
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
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
1103
|
+
// Load all input frames before invoking the stitcher. Memory cost is
|
|
1104
|
+
// N × decoded frame size. NOTE — keyframe 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'
|
|
1652
|
-
"
|
|
1653
|
-
config.warperType.c_str(),
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
//
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
// translation-heavy input (homography + BA-Ray
|
|
1786
|
-
// assume pure rotation); SCANS usually fails on
|
|
1787
|
-
// low-texture or low-overlap input (affine needs
|
|
1788
|
-
// enough matches).
|
|
1789
|
-
const char* modeStr =
|
|
1790
|
-
(config.stitchMode == StitchMode::Scans) ? "scans" : "panorama";
|
|
1791
|
-
throw cv::Exception(
|
|
1792
|
-
cv::Error::StsOutOfRange,
|
|
1793
|
-
std::string("warpRoi too large (")
|
|
1794
|
-
+ std::to_string(roi.width) + "x"
|
|
1795
|
-
+ std::to_string(roi.height)
|
|
1796
|
-
+ ") — estimator produced degenerate "
|
|
1797
|
-
+ "camera params on this frame (stitchMode="
|
|
1798
|
-
+ modeStr + ", frameIdx="
|
|
1799
|
-
+ std::to_string(i) + ")",
|
|
1800
|
-
"stitchFramePathsManual",
|
|
1801
|
-
__FILE__, __LINE__);
|
|
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
|
-
"
|
|
1885
|
-
seam_compose_aspect,
|
|
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
|
-
//
|
|
1977
|
-
|
|
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
|