Skip to main content

cumulus3d_cli/
stream.rs

1/*
2 * stream.rs
3 *
4 * Copyright (c) 2026 ParkSangWoo
5 *
6 * Author(s):
7 *
8 *      ParkSangWoo <dev.parksangwoo@gmail.com>
9 *
10 *
11 * This file is part of cumulus3d.
12 *
13 * Licensed under the Apache License, Version 2.0 (the "License");
14 * you may not use this file except in compliance with the License.
15 * You may obtain a copy of the License at
16 *
17 *      http://www.apache.org/licenses/LICENSE-2.0
18 *
19 * Unless required by applicable law or agreed to in writing, software
20 * distributed under the License is distributed on an "AS IS" BASIS,
21 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 * See the License for the specific language governing permissions and
23 * limitations under the License.
24 *
25 * SPDX-License-Identifier: Apache-2.0
26 */
27
28//! `cumulus3d stream`: feed the positions of the input folder one by one into the [`Session`](crate::session::Session) reducer
29//! ([`Pipeline`](crate::pipeline::Pipeline)),
30//! and the default output hooks ([`crate::sinks`]) produce timeline.txt, run.log, point clouds and snapshot files.
31//! The computation (features -> pair matching -> first model / continued registration -> per-zone preview and refinement) lives in
32//! [`crate::session`].
33//! [`run_stream`] converts the settings into a plan ([`crate::declare::Plan`]) and runs it through the declarative layer
34//! ([`crate::declare`]).
35
36use crate::declare::{Fusion, Plan, Recon};
37use crate::densewrap::{make_match_backend, make_sift_backend, DenseConfig};
38use crate::intrinsics::IntrinsicsPrior;
39use crate::session::{pairs_for, zones_upto, FrameImage, FrameSet, SessionConfig};
40use cumulus3d_core::io::GpsRecord;
41use std::collections::HashMap;
42use std::path::{Path, PathBuf};
43use std::sync::Arc;
44
45/// Camera folders (script order).
46pub const CAMS: [&str; 3] = ["camF", "camR", "camL"];
47
48/// Stream settings. Defaults = script behavior.
49#[derive(Clone, Debug)]
50pub struct StreamConfig {
51    /// Input folder (`images/<cam>/`, `gps_ref.txt`).
52    pub src: PathBuf,
53    /// Output folder.
54    pub out: PathBuf,
55    /// Zone size (number of positions).
56    pub span: usize,
57    /// Zone overlap (number of positions).
58    pub overlap: usize,
59    /// Frame interval (only number % stride == 0 is used).
60    pub stride: usize,
61    /// Triangulate only newly registered images (improvement, off by default).
62    pub incremental_triangulation: bool,
63    /// Reuse depth maps of overlapping images (improvement, off by default).
64    pub depth_cache: bool,
65    /// Fix the session ENU origin to the first GPS line (improvement, off by default = the first common record at each alignment).
66    pub fixed_enu_origin: bool,
67    /// PatchMatch backend name (cuda).
68    pub pm_backend: String,
69    /// Densification profile (fast|quality).
70    pub mvs_profile: String,
71    /// SIFT backend (cpu|cuda).
72    pub sift_backend: String,
73    /// Descriptor matching backend (cpu|cuda).
74    pub match_backend: String,
75    /// Image undistortion backend (cpu|cuda).
76    pub undistort_backend: String,
77    /// Number of rayon threads (0 = number of cores).
78    pub threads: usize,
79    /// Process only the first N positions.
80    pub max_positions: Option<usize>,
81    /// Save per-zone sparse models.
82    pub save_models: bool,
83    /// Serialize preview/refined densification backend calls.
84    pub serialize_dense: bool,
85    /// Maximum undistortion size (script 960).
86    pub dense_max_image_size: i64,
87    /// Number of densification source views.
88    pub number_views: usize,
89    /// Maximum number of SIFT features (script 8192).
90    pub max_num_features: usize,
91    /// Maximum SIFT input size (default 3200).
92    pub sift_max_image_size: usize,
93    /// Also show the run.log contents on standard output.
94    pub echo: bool,
95    /// Skip densification (for development/tests).
96    pub no_dense: bool,
97    /// Stable display (see [`crate::session::SessionConfig::stable_display`]).
98    pub stable_display: bool,
99    /// Stable display: intrinsics change below which the display settles (see [`crate::session::SessionConfig::settle_tol`]).
100    pub settle_tol: f64,
101    /// Stable display: last zone that may still settle (see [`crate::session::SessionConfig::settle_max_zone`]).
102    pub settle_max_zone: usize,
103    /// Soft intrinsics prior file (see [`crate::intrinsics`]).
104    pub intrinsics_prior: Option<PathBuf>,
105    /// Intrinsics prior: relative σ of the focal lengths.
106    pub intrinsics_prior_sigma: f64,
107    /// Intrinsics prior: absolute σ of the distortion parameters.
108    pub intrinsics_prior_dist_sigma: f64,
109    /// RANSAC seed for two-view geometry and GPS alignment (None = differs from run to run).
110    pub seed: Option<u64>,
111    /// Upper bound on the number of densification scales (None = profile default; 1 = a single scale at full resolution).
112    pub max_levels: Option<usize>,
113    /// Fusion overrides (smoothing, visibility; unset values use the profile default).
114    pub fusion: Fusion,
115    /// Live display thinning: keep 1 of N points by key hash (1 = keep all; ≥ 1).
116    pub live_stride: usize,
117    /// Positions whose features are extracted ahead of the position loop (see [`crate::session::SessionConfig::lookahead`]; 0 = off).
118    pub lookahead: usize,
119    /// Stable display: sparse preview of every zone when it closes (see [`crate::session::SessionConfig::preview`]).
120    pub preview: bool,
121}
122
123impl StreamConfig {
124    /// Default settings (zone 12, overlap 2, stride 3).
125    pub fn new(src: PathBuf, out: PathBuf) -> Self {
126        Self {
127            src,
128            out,
129            span: 12,
130            overlap: 2,
131            stride: 3,
132            incremental_triangulation: false,
133            depth_cache: false,
134            fixed_enu_origin: false,
135            pm_backend: "cuda".into(),
136            mvs_profile: "fast".into(),
137            sift_backend: "cpu".into(),
138            match_backend: "cpu".into(),
139            undistort_backend: "cpu".into(),
140            threads: 0,
141            max_positions: None,
142            save_models: false,
143            serialize_dense: false,
144            dense_max_image_size: 960,
145            number_views: 10,
146            max_num_features: 8192,
147            sift_max_image_size: 3200,
148            echo: true,
149            no_dense: false,
150            stable_display: false,
151            settle_tol: 0.01,
152            settle_max_zone: 3,
153            intrinsics_prior: None,
154            intrinsics_prior_sigma: crate::intrinsics::DEFAULT_FOCAL_SIGMA,
155            intrinsics_prior_dist_sigma: crate::intrinsics::DEFAULT_DIST_SIGMA,
156            seed: None,
157            max_levels: None,
158            fusion: Fusion::default(),
159            live_stride: crate::post::LiveOptions::default().stride,
160            lookahead: 0,
161            preview: false,
162        }
163    }
164}
165
166/// Position <-> image name. Position = index in the selected frame list (not file number / stride).
167#[derive(Clone, Debug)]
168pub struct Layout {
169    /// Frame number of the input file per position.
170    pub frames: Vec<u32>,
171    /// Image name -> position.
172    pub pos_of: HashMap<String, usize>,
173}
174
175impl Layout {
176    /// Those of `images/camF/camF_NNNN.jpg` with number % stride == 0 (ascending number).
177    pub fn discover(src: &Path, stride: usize) -> Result<Self, String> {
178        Self::discover_in(&src.join("images"), &CAMS, stride)
179    }
180
181    /// Those of the first camera folder of the image folder `images` (`<cams[0]>/<cams[0]>_NNNN.jpg`) with number % stride == 0
182    /// (ascending number).
183    /// The name -> position table is built for all cameras.
184    pub fn discover_in<S: AsRef<str>>(images: &Path, cams: &[S], stride: usize) -> Result<Self, String> {
185        let first = cams.first().ok_or("카메라 폴더 목록이 비어 있음")?.as_ref();
186        let dir = images.join(first);
187        let rd = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
188        let mut frames = Vec::new();
189        for e in rd.flatten() {
190            let n = e.file_name().to_string_lossy().to_string();
191            let Some(stem) = n.strip_prefix(&format!("{first}_")).and_then(|s| s.strip_suffix(".jpg")) else { continue };
192            if let Ok(i) = stem.parse::<u32>() {
193                if (i as usize).is_multiple_of(stride.max(1)) {
194                    frames.push(i);
195                }
196            }
197        }
198        frames.sort_unstable();
199        frames.dedup();
200        let mut l = Self { frames, pos_of: HashMap::new() };
201        for p in 0..l.frames.len() {
202            for c in cams {
203                let n = l.name(c.as_ref(), p);
204                l.pos_of.insert(n, p);
205            }
206        }
207        Ok(l)
208    }
209
210    /// Keep only the first `n` positions.
211    pub fn truncate(&mut self, n: usize) {
212        self.frames.truncate(n);
213        let frames = self.frames.len();
214        self.pos_of.retain(|_, p| *p < frames);
215    }
216
217    /// Image name of a camera and position (`cam/cam_NNNN.jpg`).
218    pub fn name(&self, cam: &str, p: usize) -> String {
219        format!("{cam}/{cam}_{:04}.jpg", self.frames[p])
220    }
221
222    /// Position of an image name.
223    pub fn position(&self, name: &str) -> Option<usize> {
224        self.pos_of.get(name).copied()
225    }
226}
227
228/// Frame bundle of position p (camera order camF, camR, camL).
229pub fn frame_set(layout: &Layout, p: usize) -> FrameSet {
230    frame_set_of(layout, &CAMS, p)
231}
232
233/// Frame bundle of position p (camera order = `cams`).
234pub fn frame_set_of<S: AsRef<str>>(layout: &Layout, cams: &[S], p: usize) -> FrameSet {
235    FrameSet::new(cams.iter().map(|c| (c.as_ref().to_string(), layout.name(c.as_ref(), p))))
236}
237
238/// Same pairing rule as the script: same camera gaps 1..5, 8, 16 / different cameras at position difference 0..4. Sorted name pairs.
239pub fn pairs_for_position(layout: &Layout, p: usize) -> Vec<(String, String)> {
240    let frames: Vec<Vec<FrameImage>> = (0..=p).map(|q| frame_set(layout, q).images).collect();
241    pairs_for(&frames, p)
242}
243
244/// Zones: start = 0, SPAN, ...; [start-OVL, start+SPAN+OVL) intersect [0, NPOS). Completion position = hi-1.
245pub fn regions(npos: usize, span: usize, ovl: usize) -> Vec<(usize, usize, usize)> {
246    zones_upto(npos, span, ovl).into_iter().map(|z| (z.zone, z.lo, z.hi)).collect()
247}
248
249/// Stream settings -> session settings. `gps` = GPS records of the selected images (file order), `dense` = None skips densification.
250pub fn session_config(cfg: &StreamConfig, npos: usize, gps: Vec<GpsRecord>, dense: Option<DenseConfig>) -> Result<SessionConfig, String> {
251    let mut s = SessionConfig::new(cfg.src.join("images"));
252    s.gps = gps;
253    s.span = cfg.span;
254    s.overlap = cfg.overlap;
255    s.total_positions = Some(npos);
256    s.incremental_triangulation = cfg.incremental_triangulation;
257    s.stable_display = cfg.stable_display;
258    s.settle_tol = cfg.settle_tol;
259    s.settle_max_zone = cfg.settle_max_zone;
260    s.lookahead = cfg.lookahead;
261    s.preview = cfg.preview;
262    if let Some(p) = &cfg.intrinsics_prior {
263        s.intrinsics_prior = Some(Arc::new(IntrinsicsPrior::load(p, cfg.intrinsics_prior_sigma, cfg.intrinsics_prior_dist_sigma)?));
264    }
265    s.fixed_enu_origin = cfg.fixed_enu_origin;
266    s.seed = cfg.seed;
267    s.extraction.sift.max_num_features = cfg.max_num_features;
268    s.extraction.reader.max_image_size = cfg.sift_max_image_size;
269    s.sift = make_sift_backend(&cfg.sift_backend)?;
270    s.matcher = Arc::from(make_match_backend(&cfg.match_backend)?);
271    s.dense = dense;
272    // The CLI does not rewind (saves memory).
273    s.history = 0;
274    Ok(s)
275}
276
277/// Run the stream: convert the settings into a plan ([`Plan::from_stream`]) and validate and run it through the declarative layer
278/// ([`Recon`]).
279/// Recreates the output folder. The output is the same as when the session reducer + pipeline + default output hooks are assembled
280/// directly.
281pub fn run_stream(cfg: StreamConfig) -> Result<(), String> {
282    let recon = Recon::from_plan(Plan::from_stream(&cfg)).build().map_err(|e| e.to_string())?;
283    recon.run().map(|_| ())
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn regions_match_script() {
292        // NPOS=26, SPAN 12, OVL 2 → [0,14), [10,26), [22,26)
293        assert_eq!(regions(26, 12, 2), vec![(0, 0, 14), (1, 10, 26), (2, 22, 26)]);
294        assert_eq!(regions(40, 12, 2), vec![(0, 0, 14), (1, 10, 26), (2, 22, 38), (3, 34, 40)]);
295    }
296
297    #[test]
298    fn pairs_rule() {
299        let frames: Vec<u32> = (0..20).map(|i| i * 3).collect();
300        let mut l = Layout { frames, pos_of: HashMap::new() };
301        l.pos_of.insert("x".into(), 0);
302        let p0 = pairs_for_position(&l, 0);
303        // Position 0: 3 pairs between different cameras.
304        assert_eq!(p0.len(), 3);
305        let p17 = pairs_for_position(&l, 17);
306        // Same camera at gap 7 x 3 + different cameras: 6 ordered pairs x 5 positions, of which q=p is a duplicate -> 3 + 6x4 = 27.
307        assert_eq!(p17.len(), 21 + 27);
308        assert!(p17.contains(&("camF/camF_0003.jpg".to_string(), "camF/camF_0051.jpg".to_string())));
309    }
310}