react-native-image-stitcher 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +25 -10
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +52 -7
  4. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +135 -59
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  7. package/cpp/keyframe_gate.cpp +54 -15
  8. package/cpp/keyframe_gate.hpp +33 -0
  9. package/cpp/stitcher.cpp +481 -87
  10. package/cpp/stitcher.hpp +52 -0
  11. package/dist/camera/Camera.d.ts +13 -0
  12. package/dist/camera/Camera.js +9 -64
  13. package/dist/camera/CaptureFrameCounterOverlay.js +12 -1
  14. package/dist/camera/CaptureMemoryPill.d.ts +15 -7
  15. package/dist/camera/CaptureMemoryPill.js +34 -9
  16. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  17. package/dist/camera/PanoramaBandOverlay.js +9 -3
  18. package/dist/camera/PanoramaSettings.js +22 -25
  19. package/dist/camera/RectCropPreview.d.ts +3 -29
  20. package/dist/camera/RectCropPreview.js +20 -130
  21. package/dist/stitching/incremental.d.ts +29 -0
  22. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  23. package/dist/stitching/useIncrementalStitcher.js +7 -1
  24. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +103 -26
  25. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  26. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  27. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +21 -1
  28. package/package.json +1 -1
  29. package/src/camera/Camera.tsx +21 -70
  30. package/src/camera/CaptureFrameCounterOverlay.tsx +15 -1
  31. package/src/camera/CaptureMemoryPill.tsx +33 -9
  32. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  33. package/src/camera/PanoramaSettings.ts +22 -25
  34. package/src/camera/RectCropPreview.tsx +38 -220
  35. package/src/stitching/incremental.ts +29 -0
  36. package/src/stitching/useIncrementalStitcher.ts +13 -0
  37. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
package/cpp/stitcher.cpp CHANGED
@@ -30,18 +30,24 @@
30
30
  #include <opencv2/stitching/warpers.hpp>
31
31
 
32
32
  #include <algorithm>
33
+ #include <atomic>
33
34
  #include <chrono>
34
35
  #include <cfloat>
35
36
  #include <cmath>
36
37
  #include <cstdarg>
37
38
  #include <cstdio>
38
39
  #include <cstring>
40
+ #include <functional>
39
41
  #include <string>
42
+ #include <thread>
40
43
  #include <unistd.h>
41
44
  #include <vector>
42
45
 
43
46
  #include "warp_guard.hpp"
44
47
 
48
+ // RNIS_MEMORY_PROFILING is defined in stitcher.hpp (shared across the 3 native
49
+ // translation units). kMemProfilingCompiled exposes it as a constexpr below.
50
+
45
51
 
46
52
  namespace retailens {
47
53
 
@@ -69,12 +75,21 @@ void log_error(const LogFn& logFn, const char* tag, const char* fmt, ...) {
69
75
  logFn(2, tag, buf);
70
76
  }
71
77
 
72
- // Read /proc/self/statm to get RSS in MB. Cheap (~20 µs). Used at
73
- // pipeline phase boundaries to correlate logged peak memory with the
74
- // staging-resolution + retry decisions. Returns -1 on read failure
75
- // (e.g., procfs not mounted never happens on iOS/Android but we
76
- // guard for portability).
77
- double rss_mb() {
78
+ // Compile-time gate as a constexpr (so dead branches fold away in release).
79
+ constexpr bool kMemProfilingCompiled = (RNIS_MEMORY_PROFILING != 0);
80
+
81
+ // Per-stitch resident-memory probe, installed for the duration of a stitch by
82
+ // MemProbeScope below. Lets rss_mb() (and therefore every OOM guard, phase log,
83
+ // the peak sampler and the per-stitch record) read the platform's real source
84
+ // without threading `config` through dozens of call sites. Calls are serialized
85
+ // (stitchFramePaths contract), and the sampler thread only reads it within the
86
+ // scope's lifetime, so a plain file-static is safe.
87
+ std::function<double()> g_memProbe;
88
+
89
+ // Read /proc/self/statm to get RSS in MB. Cheap (~20 µs). Returns -1 when
90
+ // procfs is absent — which is the case on iOS (no /proc), so this is the Android
91
+ // path; iOS supplies g_memProbe instead.
92
+ double rss_mb_proc() {
78
93
  FILE* f = std::fopen("/proc/self/statm", "r");
79
94
  if (f == nullptr) return -1.0;
80
95
  long size_pages = 0, resident_pages = 0;
@@ -85,6 +100,80 @@ double rss_mb() {
85
100
  return (double) resident_pages * (double) page_bytes / (1024.0 * 1024.0);
86
101
  }
87
102
 
103
+ // Effective resident-memory reader (MB). Prefers the installed probe (iOS
104
+ // phys_footprint), else /proc (Android). -1 when neither is available. Used at
105
+ // pipeline phase boundaries + by the OOM guards — making the guards work on iOS,
106
+ // where they previously got -1 (the runtime-pressure router was dead).
107
+ double rss_mb() {
108
+ if (g_memProbe) {
109
+ const double v = g_memProbe();
110
+ if (v >= 0.0) return v;
111
+ }
112
+ return rss_mb_proc();
113
+ }
114
+
115
+ // Which source rss_mb() is currently resolving to — for the per-stitch record.
116
+ const char* mem_source_label() {
117
+ if (g_memProbe && g_memProbe() >= 0.0) return "phys_footprint";
118
+ if (rss_mb_proc() >= 0.0) return "rss";
119
+ return "";
120
+ }
121
+
122
+ // RAII: install/uninstall the resident-memory probe for one stitch.
123
+ struct MemProbeScope {
124
+ explicit MemProbeScope(std::function<double()> probe) { g_memProbe = std::move(probe); }
125
+ ~MemProbeScope() { g_memProbe = nullptr; }
126
+ MemProbeScope(const MemProbeScope&) = delete;
127
+ MemProbeScope& operator=(const MemProbeScope&) = delete;
128
+ };
129
+
130
+ // DRY [memstat] phase log — gated, and (unlike the old inline form) skips the
131
+ // rss_mb() read entirely when profiling is off, so release pays nothing.
132
+ void log_memstat(const LogFn& logFn, bool enabled, const char* phase) {
133
+ if (!enabled || !logFn) return;
134
+ char buf[80];
135
+ std::snprintf(buf, sizeof(buf), "phase=%s rss=%.1f MB", phase, rss_mb());
136
+ logFn(0, "[memstat]", buf);
137
+ }
138
+
139
+ // Background peak-memory sampler. Wakes every ~50 ms during a stitch and tracks
140
+ // the max resident memory into `peak` — the ONLY way to catch the transient
141
+ // warp-all + GraphCut + MultiBand spike, which the phase-boundary reads (taken
142
+ // after the blender frees its pyramids) systematically miss. RAII: the thread
143
+ // starts in the ctor (when active) and is joined in stop()/dtor. It is a pure
144
+ // memory reader, not OpenCV work, so cv::setNumThreads(1) does not constrain it.
145
+ class PeakSampler {
146
+ public:
147
+ PeakSampler(bool active, std::atomic<double>& peak) : peak_(peak), active_(active) {
148
+ if (!active_) return;
149
+ thread_ = std::thread([this]() {
150
+ while (!stop_.load(std::memory_order_relaxed)) {
151
+ const double v = rss_mb();
152
+ double cur = peak_.load(std::memory_order_relaxed);
153
+ while (v > cur &&
154
+ !peak_.compare_exchange_weak(cur, v, std::memory_order_relaxed)) {}
155
+ // Sleep in 10 ms slices so stop() is responsive (~50 ms cadence).
156
+ for (int i = 0; i < 5 && !stop_.load(std::memory_order_relaxed); ++i)
157
+ std::this_thread::sleep_for(std::chrono::milliseconds(10));
158
+ }
159
+ });
160
+ }
161
+ void stop() {
162
+ if (!active_) return;
163
+ stop_.store(true, std::memory_order_relaxed);
164
+ if (thread_.joinable()) thread_.join();
165
+ active_ = false;
166
+ }
167
+ ~PeakSampler() { stop(); }
168
+ PeakSampler(const PeakSampler&) = delete;
169
+ PeakSampler& operator=(const PeakSampler&) = delete;
170
+ private:
171
+ std::atomic<double>& peak_;
172
+ std::atomic<bool> stop_{false};
173
+ std::thread thread_;
174
+ bool active_;
175
+ };
176
+
88
177
  // Total physical RAM in MB, read natively. The Android JNI bridge sets no
89
178
  // availableRamMB, so without this a 6 GB device is mis-treated as the 4 GB
90
179
  // fallback (and the step-7.7 canvas budget + pre-stitch abort under-size).
@@ -493,81 +582,139 @@ StitchResult stitchFramePaths(
493
582
  const StitchConfig& config,
494
583
  LogFn logFn)
495
584
  {
496
- // 2026-05-22 (audit follow-up) — mode-fallback retry. When the
497
- // configured stitchMode produces degenerate camera params (the
498
- // "warpRoi too large" crash users hit on translation-heavy
499
- // captures stitched as PANORAMA, or low-texture inputs stitched
500
- // as SCANS), automatically retry once with the OPPOSITE mode
501
- // before giving up. Symmetric: PANORAMA-then-SCANS or
502
- // SCANS-then-PANORAMA depending on configured mode.
585
+ // ── v0.16.1 — native-heap leak fix (one-time) — ANDROID ONLY ────────
586
+ // The ~7-9 MB/stitch LIVE native-heap creep is OpenCV core's OWN pooled
587
+ // scratch TBB per-worker TLS re-primed as the calling thread migrates
588
+ // across the Kotlin Dispatchers.Default pool. It is NOT app memory (every
589
+ // cv::Mat below is RAII / explicitly .release()'d) and NOT reclaimable by
590
+ // mallopt(M_PURGE) (those pools aren't serviced by bionic malloc).
591
+ // setNumThreads(1) removes the TBB worker pool so no per-worker scratch can
592
+ // accumulate. Stitches are serialized and the keyframes are small, so the
593
+ // single-threaded cost is minor. C++11 static-init is thread-safe; runs once.
594
+ //
595
+ // 2026-06-16 (audit) — ANDROID-GATED. The prebuilt iOS opencv2.xcframework
596
+ // uses the GCD parallel backend (NOT TBB — getBuildInformation reads
597
+ // "Parallel framework: GCD"), so there is no TBB pool to remove; pinning to
598
+ // 1 thread there is pure cost (serialized ORB/matcher/warp/blend + a
599
+ // single-core per-frame KeyframeGate) for ZERO memory benefit. Recovering
600
+ // iOS's multi-core path. setUseIPP(false) was dropped entirely — IPP is
601
+ // x86-only, a no-op on arm64 (both Android NDK and iOS).
602
+ #if defined(__ANDROID__)
603
+ static const bool s_cvTuned = []() {
604
+ cv::setNumThreads(1);
605
+ return true;
606
+ }();
607
+ (void)s_cvTuned;
608
+ #endif
609
+
610
+ // ── 2026-06-16 — per-stitch memory profiling (DEV; gated) ───────────
611
+ // Install the resident-memory probe (iOS phys_footprint; null on Android →
612
+ // /proc) for the whole stitch — including the retry + the high-level/manual
613
+ // impls below — so the OOM guards, phase logs, peak sampler + record all read
614
+ // the right source. The sampler runs across both attempts (peak = the worse
615
+ // of the two, which is the conservative OOM number). `finish()` stops the
616
+ // sampler + stamps the record onto whichever result we return.
617
+ const bool memProfiling = kMemProfilingCompiled && config.enableMemoryProfiling;
618
+ MemProbeScope memProbe(config.memProbeFn);
619
+ const std::string memSource =
620
+ memProfiling ? std::string(mem_source_label()) : std::string();
621
+ const double memBefore = memProfiling ? rss_mb() : -1.0;
622
+ std::atomic<double> peakMB{ memBefore };
623
+ PeakSampler sampler(memProfiling, peakMB);
624
+ auto finish = [&](StitchResult r) -> StitchResult {
625
+ sampler.stop();
626
+ if (memProfiling) {
627
+ const double memAfter = rss_mb();
628
+ r.memBeforeMB = memBefore;
629
+ r.memAfterMB = memAfter;
630
+ r.memPeakMB = std::max(peakMB.load(), std::max(memBefore, memAfter));
631
+ r.memSource = memSource;
632
+ // memFloorMB stays -1; the platform bridge fills it after its
633
+ // post-stitch reclaim (Android mallopt(M_PURGE) / iOS settle read).
634
+ char mbuf[96];
635
+ std::snprintf(mbuf, sizeof(mbuf),
636
+ "memBefore=%.1f;memPeak=%.1f;memAfter=%.1f;memSrc=%s",
637
+ r.memBeforeMB, r.memPeakMB, r.memAfterMB, memSource.c_str());
638
+ if (!r.debugSummary.empty()) r.debugSummary += ";";
639
+ r.debugSummary += mbuf;
640
+ }
641
+ return r;
642
+ };
643
+
644
+ // 2026-06-16 — TWO-BRANCH fallback retry (only fires on a retryable failure;
645
+ // no happy-path cost; each attempt is a full independent run — memory does
646
+ // NOT stack, RAII frees between attempts):
503
647
  //
504
- // Why this is safe to enable unconditionally:
505
- // - The retry only fires on a failed attempt (no perf hit on
506
- // happy paths).
507
- // - Both modes share the load-images and write-output stages,
508
- // so the per-frame I/O cost isn't duplicated only the
509
- // estimator/BA/warp middle is re-run.
510
- // - Result reflects whichever mode succeeded (returned via
511
- // StitchResult.stitchModeUsed, populated below).
512
- auto runOnce = [&](StitchMode modeOverride) -> StitchResult {
648
+ // HIGH-LEVEL caller (Android default, useManualPipeline=false): retry once
649
+ // with the SPHERICAL warper bounds the canvas on both axes (lower peak
650
+ // ~16% measured + rescues a marooned plane/cylindrical warp).
651
+ // MANUAL caller (iOS pre-Phase-2, useManualPipeline=true): the OLD
652
+ // PANORAMA↔SCANS mode-fallback is PRESERVED so iOS does not regress until
653
+ // its bridge is flipped to high-level (review #3/#4). The dispatcher
654
+ // guard routes SCANS→high-level affine; PANORAMA→manual (+ its own
655
+ // plane→spherical self-rescue).
656
+ //
657
+ // PreStitchMemoryAbort is excluded from worthRetrying (a headroom abort is
658
+ // independent of warper/mode — retrying won't help).
659
+ auto runOnce = [&](StitchMode modeOverride,
660
+ const std::string& warperOverride) -> StitchResult {
513
661
  StitchConfig cfg = config;
514
662
  cfg.stitchMode = modeOverride;
515
- // 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
- }
663
+ if (!warperOverride.empty()) cfg.warperType = warperOverride;
525
664
  return stitchFramePathsImpl_(framePaths, outputPath, cfg, logFn);
526
665
  };
527
- StitchResult firstAttempt = runOnce(config.stitchMode);
666
+ StitchResult firstAttempt = runOnce(config.stitchMode, std::string());
667
+ firstAttempt.stitchModeUsed = config.stitchMode;
528
668
  if (firstAttempt.errorCode == StitchErrorCode::Ok) {
529
- firstAttempt.stitchModeUsed = config.stitchMode;
530
- return firstAttempt;
669
+ return finish(firstAttempt);
531
670
  }
532
- // First attempt failed. Try the opposite mode unless the error
533
- // is something the opposite mode wouldn't fix (e.g. invalid
534
- // argument count, file-read failure, OOM).
535
- bool worthRetrying =
671
+ const bool worthRetrying =
536
672
  firstAttempt.errorCode == StitchErrorCode::UnknownCvException
537
673
  || firstAttempt.errorCode == StitchErrorCode::HomographyEstimationFailed
538
674
  || firstAttempt.errorCode == StitchErrorCode::CameraParamsAdjustFailed
539
675
  || firstAttempt.errorCode == StitchErrorCode::WarpFailed
540
- || firstAttempt.errorCode == StitchErrorCode::EmptyPanorama;
676
+ || firstAttempt.errorCode == StitchErrorCode::EmptyPanorama
677
+ || firstAttempt.errorCode == StitchErrorCode::LowQualityStitch;
541
678
  if (!worthRetrying) {
542
- firstAttempt.stitchModeUsed = config.stitchMode;
543
- return firstAttempt;
679
+ return finish(firstAttempt);
544
680
  }
545
- StitchMode fallbackMode =
546
- (config.stitchMode == StitchMode::Panorama) ? StitchMode::Scans
547
- : StitchMode::Panorama;
548
- log_info(logFn, "[stitch-fallback]",
549
- "primary mode (%s) failed with code=%d msg=%s — retrying with %s",
550
- config.stitchMode == StitchMode::Scans ? "scans" : "panorama",
551
- static_cast<int>(firstAttempt.errorCode),
552
- firstAttempt.errorMessage.c_str(),
553
- fallbackMode == StitchMode::Scans ? "scans" : "panorama");
554
- StitchResult secondAttempt = runOnce(fallbackMode);
555
- if (secondAttempt.errorCode == StitchErrorCode::Ok) {
556
- secondAttempt.stitchModeUsed = fallbackMode;
681
+ // HIGH-LEVEL: spherical warper rescue.
682
+ if (!config.useManualPipeline
683
+ && config.stitchMode == StitchMode::Panorama
684
+ && config.warperType != "spherical") {
685
+ log_info(logFn, "[stitch-fallback]",
686
+ "high-level warper (%s) failed code=%d (%s) — retrying spherical",
687
+ config.warperType.c_str(),
688
+ static_cast<int>(firstAttempt.errorCode),
689
+ firstAttempt.errorMessage.c_str());
690
+ StitchResult sph = runOnce(config.stitchMode, "spherical");
691
+ sph.stitchModeUsed = config.stitchMode;
692
+ if (sph.errorCode == StitchErrorCode::Ok) {
693
+ log_info(logFn, "[stitch-fallback]", "spherical rescue succeeded");
694
+ return finish(sph);
695
+ }
696
+ log_info(logFn, "[stitch-fallback]",
697
+ "spherical rescue also failed code=%d — returning primary error",
698
+ static_cast<int>(sph.errorCode));
699
+ return finish(firstAttempt);
700
+ }
701
+ // MANUAL: preserved opposite-mode fallback (iOS no-regression).
702
+ if (config.useManualPipeline) {
703
+ const StitchMode fallbackMode =
704
+ (config.stitchMode == StitchMode::Panorama) ? StitchMode::Scans
705
+ : StitchMode::Panorama;
557
706
  log_info(logFn, "[stitch-fallback]",
558
- "fallback mode (%s) succeeded",
707
+ "manual primary mode (%s) failed code=%d — retrying %s",
708
+ config.stitchMode == StitchMode::Scans ? "scans" : "panorama",
709
+ static_cast<int>(firstAttempt.errorCode),
559
710
  fallbackMode == StitchMode::Scans ? "scans" : "panorama");
560
- return secondAttempt;
711
+ StitchResult secondAttempt = runOnce(fallbackMode, std::string());
712
+ if (secondAttempt.errorCode == StitchErrorCode::Ok) {
713
+ secondAttempt.stitchModeUsed = fallbackMode;
714
+ return finish(secondAttempt);
715
+ }
561
716
  }
562
- // Both attempts failed. Return the FIRST attempt's error (it's
563
- // what the operator's chosen mode produced — more useful for
564
- // diagnosis than the fallback's failure).
565
- log_info(logFn, "[stitch-fallback]",
566
- "fallback mode (%s) also failed with code=%d — returning primary error",
567
- fallbackMode == StitchMode::Scans ? "scans" : "panorama",
568
- static_cast<int>(secondAttempt.errorCode));
569
- firstAttempt.stitchModeUsed = config.stitchMode;
570
- return firstAttempt;
717
+ return finish(firstAttempt);
571
718
  }
572
719
 
573
720
  // 2026-05-22 (audit follow-up) — renamed inner entry point so the
@@ -584,7 +731,14 @@ static StitchResult stitchFramePathsImpl_(
584
731
  // useManualPipeline for the tradeoffs. Routing here keeps the
585
732
  // call-site signature identical so existing bridges (iOS Obj-C++,
586
733
  // Android JNI) don't need to know which path runs internally.
587
- if (config.useManualPipeline) {
734
+ //
735
+ // 2026-06-16 — SCANS always uses the high-level pipeline: the manual path is
736
+ // homography-only (no affine matcher/estimator/warper), so SCANS+manual
737
+ // would silently run a homography stitch. The old mode-fallback enforced
738
+ // this in runOnce; now that runOnce is warper-only, enforce it here so a
739
+ // not-yet-migrated caller passing stitchMode=scans + useManualPipeline=true
740
+ // (e.g. iOS pre-Phase-2) still gets the correct affine SCANS path.
741
+ if (config.useManualPipeline && config.stitchMode != StitchMode::Scans) {
588
742
  StitchResult r =
589
743
  stitchFramePathsManual(framePaths, outputPath, config, logFn);
590
744
  // 2026-06-15 — AUTO SPHERICAL FALLBACK. The manual pipeline defaults to
@@ -614,6 +768,16 @@ static StitchResult stitchFramePathsImpl_(
614
768
  StitchResult result;
615
769
  result.framesRequested = static_cast<int32_t>(framePaths.size());
616
770
 
771
+ // 2026-06-16 (review #1) — outer crash-catch ladder over the WHOLE high-level
772
+ // body. Now that high-level is the default pipeline, an OOM-class throw must
773
+ // NOT escape: cv::Stitcher PANORAMA internals (MultiBand pyramids, GraphCut,
774
+ // STL vectors) can throw std::bad_alloc (NOT a cv::Exception, so the narrow
775
+ // inner catch(cv::Exception&) misses it), and the post-stitch clone/crop/bake
776
+ // ops can throw cv::Exception(StsNoMem) OUTSIDE the inner catches — either
777
+ // would cross the JNI C-ABI → std::terminate/SIGABRT. Mirror the manual
778
+ // path's ladder so any throw becomes a clean StitchResult error (which also
779
+ // makes the spherical rescue eligible). The JNI adds a backstop too.
780
+ try {
617
781
  if (framePaths.size() < 2) {
618
782
  result.errorCode = StitchErrorCode::InvalidArgument;
619
783
  result.errorMessage = "Need at least 2 frames to stitch (got " +
@@ -639,7 +803,9 @@ static StitchResult stitchFramePathsImpl_(
639
803
  config.captureOrientation.c_str(),
640
804
  config.jpegQuality,
641
805
  config.useInscribedRectCrop ? 1 : 0);
642
- log_info(logFn, "[memstat]", "phase=entry rss=%.1f MB", rss_mb());
806
+ // Gate the [memstat] phase logs (compile flag + runtime settings.debug).
807
+ const bool memstat = kMemProfilingCompiled && config.enableMemoryProfiling;
808
+ log_memstat(logFn, memstat, "entry");
643
809
 
644
810
  // ── 1. Load input frames ───────────────────────────────────────
645
811
  std::vector<cv::Mat> images;
@@ -662,7 +828,7 @@ static StitchResult stitchFramePathsImpl_(
662
828
  }
663
829
  log_info(logFn, "[dimstat]", "loaded %zu frames total_input_data=%.2f MB",
664
830
  images.size(), totalInputMB);
665
- log_info(logFn, "[memstat]", "phase=after_imread rss=%.1f MB", rss_mb());
831
+ log_memstat(logFn, memstat, "after_imread");
666
832
 
667
833
  // ── 2. Configure cv::Stitcher ──────────────────────────────────
668
834
  const cv::Stitcher::Mode cvMode = (config.stitchMode == StitchMode::Scans)
@@ -704,7 +870,17 @@ static StitchResult stitchFramePathsImpl_(
704
870
  if (config.seamEstimationResolMP > 0.0) {
705
871
  stitcher->setSeamEstimationResol(config.seamEstimationResolMP);
706
872
  }
707
- const double kHighLevelComposeFallbackMP = 1.0;
873
+ // 2026-06-16 (high-level safety) — RAM-aware compositing resolution.
874
+ // cv::Stitcher composes the whole canvas at once (no STREAM mode), so the
875
+ // per-frame compose MP directly sizes the peak. 1.0 MP is fine on 6 GB+
876
+ // (measured peak ~0.7-0.9 GB on the A35); on lower-RAM devices clamp to
877
+ // 0.6 MP so the unguarded high-level path can't out-grow the per-process
878
+ // budget. An explicit caller override (compositingResolMP > 0) still wins.
879
+ double totalRamMB = (config.availableRamMB > 0.0)
880
+ ? config.availableRamMB : device_total_ram_mb();
881
+ if (totalRamMB <= 0.0) totalRamMB = 4.0 * 1024.0; // conservative fallback
882
+ const double kHighLevelComposeFallbackMP =
883
+ (totalRamMB >= 5.0 * 1024.0) ? 1.0 : 0.6;
708
884
  const double composeMP = (config.compositingResolMP > 0.0)
709
885
  ? config.compositingResolMP : kHighLevelComposeFallbackMP;
710
886
  stitcher->setCompositingResol(composeMP);
@@ -725,7 +901,28 @@ static StitchResult stitchFramePathsImpl_(
725
901
  // with progressively lower thresholds [1.0 → 0.5 → 0.3] until
726
902
  // every frame is retained or we hit the floor. SCANS skips the
727
903
  // higher thresholds (its default is already 0.3).
728
- log_info(logFn, "[memstat]", "phase=before_stitch rss=%.1f MB", rss_mb());
904
+ // 2026-06-16 (high-level safety) pre-stitch headroom abort. The
905
+ // high-level path has no STREAM fallback or canvas downscale, so if the
906
+ // process is ALREADY so close to its per-process kill ceiling that even a
907
+ // minimal stitch won't fit on top, abort cleanly (surfaced via onError)
908
+ // rather than letting cv::Stitcher march into an lmkd/jetsam kill. Reuses
909
+ // the manual path's headroom guard; rss_mb() is memProbeFn-backed so this
910
+ // works on iOS too (where /proc is absent). Skipped when rss is unknown.
911
+ {
912
+ const double startRssMB = rss_mb();
913
+ if (startRssMB >= 0.0
914
+ && retailens::stitchExceedsMinimalHeadroom(startRssMB, totalRamMB)) {
915
+ result.errorCode = StitchErrorCode::PreStitchMemoryAbort;
916
+ result.errorMessage =
917
+ "Pre-stitch abort: insufficient memory headroom for high-level "
918
+ "stitch (rss=" + std::to_string(static_cast<int>(startRssMB)) +
919
+ "MB, budget=" + std::to_string(static_cast<int>(
920
+ retailens::perProcessMemoryBudgetMB(totalRamMB))) + "MB)";
921
+ log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
922
+ return result;
923
+ }
924
+ }
925
+ log_memstat(logFn, memstat, "before_stitch");
729
926
  const double kRetryThresholds[] = {1.0, 0.5, 0.3};
730
927
  const int kNumAttempts = sizeof(kRetryThresholds) / sizeof(double);
731
928
  cv::Mat panorama;
@@ -743,10 +940,14 @@ static StitchResult stitchFramePathsImpl_(
743
940
  "attempt %d/%d panoConfidenceThresh=%.2f",
744
941
  finalAttempt, kNumAttempts, thresh);
745
942
  try {
746
- status = stitcher->stitch(images, panorama);
943
+ // 2026-06-16 (review #2) — TWO-PHASE: estimateTransform (registration
944
+ // + BA + leaveBiggestComponent at this threshold) here; composePanorama
945
+ // runs ONCE after the canvas-union guard below. This is the ONLY way
946
+ // to inspect the estimated canvas BEFORE the warp/blend allocates it.
947
+ status = stitcher->estimateTransform(images);
747
948
  } catch (const cv::Exception& e) {
748
949
  result.errorCode = StitchErrorCode::UnknownCvException;
749
- result.errorMessage = std::string("Stitcher::stitch threw on attempt ") +
950
+ result.errorMessage = std::string("Stitcher::estimateTransform threw on attempt ") +
750
951
  std::to_string(finalAttempt) + ": " + e.what();
751
952
  log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
752
953
  return result;
@@ -777,19 +978,156 @@ static StitchResult stitchFramePathsImpl_(
777
978
  }
778
979
  if (status != cv::Stitcher::OK) {
779
980
  result.errorCode = statusToErrorCode(status);
780
- result.errorMessage = "Stitcher::stitch failed at all " +
981
+ result.errorMessage = "Stitcher::estimateTransform failed at all " +
781
982
  std::to_string(finalAttempt) + " thresholds, last status code " +
782
983
  std::to_string(static_cast<int>(status));
783
984
  log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
784
985
  return result;
785
986
  }
987
+
988
+ // 2026-06-16 (review #2) — degenerate-canvas guard BEFORE composePanorama.
989
+ // estimateTransform succeeded; composePanorama will now warp+blend a canvas
990
+ // whose size is set by the estimated focals/rotations. A divergent BA
991
+ // produces absurd focals → a gigapixel canvas → an lmkd/jetsam kill MID
992
+ // allocation that NO try/catch can intercept. Project the warp-canvas union
993
+ // from cameras() + the configured warper at the registration/WORK scale
994
+ // (conservative: a valid pano is well under 1 MP here, so a union over
995
+ // kMaxCanvasPixels (50 MP) is unambiguously degenerate) and abort cleanly
996
+ // before the allocation. Valid wide pans are bounded separately by the
997
+ // RAM-aware compositingResol above, so this only fires on a divergent
998
+ // estimate — and the abort routes to the outer wrapper's spherical rescue.
999
+ try {
1000
+ const std::vector<cv::detail::CameraParams> cams = stitcher->cameras();
1001
+ if (!cams.empty()) {
1002
+ std::vector<double> focals;
1003
+ focals.reserve(cams.size());
1004
+ for (const auto& c : cams) focals.push_back(c.focal);
1005
+ std::sort(focals.begin(), focals.end());
1006
+ const double warpScale = focals[focals.size() / 2]; // median focal
1007
+ cv::Ptr<cv::WarperCreator> wc = make_warper(config.warperType);
1008
+ if (wc) {
1009
+ cv::Ptr<cv::detail::RotationWarper> w =
1010
+ wc->create(static_cast<float>(warpScale));
1011
+ const double workScale = stitcher->workScale();
1012
+ int64_t minX = 0, minY = 0, maxX = 0, maxY = 0;
1013
+ bool seeded = false;
1014
+ for (size_t i = 0; i < cams.size() && i < images.size(); ++i) {
1015
+ cv::Mat K;
1016
+ cams[i].K().convertTo(K, CV_32F);
1017
+ const cv::Size workSz(
1018
+ std::max(1, (int)std::lround(images[i].cols * workScale)),
1019
+ std::max(1, (int)std::lround(images[i].rows * workScale)));
1020
+ const cv::Rect roi = w->warpRoi(workSz, K, cams[i].R);
1021
+ if (!seeded) {
1022
+ minX = roi.x; minY = roi.y;
1023
+ maxX = (int64_t)roi.x + roi.width;
1024
+ maxY = (int64_t)roi.y + roi.height;
1025
+ seeded = true;
1026
+ } else {
1027
+ minX = std::min<int64_t>(minX, roi.x);
1028
+ minY = std::min<int64_t>(minY, roi.y);
1029
+ maxX = std::max<int64_t>(maxX, (int64_t)roi.x + roi.width);
1030
+ maxY = std::max<int64_t>(maxY, (int64_t)roi.y + roi.height);
1031
+ }
1032
+ }
1033
+ if (seeded && canvasExceedsGuard(maxX - minX, maxY - minY)) {
1034
+ result.errorCode = StitchErrorCode::WarpFailed;
1035
+ result.errorMessage =
1036
+ "Degenerate high-level estimate: warp-canvas union " +
1037
+ std::to_string(maxX - minX) + "x" +
1038
+ std::to_string(maxY - minY) +
1039
+ " px (work scale) exceeds guard — aborting before "
1040
+ "composePanorama to avoid an OOM kill";
1041
+ log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
1042
+ return result; // → outer wrapper's spherical rescue (bounded)
1043
+ }
1044
+
1045
+ // 2026-06-16 (review #2 + on-device data) — VALID-but-large canvas
1046
+ // budget. A wide PLANE pan peaked ~2520 MB on the 6 GB A35 (above
1047
+ // its red line; OOM on 4 GB) — not degenerate (passed the guard
1048
+ // above), just unbounded-plane-large. Project the COMPOSE-scale
1049
+ // canvas from the work-scale union (same geometry; resolution
1050
+ // ratio composeScale/workScale) and, if it exceeds the RAM canvas
1051
+ // budget, bound it BEFORE composePanorama: downscale
1052
+ // compositingResol when a modest (≤2×) shrink suffices, else route
1053
+ // to spherical (its geometry bounds the canvas at FULL resolution
1054
+ // — data: ~5× lower peak). This is the manual path's
1055
+ // composeCanvasBudgetMP downscale, ported to high-level.
1056
+ if (seeded && workScale > 0.0 && !images.empty()
1057
+ && images[0].total() > 0) {
1058
+ const double fullArea =
1059
+ static_cast<double>(images[0].cols) * images[0].rows;
1060
+ const double composeScale =
1061
+ std::min(1.0, std::sqrt(composeMP * 1e6 / fullArea));
1062
+ const double ratioCS = composeScale / workScale;
1063
+ const double workCanvasMP =
1064
+ static_cast<double>(maxX - minX) * (maxY - minY) / 1e6;
1065
+ const double composeCanvasMP = workCanvasMP * ratioCS * ratioCS;
1066
+ const double canvasBudgetMP =
1067
+ retailens::composeCanvasBudgetMP(totalRamMB);
1068
+ if (composeCanvasMP > canvasBudgetMP) {
1069
+ const double over = composeCanvasMP / canvasBudgetMP;
1070
+ if (over <= 2.0 || config.warperType == "spherical") {
1071
+ const double targetMP = composeMP / over;
1072
+ stitcher->setCompositingResol(targetMP);
1073
+ log_info(logFn, "[stitch]",
1074
+ "canvas %.1f MP > budget %.1f MP (warp=%s) — "
1075
+ "downscaled compositingResol %.2f→%.2f MP",
1076
+ composeCanvasMP, canvasBudgetMP,
1077
+ config.warperType.c_str(), composeMP, targetMP);
1078
+ } else {
1079
+ // >2× over on plane/cylindrical → spherical bounds it
1080
+ // geometrically (full res) far better than a big shrink.
1081
+ result.errorCode = StitchErrorCode::WarpFailed;
1082
+ result.errorMessage =
1083
+ "high-level canvas " +
1084
+ std::to_string(static_cast<int>(composeCanvasMP)) +
1085
+ " MP >> budget " +
1086
+ std::to_string(static_cast<int>(canvasBudgetMP)) +
1087
+ " MP for warper '" + config.warperType +
1088
+ "' — routing to spherical (bounded geometry)";
1089
+ log_info(logFn, "[stitch]", "%s",
1090
+ result.errorMessage.c_str());
1091
+ return result; // → outer wrapper's spherical rescue
1092
+ }
1093
+ }
1094
+ }
1095
+ }
1096
+ }
1097
+ } catch (const cv::Exception& e) {
1098
+ // warpRoi can itself throw on a degenerate camera — treat as degenerate.
1099
+ result.errorCode = StitchErrorCode::WarpFailed;
1100
+ result.errorMessage =
1101
+ std::string("Degenerate high-level estimate (warpRoi threw): ") + e.what();
1102
+ log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
1103
+ return result;
1104
+ }
1105
+
1106
+ // Canvas bounded → compose.
1107
+ try {
1108
+ status = stitcher->composePanorama(panorama);
1109
+ } catch (const cv::Exception& e) {
1110
+ result.errorCode = StitchErrorCode::UnknownCvException;
1111
+ result.errorMessage =
1112
+ std::string("Stitcher::composePanorama threw: ") + e.what();
1113
+ log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
1114
+ return result;
1115
+ }
1116
+ if (status != cv::Stitcher::OK) {
1117
+ result.errorCode = statusToErrorCode(status);
1118
+ result.errorMessage = "Stitcher::composePanorama failed, status " +
1119
+ std::to_string(static_cast<int>(status));
1120
+ log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
1121
+ return result;
1122
+ }
1123
+
786
1124
  log_info(logFn, "[dimstat]",
787
1125
  "post-stitch panorama %dx%d %dch data=%.2f MB"
788
1126
  " (framesIncluded=%d/%zu, finalThresh=%.2f, attempts=%d)",
789
1127
  panorama.cols, panorama.rows, panorama.channels(),
790
1128
  mat_mb(panorama),
791
1129
  framesIncluded, framePaths.size(), finalThreshold, finalAttempt);
792
- log_info(logFn, "[memstat]", "phase=after_stitch rss=%.1f MB", rss_mb());
1130
+ log_memstat(logFn, memstat, "after_stitch");
793
1131
 
794
1132
  // ── 4. Crop (coverage-aware inscribed rect, or bbox) ───────────
795
1133
  // Pull cv::Stitcher's coverage mask (0xFF filled / 0 unfilled). It is
@@ -802,6 +1140,26 @@ static StitchResult stitchFramePathsImpl_(
802
1140
  rm.copyTo(coverage); // download UMat → Mat
803
1141
  }
804
1142
  }
1143
+
1144
+ // 2026-06-16 (high-level safety) — validate the output BEFORE cropping/
1145
+ // writing. The manual path has had this since v0.16; the high-level path
1146
+ // shipped whatever cv::Stitcher produced. Rejects the black-canvas /
1147
+ // fragmented-output failures (utilization + disjoint guards) as
1148
+ // LowQualityStitch so the host can surface a "retry" instead of a broken
1149
+ // image. Fails open on an empty coverage mask.
1150
+ {
1151
+ std::string validateMessage;
1152
+ const StitchErrorCode validateCode = validateStitchOutput(
1153
+ panorama, coverage, framesIncluded, logFn, validateMessage);
1154
+ if (validateCode != StitchErrorCode::Ok) {
1155
+ result.errorCode = validateCode;
1156
+ result.errorMessage = validateMessage;
1157
+ log_error(logFn, "[stitch]", "high-level REJECTED — %s",
1158
+ validateMessage.c_str());
1159
+ return result;
1160
+ }
1161
+ }
1162
+
805
1163
  cv::Mat cropMask;
806
1164
  const cv::Rect cropRect = choose_crop_rect(
807
1165
  panorama, coverage, config.useInscribedRectCrop, logFn, cropMask);
@@ -817,14 +1175,14 @@ static StitchResult stitchFramePathsImpl_(
817
1175
  mat_mb(cropped),
818
1176
  config.useInscribedRectCrop ? 1 : 0,
819
1177
  coverage.empty() ? 0 : 1);
820
- log_info(logFn, "[memstat]", "phase=after_crop rss=%.1f MB", rss_mb());
1178
+ log_memstat(logFn, memstat, "after_crop");
821
1179
 
822
1180
  // ── 5. Bake rotation per capture orientation ───────────────────
823
1181
  cv::Mat final_image = bake_rotation(cropped, config.captureOrientation, logFn);
824
1182
  log_info(logFn, "[dimstat]",
825
1183
  "post-bake_rotation %dx%d data=%.2f MB",
826
1184
  final_image.cols, final_image.rows, mat_mb(final_image));
827
- log_info(logFn, "[memstat]", "phase=after_bake_rotation rss=%.1f MB", rss_mb());
1185
+ log_memstat(logFn, memstat, "after_bake_rotation");
828
1186
 
829
1187
  // ── 6. Write JPEG ──────────────────────────────────────────────
830
1188
  const int q = std::max(0, std::min(100, config.jpegQuality));
@@ -847,7 +1205,7 @@ static StitchResult stitchFramePathsImpl_(
847
1205
  log_info(logFn, "[stitch]",
848
1206
  "output written: %s (%dx%d)",
849
1207
  outputPath.c_str(), final_image.cols, final_image.rows);
850
- log_info(logFn, "[memstat]", "phase=after_imwrite rss=%.1f MB", rss_mb());
1208
+ log_memstat(logFn, memstat, "after_imwrite");
851
1209
 
852
1210
  // Best-effort coverage sidecar (<output>.coverage.png), bake-rotated
853
1211
  // to align with the JPEG, for the debug harness. Never fails stitch.
@@ -878,6 +1236,29 @@ static StitchResult stitchFramePathsImpl_(
878
1236
  std::string("pipe=highlevel;warp=") + config.warperType +
879
1237
  ";route=batch;seam=graphcut;blend=multiband";
880
1238
  return result;
1239
+ } catch (const cv::Exception& e) {
1240
+ result.success = false;
1241
+ result.errorCode = StitchErrorCode::UnknownCvException;
1242
+ result.errorMessage =
1243
+ std::string("high-level cv::Exception (uncaught): ") + e.what();
1244
+ log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
1245
+ return result;
1246
+ } catch (const std::exception& e) {
1247
+ // std::bad_alloc from cv::Stitcher's STL internals lands here (it is NOT
1248
+ // a cv::Exception). Clean error instead of std::terminate.
1249
+ result.success = false;
1250
+ result.errorCode = StitchErrorCode::UnknownCvException;
1251
+ result.errorMessage =
1252
+ std::string("high-level std::exception (likely OOM): ") + e.what();
1253
+ log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
1254
+ return result;
1255
+ } catch (...) {
1256
+ result.success = false;
1257
+ result.errorCode = StitchErrorCode::UnknownCvException;
1258
+ result.errorMessage = "high-level unknown exception (uncaught)";
1259
+ log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
1260
+ return result;
1261
+ }
881
1262
  }
882
1263
 
883
1264
 
@@ -2259,16 +2640,20 @@ StitchResult stitchFramePathsManual(
2259
2640
  cv::Mat K;
2260
2641
  cameras[i].K().convertTo(K, CV_32F);
2261
2642
 
2262
- // V12.14.6 clone input to break any recycled-mmap
2263
- // link to prior captures' allocations. cv::Mat::clone
2264
- // forces a fresh memcpy into a freshly-allocated buffer.
2265
- cv::Mat freshInput = composeFrames[i].clone();
2643
+ // 2026-06-16 (audit #5/L4) warp composeFrames[i] DIRECTLY.
2644
+ // The old V12.14.6 `freshInput = composeFrames[i].clone()` (a
2645
+ // full-res malloc+memcpy+free per frame, as a recycled-mmap
2646
+ // defense) is disproven by the STREAM warp path below, which
2647
+ // passes the frame raw with no clone. warp READS the input +
2648
+ // writes a SEPARATE output Mat, and composeFrames[i] is
2649
+ // released just after — so the copy bought nothing. Removes N
2650
+ // full-res copies per BATCH stitch.
2266
2651
 
2267
2652
  // V12.14.6 — pre-allocate output Mats via warpRoi() so
2268
2653
  // cv::remap doesn't need to call create() internally
2269
2654
  // (the suspect path that crashed in cv::resize too).
2270
2655
  cv::Rect roi = warper->warpRoi(
2271
- freshInput.size(), K, cameras[i].R);
2656
+ composeFrames[i].size(), K, cameras[i].R);
2272
2657
  // 2026-05-18 (Issue #1 guard): cv::Stitcher's estimator
2273
2658
  // + BA can produce wildly wrong camera parameters on
2274
2659
  // degenerate input (low feature count, near-duplicate
@@ -2303,12 +2688,12 @@ StitchResult stitchFramePathsManual(
2303
2688
  throw degenerateFrameException(
2304
2689
  roi.width, roi.height, config.stitchMode, i);
2305
2690
  }
2306
- imagesWarped[i].create(roi.size(), freshInput.type());
2691
+ imagesWarped[i].create(roi.size(), composeFrames[i].type());
2307
2692
  masksWarped[i].create(roi.size(), CV_8U);
2308
2693
 
2309
- cv::Mat mask(freshInput.size(), CV_8U, cv::Scalar(255));
2694
+ cv::Mat mask(composeFrames[i].size(), CV_8U, cv::Scalar(255));
2310
2695
  corners[i] = warper->warp(
2311
- freshInput, K, cameras[i].R, cv::INTER_LINEAR,
2696
+ composeFrames[i], K, cameras[i].R, cv::INTER_LINEAR,
2312
2697
  cv::BORDER_CONSTANT, imagesWarped[i]);
2313
2698
  warper->warp(mask, K, cameras[i].R, cv::INTER_NEAREST,
2314
2699
  cv::BORDER_CONSTANT, masksWarped[i]);
@@ -2487,10 +2872,19 @@ StitchResult stitchFramePathsManual(
2487
2872
  auto compensator = cv::detail::ExposureCompensator::createDefault(
2488
2873
  cv::detail::ExposureCompensator::GAIN_BLOCKS);
2489
2874
  {
2875
+ // 2026-06-16 (audit #1) — feed the compensator ZERO-COPY views,
2876
+ // not deep copies. With HAVE_OPENCL undefined (both platforms)
2877
+ // getUMat(ACCESS_READ) shares the backing Mat buffer (no memcpy,
2878
+ // no GPU transfer); feed() takes const& and only READS pixels to
2879
+ // solve gains, so the output is byte-identical. The old copyTo
2880
+ // loop DOUBLED the full-res warped held-set (~60-90 MB transient)
2881
+ // at the exact BATCH peak the canvas budget is tuned around —
2882
+ // imagesWarped[]/masksWarped[] are still live here (released in
2883
+ // the feed loop below).
2490
2884
  std::vector<cv::UMat> compImgs(N), compMasks(N);
2491
2885
  for (size_t i = 0; i < N; i++) {
2492
- imagesWarped[i].copyTo(compImgs[i]);
2493
- masksWarped[i].copyTo(compMasks[i]);
2886
+ compImgs[i] = imagesWarped[i].getUMat(cv::ACCESS_READ);
2887
+ compMasks[i] = masksWarped[i].getUMat(cv::ACCESS_READ);
2494
2888
  }
2495
2889
  compensator->feed(corners, compImgs, compMasks);
2496
2890
  }