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