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}
118
119impl StreamConfig {
120    /// Default settings (zone 12, overlap 2, stride 3).
121    pub fn new(src: PathBuf, out: PathBuf) -> Self {
122        Self {
123            src,
124            out,
125            span: 12,
126            overlap: 2,
127            stride: 3,
128            incremental_triangulation: false,
129            depth_cache: false,
130            fixed_enu_origin: false,
131            pm_backend: "cuda".into(),
132            mvs_profile: "fast".into(),
133            sift_backend: "cpu".into(),
134            match_backend: "cpu".into(),
135            undistort_backend: "cpu".into(),
136            threads: 0,
137            max_positions: None,
138            save_models: false,
139            serialize_dense: false,
140            dense_max_image_size: 960,
141            number_views: 10,
142            max_num_features: 8192,
143            sift_max_image_size: 3200,
144            echo: true,
145            no_dense: false,
146            stable_display: false,
147            settle_tol: 0.01,
148            settle_max_zone: 3,
149            intrinsics_prior: None,
150            intrinsics_prior_sigma: crate::intrinsics::DEFAULT_FOCAL_SIGMA,
151            intrinsics_prior_dist_sigma: crate::intrinsics::DEFAULT_DIST_SIGMA,
152            seed: None,
153            max_levels: None,
154            fusion: Fusion::default(),
155            live_stride: crate::post::LiveOptions::default().stride,
156        }
157    }
158}
159
160/// Position <-> image name. Position = index in the selected frame list (not file number / stride).
161#[derive(Clone, Debug)]
162pub struct Layout {
163    /// Frame number of the input file per position.
164    pub frames: Vec<u32>,
165    /// Image name -> position.
166    pub pos_of: HashMap<String, usize>,
167}
168
169impl Layout {
170    /// Those of `images/camF/camF_NNNN.jpg` with number % stride == 0 (ascending number).
171    pub fn discover(src: &Path, stride: usize) -> Result<Self, String> {
172        Self::discover_in(&src.join("images"), &CAMS, stride)
173    }
174
175    /// Those of the first camera folder of the image folder `images` (`<cams[0]>/<cams[0]>_NNNN.jpg`) with number % stride == 0
176    /// (ascending number).
177    /// The name -> position table is built for all cameras.
178    pub fn discover_in<S: AsRef<str>>(images: &Path, cams: &[S], stride: usize) -> Result<Self, String> {
179        let first = cams.first().ok_or("카메라 폴더 목록이 비어 있음")?.as_ref();
180        let dir = images.join(first);
181        let rd = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
182        let mut frames = Vec::new();
183        for e in rd.flatten() {
184            let n = e.file_name().to_string_lossy().to_string();
185            let Some(stem) = n.strip_prefix(&format!("{first}_")).and_then(|s| s.strip_suffix(".jpg")) else { continue };
186            if let Ok(i) = stem.parse::<u32>() {
187                if (i as usize).is_multiple_of(stride.max(1)) {
188                    frames.push(i);
189                }
190            }
191        }
192        frames.sort_unstable();
193        frames.dedup();
194        let mut l = Self { frames, pos_of: HashMap::new() };
195        for p in 0..l.frames.len() {
196            for c in cams {
197                let n = l.name(c.as_ref(), p);
198                l.pos_of.insert(n, p);
199            }
200        }
201        Ok(l)
202    }
203
204    /// Keep only the first `n` positions.
205    pub fn truncate(&mut self, n: usize) {
206        self.frames.truncate(n);
207        let frames = self.frames.len();
208        self.pos_of.retain(|_, p| *p < frames);
209    }
210
211    /// Image name of a camera and position (`cam/cam_NNNN.jpg`).
212    pub fn name(&self, cam: &str, p: usize) -> String {
213        format!("{cam}/{cam}_{:04}.jpg", self.frames[p])
214    }
215
216    /// Position of an image name.
217    pub fn position(&self, name: &str) -> Option<usize> {
218        self.pos_of.get(name).copied()
219    }
220}
221
222/// Frame bundle of position p (camera order camF, camR, camL).
223pub fn frame_set(layout: &Layout, p: usize) -> FrameSet {
224    frame_set_of(layout, &CAMS, p)
225}
226
227/// Frame bundle of position p (camera order = `cams`).
228pub fn frame_set_of<S: AsRef<str>>(layout: &Layout, cams: &[S], p: usize) -> FrameSet {
229    FrameSet::new(cams.iter().map(|c| (c.as_ref().to_string(), layout.name(c.as_ref(), p))))
230}
231
232/// Same pairing rule as the script: same camera gaps 1..5, 8, 16 / different cameras at position difference 0..4. Sorted name pairs.
233pub fn pairs_for_position(layout: &Layout, p: usize) -> Vec<(String, String)> {
234    let frames: Vec<Vec<FrameImage>> = (0..=p).map(|q| frame_set(layout, q).images).collect();
235    pairs_for(&frames, p)
236}
237
238/// Zones: start = 0, SPAN, ...; [start-OVL, start+SPAN+OVL) intersect [0, NPOS). Completion position = hi-1.
239pub fn regions(npos: usize, span: usize, ovl: usize) -> Vec<(usize, usize, usize)> {
240    zones_upto(npos, span, ovl).into_iter().map(|z| (z.zone, z.lo, z.hi)).collect()
241}
242
243/// Stream settings -> session settings. `gps` = GPS records of the selected images (file order), `dense` = None skips densification.
244pub fn session_config(cfg: &StreamConfig, npos: usize, gps: Vec<GpsRecord>, dense: Option<DenseConfig>) -> Result<SessionConfig, String> {
245    let mut s = SessionConfig::new(cfg.src.join("images"));
246    s.gps = gps;
247    s.span = cfg.span;
248    s.overlap = cfg.overlap;
249    s.total_positions = Some(npos);
250    s.incremental_triangulation = cfg.incremental_triangulation;
251    s.stable_display = cfg.stable_display;
252    s.settle_tol = cfg.settle_tol;
253    s.settle_max_zone = cfg.settle_max_zone;
254    if let Some(p) = &cfg.intrinsics_prior {
255        s.intrinsics_prior = Some(Arc::new(IntrinsicsPrior::load(p, cfg.intrinsics_prior_sigma, cfg.intrinsics_prior_dist_sigma)?));
256    }
257    s.fixed_enu_origin = cfg.fixed_enu_origin;
258    s.seed = cfg.seed;
259    s.extraction.sift.max_num_features = cfg.max_num_features;
260    s.extraction.reader.max_image_size = cfg.sift_max_image_size;
261    s.sift = make_sift_backend(&cfg.sift_backend)?;
262    s.matcher = Arc::from(make_match_backend(&cfg.match_backend)?);
263    s.dense = dense;
264    // The CLI does not rewind (saves memory).
265    s.history = 0;
266    Ok(s)
267}
268
269/// Run the stream: convert the settings into a plan ([`Plan::from_stream`]) and validate and run it through the declarative layer
270/// ([`Recon`]).
271/// Recreates the output folder. The output is the same as when the session reducer + pipeline + default output hooks are assembled
272/// directly.
273pub fn run_stream(cfg: StreamConfig) -> Result<(), String> {
274    let recon = Recon::from_plan(Plan::from_stream(&cfg)).build().map_err(|e| e.to_string())?;
275    recon.run().map(|_| ())
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn regions_match_script() {
284        // NPOS=26, SPAN 12, OVL 2 → [0,14), [10,26), [22,26)
285        assert_eq!(regions(26, 12, 2), vec![(0, 0, 14), (1, 10, 26), (2, 22, 26)]);
286        assert_eq!(regions(40, 12, 2), vec![(0, 0, 14), (1, 10, 26), (2, 22, 38), (3, 34, 40)]);
287    }
288
289    #[test]
290    fn pairs_rule() {
291        let frames: Vec<u32> = (0..20).map(|i| i * 3).collect();
292        let mut l = Layout { frames, pos_of: HashMap::new() };
293        l.pos_of.insert("x".into(), 0);
294        let p0 = pairs_for_position(&l, 0);
295        // Position 0: 3 pairs between different cameras.
296        assert_eq!(p0.len(), 3);
297        let p17 = pairs_for_position(&l, 17);
298        // 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.
299        assert_eq!(p17.len(), 21 + 27);
300        assert!(p17.contains(&("camF/camF_0003.jpg".to_string(), "camF/camF_0051.jpg".to_string())));
301    }
302}