Skip to main content

cumulus3d_cli/
declare.rs

1/*
2 * declare.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//! Declarative builder layer: record a plan first and run it all at once later.
29//!
30//! The methods of the [`ReconBuilder`] returned by [`Recon::declare`] only **record a plan** ("this is what to do") and execute
31//! nothing (they neither create nor delete files or folders, and do not open devices). [`ReconBuilder::build`] validates the plan
32//! and builds an executable reconstruction ([`Recon`]); [`Recon::run`] assembles the session reducer + pipeline + hooks + default output
33//! hook and
34//! runs the position loop, the wait for background jobs and the shutdown in one go (deferred execution).
35//!
36//! The plan itself is a value ([`Plan`]), so it can be compared, cloned and saved as TOML ([`Plan::to_toml`] / [`Plan::from_toml`]).
37//! Hooks (closures) are kept in the builder, not in the plan.
38//!
39//! # Usage example
40//!
41//! ```no_run
42//! use cumulus3d_cli::declare::{Dense, Recon, Sinks};
43//! use cumulus3d_cli::events::{Event, EventKind};
44//!
45//! fn main() -> Result<(), String> {
46//!     let recon = Recon::declare()
47//!         .input("data")                      // data/images/<cam>/, data/gps_ref.txt
48//!         .preset("aerial-formation")         // camF/camR/camL, formation pairing rule, stride 3
49//!         .stride(3)
50//!         .zones(12, 2)
51//!         .dense(Dense::profile("fast").fusion_min_views(3))
52//!         .sinks(Sinks::default_files("out").models(true))
53//!         .seed(7)
54//!         .on(EventKind::PositionDone, |e: &Event| {
55//!             if let Some(t) = e.timeline_text() {
56//!                 println!("{t}");
57//!             }
58//!         })
59//!         .on_zone_preview(|range, cloud| println!("preview {}: {} points", range.zone, cloud.len()))
60//!         .build()                            // validation only (errors are collected and reported at once)
61//!         .map_err(|e| e.to_string())?;
62//!     println!("{}", recon.plan().to_toml());
63//!     let summary = recon.run()?;              // actual execution happens here
64//!     println!("{} events", summary.events);
65//!     Ok(())
66//! }
67//! ```
68//!
69//! Run from a TOML plan file: `cumulus3d run plan.toml`. The default plan is `cumulus3d plan --print-default`.
70//! Relative paths in a plan file are relative to the working location (current folder).
71
72use crate::blocks::LiveStream;
73use crate::densewrap::{
74    make_ba_backend, make_match_backend, make_pm_backend, make_sift_backend, make_sparse_backend, make_undistort_backend, parse_profile,
75    DenseConfig,
76};
77use crate::events::{EventKind, Frame, ZoneRange};
78use crate::intrinsics::IntrinsicsPrior;
79use crate::pipeline::{Pipeline, QueuePolicy, Summary};
80use crate::session::{zones_upto, Input, Session, SessionConfig};
81use crate::sinks::{DefaultSinks, SinkSet};
82use crate::stream::{frame_set_of, Layout, StreamConfig, CAMS};
83use cumulus3d_core::io::ply::PointCloud;
84use cumulus3d_core::io::{read_gps_file, GpsRecord};
85use cumulus3d_dense::{DensifyOptions, DepthMapCache, FusionMode, FusionResidual};
86use serde::{Deserialize, Serialize};
87use std::collections::BTreeMap;
88use std::fmt;
89use std::path::{Path, PathBuf};
90use std::sync::{Arc, Mutex};
91
92/// Default preset name.
93pub const DEFAULT_PRESET: &str = "aerial-formation";
94
95/// Known preset names.
96pub const PRESETS: [&str; 1] = [DEFAULT_PRESET];
97
98// ============================================================================================
99// Plan values
100// ============================================================================================
101
102/// Reconstruction plan (a value). It runs nothing and only holds "what to do and how".
103///
104/// The default ([`Plan::default`]) is the `aerial-formation` preset + the `cumulus3d stream` defaults,
105/// and the paths are placeholders (`data/images`, `data/gps_ref.txt`, `out`).
106#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107#[serde(default, deny_unknown_fields)]
108pub struct Plan {
109    /// RANSAC seed for two-view geometry and GPS alignment (if unset, differs from run to run).
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub seed: Option<u64>,
112    /// Number of rayon threads (0 = number of cores).
113    pub threads: usize,
114    /// GPS record file (`name latitude longitude altitude` lines). Without it, alignment is impossible.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub gps: Option<PathBuf>,
117    /// Pairing strategy.
118    pub pairing: Pairing,
119    /// Input images.
120    pub input: Source,
121    /// Zone splitting.
122    pub zones: Zones,
123    /// Coordinate alignment.
124    pub align: Align,
125    /// Feature extraction.
126    pub features: Features,
127    /// Descriptor matching.
128    pub matching: Matching,
129    /// Sparse reconstruction.
130    pub sparse: Sparse,
131    /// Live throughput: feature extraction ahead, backlog batching, sparse previews.
132    pub live: Live,
133    /// Densification.
134    pub dense: Dense,
135    /// Output hooks.
136    pub sinks: Sinks,
137}
138
139impl Default for Plan {
140    fn default() -> Self {
141        Self {
142            seed: None,
143            threads: 0,
144            gps: Some(PathBuf::from("data/gps_ref.txt")),
145            pairing: Pairing::Formation,
146            input: Source::default(),
147            zones: Zones::default(),
148            align: Align::default(),
149            features: Features::default(),
150            matching: Matching::default(),
151            sparse: Sparse::default(),
152            live: Live::default(),
153            dense: Dense::default(),
154            sinks: Sinks::default_files("out"),
155        }
156    }
157}
158
159/// Input images: `<images>/<camera>/<camera>_NNNN.jpg`.
160#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
161#[serde(default, deny_unknown_fields)]
162pub struct Source {
163    /// Image folder (parent of the camera folders).
164    pub images: PathBuf,
165    /// List of camera folders (frame-bundle order for one position). The position list is taken from the first camera folder.
166    pub cameras: Vec<String>,
167    /// File number interval: only frames with number % stride == 0 are used. Position = index in the selected frame list.
168    pub stride: usize,
169    /// Only the first N positions (all if unset).
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub positions: Option<usize>,
172}
173
174impl Default for Source {
175    fn default() -> Self {
176        Self { images: PathBuf::from("data/images"), cameras: CAMS.iter().map(|c| c.to_string()).collect(), stride: 3, positions: None }
177    }
178}
179
180/// Pairing strategy.
181#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "kebab-case")]
183pub enum Pairing {
184    /// Formation rule: same camera at position gaps 1..5, 8, 16 / different cameras at position difference 0..4.
185    #[default]
186    Formation,
187}
188
189/// Zones (in units of positions).
190#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(default, deny_unknown_fields)]
192pub struct Zones {
193    /// Zone size.
194    pub span: usize,
195    /// Zone overlap (< span).
196    pub overlap: usize,
197}
198
199impl Default for Zones {
200    fn default() -> Self {
201        Self { span: 12, overlap: 2 }
202    }
203}
204
205/// Alignment coordinate frame.
206#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "kebab-case")]
208pub enum AlignFrame {
209    /// Align to the ENU (east, north, up) frame using GPS records (requires the GPS file).
210    #[default]
211    Enu,
212    /// No alignment (the GPS file is not used).
213    None,
214}
215
216/// Coordinate alignment.
217#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(default, deny_unknown_fields)]
219pub struct Align {
220    /// Coordinate frame.
221    pub frame: AlignFrame,
222    /// Fix the session ENU origin to the first GPS line (if off, the first common record at each alignment).
223    pub fixed_origin: bool,
224}
225
226/// Feature extraction.
227#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(default, deny_unknown_fields)]
229pub struct Features {
230    /// SIFT backend (cpu|cuda). The result is the same as cpu.
231    pub backend: String,
232    /// Maximum number of features.
233    pub max_num_features: usize,
234    /// Maximum input image size.
235    pub max_image_size: usize,
236}
237
238impl Default for Features {
239    fn default() -> Self {
240        Self { backend: "cpu".into(), max_num_features: 8192, max_image_size: 3200 }
241    }
242}
243
244/// Descriptor matching.
245#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(default, deny_unknown_fields)]
247pub struct Matching {
248    /// Matching backend (cpu|cuda). The result is the same as cpu.
249    pub backend: String,
250}
251
252impl Default for Matching {
253    fn default() -> Self {
254        Self { backend: "cpu".into() }
255    }
256}
257
258/// Sparse reconstruction.
259#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
260#[serde(default, deny_unknown_fields)]
261pub struct Sparse {
262    /// Triangulate only newly registered images (an improvement that can change the result).
263    pub incremental_triangulation: bool,
264    /// Two-view geometric verification, registration and triangulation backend (cpu|cuda).
265    pub backend: String,
266    /// Bundle adjustment backend (cpu|cuda). cuda falls back to the CPU with a warning if it fails at run time.
267    pub ba_backend: String,
268    /// Stable display (see [`SessionConfig::stable_display`]).
269    pub stable_display: bool,
270    /// Stable display: settle tolerance (see [`SessionConfig::settle_tol`]).
271    pub settle_tol: f64,
272    /// Stable display: last zone that may still settle (see [`SessionConfig::settle_max_zone`]).
273    pub settle_max_zone: usize,
274    /// Soft intrinsics prior file (see [`crate::intrinsics`]).
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub intrinsics_prior: Option<PathBuf>,
277    /// Intrinsics prior: relative σ of the focal lengths.
278    pub intrinsics_prior_sigma: f64,
279    /// Intrinsics prior: absolute σ of the distortion parameters.
280    pub intrinsics_prior_dist_sigma: f64,
281}
282
283impl Default for Sparse {
284    fn default() -> Self {
285        Self {
286            incremental_triangulation: false,
287            backend: "cpu".into(),
288            ba_backend: "cpu".into(),
289            stable_display: false,
290            settle_tol: 0.01,
291            settle_max_zone: 3,
292            intrinsics_prior: None,
293            intrinsics_prior_sigma: crate::intrinsics::DEFAULT_FOCAL_SIGMA,
294            intrinsics_prior_dist_sigma: crate::intrinsics::DEFAULT_DIST_SIGMA,
295        }
296    }
297}
298
299/// Live throughput: how a session keeps up with arriving positions and shows a low level first.
300#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(default, deny_unknown_fields)]
302pub struct Live {
303    /// Positions whose features are extracted ahead of the position loop (0 = off, see [`SessionConfig::lookahead`]). The
304    /// results do not change. Applies to `Recon::run` and to nodes (default 0; `ReconNode::declare()` starts with 4).
305    pub lookahead: usize,
306    /// Nodes: when this many states are waiting (the one being taken plus the queued ones), they are processed as one
307    /// backlog batch ([`crate::session::Input::Batch`]); 0 = off. Applies to spawned nodes (`ReconNode::spawn`); not used with
308    /// `ReconcilePolicy::ReplayWindow`, which needs a rewind point at every position. Default 0; `ReconNode::declare()` starts with 1 (off).
309    pub batch: usize,
310    /// Stable display: a sparse preview of every zone as soon as it closes (see [`SessionConfig::preview`]).
311    pub preview: bool,
312}
313
314impl Live {
315    /// The defaults of a live node ([`crate::compose::ReconNode::declare`]): lookahead 4, backlog batching off (opt in with `batch(n)`).
316    pub fn live_node() -> Self {
317        Self { lookahead: 4, batch: 1, preview: false }
318    }
319}
320
321/// Densification plan. Start from [`Dense::profile`] and add to it with methods. Unset fusion/filter values use the profile defaults.
322#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
323#[serde(default, deny_unknown_fields)]
324pub struct Dense {
325    /// Whether to densify (if off, there is no post-processing point cloud).
326    pub enabled: bool,
327    /// PatchMatch backend (cuda).
328    pub backend: String,
329    /// Image undistortion backend (cpu|cuda). Same result as cpu up to rounding.
330    pub undistort_backend: String,
331    /// Profile (fast|quality).
332    pub profile: String,
333    /// Maximum undistorted image size.
334    pub max_image_size: i64,
335    /// Number of source views (1..=32).
336    pub views: usize,
337    /// Upper bound on the number of scales (>= 1; 1 = a single scale at full resolution). If unset, the profile default.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub max_levels: Option<usize>,
340    /// Serialize preview/refined densification backend calls.
341    pub serialize: bool,
342    /// Reuse depth maps of overlapping images.
343    pub depth_cache: bool,
344    /// Fusion overrides.
345    pub fusion: Fusion,
346    /// Depth map filter overrides.
347    pub filter: Filter,
348}
349
350impl Default for Dense {
351    fn default() -> Self {
352        Self {
353            enabled: true,
354            backend: "cuda".into(),
355            undistort_backend: "cpu".into(),
356            profile: "fast".into(),
357            max_image_size: 960,
358            views: 10,
359            max_levels: None,
360            serialize: false,
361            depth_cache: false,
362            fusion: Fusion::default(),
363            filter: Filter::default(),
364        }
365    }
366}
367
368/// Fusion overrides (same meaning as `--fusion-*` of the `densify` subcommand). If unset, the profile default.
369#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
370#[serde(default, deny_unknown_fields)]
371pub struct Fusion {
372    /// Fusion mode (consistency|traversal).
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub mode: Option<String>,
375    /// Consistent fusion: minimum number of agreeing views besides the reference (>= 1).
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub min_views: Option<usize>,
378    /// Relative depth tolerance (> 0).
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub depth_error: Option<f64>,
381    /// Normal tolerance angle (degrees, (0, 180]).
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub normal_error: Option<f64>,
384    /// Reprojection tolerance (pixels, > 0).
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub reproj_error: Option<f64>,
387    /// Handling of remaining pixels (none|release|second-pass).
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub residual: Option<String>,
390    /// Smoothing of the fused cloud along normals: iterations (0 = off).
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub smooth_iters: Option<usize>,
393    /// Smoothing neighbour radius (multiple of the depth-map ground sample distance, > 0).
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub smooth_radius: Option<f64>,
396    /// Smoothing range sigma along the normal (ground-sample-distance multiple, > 0).
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub smooth_range: Option<f64>,
399    /// Smoothing neighbour normal gate (degrees, (0, 180]).
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub smooth_normal: Option<f64>,
402    /// Free-space visibility filter: neighbouring views checked per fused point (0 = off).
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub visibility_views: Option<usize>,
405    /// Free-space visibility filter: relative depth tolerance (> 0).
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub visibility_tau: Option<f64>,
408    /// Free-space visibility filter: minimum violating views to remove a point.
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub visibility_min_violations: Option<usize>,
411}
412
413/// Depth map filter overrides (same meaning as `--filter-*` and `--median-filter` of the `densify` subcommand). If unset, the profile
414/// default.
415#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
416#[serde(default, deny_unknown_fields)]
417pub struct Filter {
418    /// Minimum number of agreeing views.
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub min_views: Option<u32>,
421    /// Forward/backward reprojection tolerance (pixels, > 0).
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub geom_error: Option<f32>,
424    /// Minimum NCC ([-1, 1]).
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub min_ncc: Option<f32>,
427    /// 5x5 median plane filter before readout.
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub median: Option<bool>,
430    /// Edge-aware hole filling of the filtered depth maps.
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub fill_holes: Option<bool>,
433}
434
435impl Dense {
436    /// Start from a profile (fast|quality) and the defaults (on, backend cuda).
437    pub fn profile(name: &str) -> Self {
438        Self { profile: name.to_string(), ..Self::default() }
439    }
440    /// Densification turned off.
441    pub fn off() -> Self {
442        Self { enabled: false, ..Self::default() }
443    }
444    /// PatchMatch backend name.
445    pub fn backend(mut self, name: &str) -> Self {
446        self.backend = name.to_string();
447        self
448    }
449    /// Image undistortion backend name (cpu|cuda).
450    pub fn undistort_backend(mut self, name: &str) -> Self {
451        self.undistort_backend = name.to_string();
452        self
453    }
454    /// Maximum undistorted image size.
455    pub fn max_image_size(mut self, n: i64) -> Self {
456        self.max_image_size = n;
457        self
458    }
459    /// Number of source views.
460    pub fn views(mut self, n: usize) -> Self {
461        self.views = n;
462        self
463    }
464    /// Serialize preview/refined backend calls.
465    pub fn serialize(mut self, on: bool) -> Self {
466        self.serialize = on;
467        self
468    }
469    /// Reuse depth maps of overlapping images.
470    pub fn depth_cache(mut self, on: bool) -> Self {
471        self.depth_cache = on;
472        self
473    }
474    /// Fusion mode (consistency|traversal).
475    pub fn fusion_mode(mut self, mode: &str) -> Self {
476        self.fusion.mode = Some(mode.to_string());
477        self
478    }
479    /// Consistent fusion: minimum number of agreeing views besides the reference.
480    pub fn fusion_min_views(mut self, n: usize) -> Self {
481        self.fusion.min_views = Some(n);
482        self
483    }
484    /// Fusion relative depth tolerance.
485    pub fn fusion_depth_error(mut self, v: f64) -> Self {
486        self.fusion.depth_error = Some(v);
487        self
488    }
489    /// Fusion normal tolerance angle (degrees).
490    pub fn fusion_normal_error(mut self, deg: f64) -> Self {
491        self.fusion.normal_error = Some(deg);
492        self
493    }
494    /// Fusion reprojection tolerance (pixels).
495    pub fn fusion_reproj_error(mut self, px: f64) -> Self {
496        self.fusion.reproj_error = Some(px);
497        self
498    }
499    /// Handling of remaining pixels in consistent fusion (none|release|second-pass).
500    pub fn fusion_residual(mut self, mode: &str) -> Self {
501        self.fusion.residual = Some(mode.to_string());
502        self
503    }
504    /// Smoothing of the fused cloud along normals: iterations (0 = off).
505    pub fn fusion_smooth_iters(mut self, n: usize) -> Self {
506        self.fusion.smooth_iters = Some(n);
507        self
508    }
509    /// Smoothing neighbour radius (ground-sample-distance multiple).
510    pub fn fusion_smooth_radius(mut self, r: f64) -> Self {
511        self.fusion.smooth_radius = Some(r);
512        self
513    }
514    /// Smoothing range sigma along the normal (ground-sample-distance multiple).
515    pub fn fusion_smooth_range(mut self, r: f64) -> Self {
516        self.fusion.smooth_range = Some(r);
517        self
518    }
519    /// Smoothing neighbour normal gate (degrees).
520    pub fn fusion_smooth_normal(mut self, deg: f64) -> Self {
521        self.fusion.smooth_normal = Some(deg);
522        self
523    }
524    /// Upper bound on the number of scales (1 = a single scale at full resolution).
525    pub fn max_levels(mut self, n: usize) -> Self {
526        self.max_levels = Some(n);
527        self
528    }
529    /// Edge-aware hole filling of the filtered depth maps.
530    pub fn fill_holes(mut self, on: bool) -> Self {
531        self.filter.fill_holes = Some(on);
532        self
533    }
534    /// Depth map filter: minimum number of agreeing views.
535    pub fn filter_min_views(mut self, n: u32) -> Self {
536        self.filter.min_views = Some(n);
537        self
538    }
539    /// Depth map filter: forward/backward reprojection tolerance (pixels).
540    pub fn filter_geom_error(mut self, px: f32) -> Self {
541        self.filter.geom_error = Some(px);
542        self
543    }
544    /// Depth map filter: minimum NCC.
545    pub fn filter_min_ncc(mut self, v: f32) -> Self {
546        self.filter.min_ncc = Some(v);
547        self
548    }
549    /// Median plane filter before readout.
550    pub fn median_filter(mut self, on: bool) -> Self {
551        self.filter.median = Some(on);
552        self
553    }
554
555    /// Apply the overrides to the densification options (an unknown name is an error).
556    pub fn apply(&self, o: &mut DensifyOptions) -> Result<(), String> {
557        let f = &self.fusion;
558        if let Some(v) = self.max_levels {
559            o.max_levels = v;
560        }
561        if let Some(m) = &f.mode {
562            o.set_fusion_mode(parse_fusion_mode(m)?);
563        }
564        if let Some(v) = f.min_views {
565            o.fusion.min_consistent_views = v;
566        }
567        if let Some(v) = f.depth_error {
568            o.fusion.max_depth_error = v;
569        }
570        if let Some(v) = f.normal_error {
571            o.fusion.max_normal_error_deg = v;
572        }
573        if let Some(v) = f.reproj_error {
574            o.fusion.max_reproj_error = v;
575        }
576        if let Some(v) = f.smooth_iters {
577            o.fusion.smooth.iterations = v;
578        }
579        if let Some(v) = f.smooth_radius {
580            o.fusion.smooth.radius_gsd = v;
581        }
582        if let Some(v) = f.smooth_range {
583            o.fusion.smooth.sigma_range_gsd = v;
584        }
585        if let Some(v) = f.smooth_normal {
586            o.fusion.smooth.normal_deg = v;
587        }
588        if let Some(v) = f.visibility_views {
589            o.fusion.visibility.check_views = v;
590        }
591        if let Some(v) = f.visibility_tau {
592            o.fusion.visibility.tau = v;
593        }
594        if let Some(v) = f.visibility_min_violations {
595            o.fusion.visibility.min_violations = v;
596        }
597        if let Some(m) = &f.residual {
598            o.fusion.residual =
599                FusionResidual::parse(m).ok_or_else(|| format!("알 수 없는 융합 남은 픽셀 처리 {m} (none|release|second-pass)"))?;
600        }
601        let g = &self.filter;
602        if let Some(v) = g.min_views {
603            o.filter.min_num_consistent = v;
604        }
605        if let Some(v) = g.geom_error {
606            o.filter.geom_max_cost = v;
607        }
608        if let Some(v) = g.min_ncc {
609            o.filter.min_ncc = v;
610        }
611        if let Some(v) = g.median {
612            o.filter.median_filter = v;
613        }
614        if let Some(v) = g.fill_holes {
615            o.post.fill_holes = v;
616        }
617        Ok(())
618    }
619
620    /// Densification settings for running. The backend is created here (if there is no device, that error is reported in the
621    /// densification stage).
622    pub fn config(&self) -> Result<DenseConfig, String> {
623        let mut d = DenseConfig::new(make_pm_backend(&self.backend), parse_profile(&self.profile)?);
624        d.undistort_backend = make_undistort_backend(&self.undistort_backend)?;
625        d.undistort.max_image_size = self.max_image_size;
626        d.densify.neighbors.num_views = self.views;
627        self.apply(&mut d.densify)?;
628        if self.serialize {
629            d.lock = Some(Arc::new(Mutex::new(())));
630        }
631        if self.depth_cache {
632            d.depth_cache = Some(Arc::new(DepthMapCache::new()));
633        }
634        Ok(d)
635    }
636}
637
638fn parse_fusion_mode(m: &str) -> Result<FusionMode, String> {
639    FusionMode::parse(m).ok_or_else(|| format!("알 수 없는 융합 방식 {m} (consistency|traversal)"))
640}
641
642/// Output hook plan. If `out` is unset, no files are written (enabled file items are ignored, only hooks remain).
643#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
644#[serde(default, deny_unknown_fields)]
645pub struct Sinks {
646    /// Output folder.
647    #[serde(skip_serializing_if = "Option::is_none")]
648    pub out: Option<PathBuf>,
649    /// Delete and recreate the output folder when the run starts.
650    pub clean: bool,
651    /// Also write the run.log contents to standard output.
652    pub echo: bool,
653    /// `timeline.txt`.
654    pub timeline: bool,
655    /// `run.log`, `DONE`.
656    pub run_log: bool,
657    /// Zone PLY (`full/preview`, `full/refined`).
658    pub zone_ply: bool,
659    /// Alignment, snapshots, manifest, final_frame.
660    pub snapshots: bool,
661    /// Per-zone sparse models (`work/models/`).
662    pub models: bool,
663    /// Live display thinning: keep 1 of `live_stride` points by key hash (1 = keep all; ≥ 1).
664    pub live_stride: usize,
665}
666
667impl Default for Sinks {
668    /// [`Sinks::default_files`] without an output folder (the remaining values when only part of `[sinks]` is written in TOML).
669    fn default() -> Self {
670        Self { out: None, ..Self::default_files("") }
671    }
672}
673
674impl Sinks {
675    /// The same file output as `cumulus3d stream` (excluding sparse models, also logged to standard output, folder recreated at start).
676    pub fn default_files(out: impl Into<PathBuf>) -> Self {
677        Self {
678            out: Some(out.into()),
679            clean: true,
680            echo: true,
681            timeline: true,
682            run_log: true,
683            zone_ply: true,
684            snapshots: true,
685            models: false,
686            live_stride: crate::post::LiveOptions::default().stride,
687        }
688    }
689    /// No file output (no output folder, no standard output logging).
690    pub fn none() -> Self {
691        Self {
692            out: None,
693            clean: false,
694            echo: false,
695            timeline: false,
696            run_log: false,
697            zone_ply: false,
698            snapshots: false,
699            models: false,
700            live_stride: crate::post::LiveOptions::default().stride,
701        }
702    }
703    /// Whether to delete the output folder at start.
704    pub fn clean(mut self, on: bool) -> Self {
705        self.clean = on;
706        self
707    }
708    /// Whether to log to standard output.
709    pub fn echo(mut self, on: bool) -> Self {
710        self.echo = on;
711        self
712    }
713    /// Whether to write `timeline.txt`.
714    pub fn timeline(mut self, on: bool) -> Self {
715        self.timeline = on;
716        self
717    }
718    /// Whether to write `run.log`.
719    pub fn run_log(mut self, on: bool) -> Self {
720        self.run_log = on;
721        self
722    }
723    /// Whether to write zone PLY.
724    pub fn zone_ply(mut self, on: bool) -> Self {
725        self.zone_ply = on;
726        self
727    }
728    /// Whether to write snapshot/alignment output.
729    pub fn snapshots(mut self, on: bool) -> Self {
730        self.snapshots = on;
731        self
732    }
733    /// Whether to write per-zone sparse models.
734    pub fn models(mut self, on: bool) -> Self {
735        self.models = on;
736        self
737    }
738    /// Live display thinning stride (1 = keep all).
739    pub fn live_stride(mut self, stride: usize) -> Self {
740        self.live_stride = stride;
741        self
742    }
743    fn set(&self) -> SinkSet {
744        SinkSet {
745            timeline: self.timeline,
746            run_log: self.run_log,
747            zone_ply: self.zone_ply,
748            models: self.models,
749            snapshots: self.snapshots,
750            live_stream: None,
751            live_stride: self.live_stride,
752            ..SinkSet::all()
753        }
754    }
755}
756
757impl Plan {
758    /// As a TOML string.
759    pub fn to_toml(&self) -> String {
760        toml::to_string(self).expect("계획은 항상 TOML 로 바꿀 수 있다")
761    }
762
763    /// TOML with the profile's effective dense defaults written as comments under empty
764    /// `[dense.fusion]` / `[dense.filter]` tables (what `cumulus3d plan --print-default` prints).
765    /// Comments do not change the plan: `from_toml` of the result equals `self`.
766    pub fn to_toml_annotated(&self) -> String {
767        let s = self.to_toml();
768        let Ok(profile) = parse_profile(&self.dense.profile) else { return s };
769        let o = DensifyOptions::with_profile(profile);
770        let (f, g) = (&o.fusion, &o.filter);
771        let mode = match f.mode {
772            FusionMode::Consistency => "consistency",
773            FusionMode::Traversal => "traversal",
774        };
775        let fusion = format!(
776            "# {} profile defaults:\n# mode = \"{mode}\"\n# min_views = {}\n# depth_error = {}\n# normal_error = {}\n# reproj_error = {}\n# residual = \"{}\"\n# smooth_iters = {}\n# smooth_radius = {}\n# smooth_range = {}\n# smooth_normal = {}\n",
777            self.dense.profile, f.min_consistent_views, f.max_depth_error, f.max_normal_error_deg, f.max_reproj_error, f.residual.name(), f.smooth.iterations, f.smooth.radius_gsd, f.smooth.sigma_range_gsd, f.smooth.normal_deg
778        );
779        let filter = format!(
780            "# {} profile defaults:\n# min_views = {}\n# geom_error = {}\n# min_ncc = {}\n# median = {}\n# fill_holes = {}\n",
781            self.dense.profile, g.min_num_consistent, g.geom_max_cost, g.min_ncc, g.median_filter, o.post.fill_holes
782        );
783        let mut out = String::with_capacity(s.len() + fusion.len() + filter.len());
784        let mut lines = s.lines().peekable();
785        while let Some(l) = lines.next() {
786            out.push_str(l);
787            out.push('\n');
788            let empty = lines.peek().is_none_or(|n| n.trim().is_empty() || n.starts_with('['));
789            if empty && l == "[dense.fusion]" {
790                out.push_str(&fusion);
791            } else if empty && l == "[dense.filter]" {
792                out.push_str(&filter);
793            }
794        }
795        out
796    }
797
798    /// From a TOML string (missing items take defaults, unknown items are an error).
799    pub fn from_toml(s: &str) -> Result<Self, String> {
800        toml::from_str(s).map_err(|e| format!("계획 TOML: {e}"))
801    }
802
803    /// From a TOML file.
804    pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
805        let p = path.as_ref();
806        let s = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
807        Self::from_toml(&s).map_err(|e| format!("{}: {e}", p.display()))
808    }
809
810    /// The plan equivalent to the `cumulus3d stream` settings (`<src>/images`, `<src>/gps_ref.txt`, aerial-formation cameras).
811    pub fn from_stream(c: &StreamConfig) -> Self {
812        Self {
813            seed: c.seed,
814            threads: c.threads,
815            gps: Some(c.src.join("gps_ref.txt")),
816            pairing: Pairing::Formation,
817            input: Source {
818                images: c.src.join("images"),
819                cameras: CAMS.iter().map(|s| s.to_string()).collect(),
820                stride: c.stride,
821                positions: c.max_positions,
822            },
823            zones: Zones { span: c.span, overlap: c.overlap },
824            align: Align { frame: AlignFrame::Enu, fixed_origin: c.fixed_enu_origin },
825            features: Features {
826                backend: c.sift_backend.clone(),
827                max_num_features: c.max_num_features,
828                max_image_size: c.sift_max_image_size,
829            },
830            matching: Matching { backend: c.match_backend.clone() },
831            sparse: Sparse {
832                incremental_triangulation: c.incremental_triangulation,
833                stable_display: c.stable_display,
834                settle_tol: c.settle_tol,
835                settle_max_zone: c.settle_max_zone,
836                intrinsics_prior: c.intrinsics_prior.clone(),
837                intrinsics_prior_sigma: c.intrinsics_prior_sigma,
838                intrinsics_prior_dist_sigma: c.intrinsics_prior_dist_sigma,
839                ..Default::default()
840            },
841            live: Live { lookahead: c.lookahead, preview: c.preview, ..Live::default() },
842            dense: Dense {
843                enabled: !c.no_dense,
844                backend: c.pm_backend.clone(),
845                undistort_backend: c.undistort_backend.clone(),
846                profile: c.mvs_profile.clone(),
847                max_image_size: c.dense_max_image_size,
848                views: c.number_views,
849                max_levels: c.max_levels,
850                serialize: c.serialize_dense,
851                depth_cache: c.depth_cache,
852                fusion: c.fusion.clone(),
853                filter: Filter::default(),
854            },
855            sinks: Sinks { models: c.save_models, echo: c.echo, live_stride: c.live_stride, ..Sinks::default_files(&c.out) },
856        }
857    }
858}
859
860// ============================================================================================
861// Validation errors
862// ============================================================================================
863
864/// One plan validation problem.
865#[derive(Clone, Debug, PartialEq, Eq)]
866pub struct Problem {
867    /// Plan item (TOML path, e.g. `zones.overlap`).
868    pub field: String,
869    /// Description.
870    pub message: String,
871}
872
873/// Validation error of [`ReconBuilder::build`]. It holds all the problems found.
874#[derive(Clone, Debug, PartialEq, Eq)]
875pub struct PlanError {
876    /// List of problems (in the order found).
877    pub problems: Vec<Problem>,
878}
879
880impl PlanError {
881    /// Whether there is a problem for the given item.
882    pub fn has(&self, field: &str) -> bool {
883        self.problems.iter().any(|p| p.field == field)
884    }
885}
886
887impl fmt::Display for PlanError {
888    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
889        write!(f, "계획 검사 실패({} 건)", self.problems.len())?;
890        for p in &self.problems {
891            write!(f, "\n  - {}: {}", p.field, p.message)?;
892        }
893        Ok(())
894    }
895}
896
897impl std::error::Error for PlanError {}
898
899#[derive(Default)]
900struct Problems(Vec<Problem>);
901
902impl Problems {
903    fn add(&mut self, field: &str, message: impl Into<String>) {
904        self.0.push(Problem { field: field.to_string(), message: message.into() });
905    }
906    fn check(&mut self, ok: bool, field: &str, message: impl FnOnce() -> String) {
907        if !ok {
908            self.add(field, message());
909        }
910    }
911}
912
913// ============================================================================================
914// Builder
915// ============================================================================================
916
917type Attach = Box<dyn FnOnce(Pipeline<Session>) -> Pipeline<Session>>;
918
919/// Plan recorder. Methods only record and do not run anything. Validate with [`ReconBuilder::build`].
920pub struct ReconBuilder {
921    plan: Plan,
922    attach: Vec<Attach>,
923    pending: Vec<Problem>,
924    frames: bool,
925}
926
927impl fmt::Debug for ReconBuilder {
928    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
929        f.debug_struct("ReconBuilder").field("plan", &self.plan).field("hooks", &self.attach.len()).field("pending", &self.pending).finish()
930    }
931}
932
933impl ReconBuilder {
934    /// The plan recorded so far.
935    pub fn plan(&self) -> &Plan {
936        &self.plan
937    }
938
939    /// Input folder: images `<src>/images/<camera>/`, GPS `<src>/gps_ref.txt`.
940    pub fn input(mut self, src: impl AsRef<Path>) -> Self {
941        let src = src.as_ref();
942        self.plan.input.images = src.join("images");
943        self.plan.gps = Some(src.join("gps_ref.txt"));
944        self
945    }
946
947    /// Only specify the image folder (parent of the camera folders).
948    pub fn images(mut self, dir: impl Into<PathBuf>) -> Self {
949        self.plan.input.images = dir.into();
950        self
951    }
952
953    /// List of camera folders (frame-bundle order).
954    pub fn cameras<S: Into<String>>(mut self, cams: impl IntoIterator<Item = S>) -> Self {
955        self.plan.input.cameras = cams.into_iter().map(Into::into).collect();
956        self
957    }
958
959    /// Apply a preset (cameras, pairing strategy, stride). An unknown name is an error in `build`.
960    /// `aerial-formation`: camF/camR/camL, formation pairing rule, stride 3.
961    pub fn preset(mut self, name: &str) -> Self {
962        match name {
963            DEFAULT_PRESET => {
964                self.plan.input.cameras = CAMS.iter().map(|c| c.to_string()).collect();
965                self.plan.input.stride = 3;
966                self.plan.pairing = Pairing::Formation;
967            }
968            other => self
969                .pending
970                .push(Problem { field: "preset".into(), message: format!("알 수 없는 프리셋 {other} ({})", PRESETS.join("|")) }),
971        }
972        self
973    }
974
975    /// File number interval.
976    pub fn stride(mut self, n: usize) -> Self {
977        self.plan.input.stride = n;
978        self
979    }
980
981    /// Only the first N positions.
982    pub fn positions(mut self, n: usize) -> Self {
983        self.plan.input.positions = Some(n);
984        self
985    }
986
987    /// Pairing strategy.
988    pub fn pairing(mut self, p: Pairing) -> Self {
989        self.plan.pairing = p;
990        self
991    }
992
993    /// Zone size and overlap (number of positions).
994    pub fn zones(mut self, span: usize, overlap: usize) -> Self {
995        self.plan.zones = Zones { span, overlap };
996        self
997    }
998
999    /// GPS record file.
1000    pub fn gps(mut self, path: impl Into<PathBuf>) -> Self {
1001        self.plan.gps = Some(path.into());
1002        self
1003    }
1004
1005    /// No GPS file (to turn off alignment too, pass [`AlignFrame::None`] to [`ReconBuilder::align`]).
1006    pub fn no_gps(mut self) -> Self {
1007        self.plan.gps = None;
1008        self
1009    }
1010
1011    /// Alignment coordinate frame.
1012    pub fn align(mut self, frame: AlignFrame) -> Self {
1013        self.plan.align.frame = frame;
1014        self
1015    }
1016
1017    /// Fix the session ENU origin to the first GPS line.
1018    pub fn fixed_enu_origin(mut self, on: bool) -> Self {
1019        self.plan.align.fixed_origin = on;
1020        self
1021    }
1022
1023    /// Feature extraction settings.
1024    pub fn features(mut self, f: Features) -> Self {
1025        self.plan.features = f;
1026        self
1027    }
1028
1029    /// Matching backend (cpu|cuda).
1030    pub fn match_backend(mut self, name: &str) -> Self {
1031        self.plan.matching.backend = name.to_string();
1032        self
1033    }
1034
1035    /// Set the SIFT, matching, sparse-stage, PatchMatch, undistortion and bundle adjustment backends all to cuda.
1036    pub fn gpu(mut self) -> Self {
1037        self.plan.features.backend = "cuda".into();
1038        self.plan.matching.backend = "cuda".into();
1039        self.plan.sparse.backend = "cuda".into();
1040        self.plan.dense.backend = "cuda".into();
1041        self.plan.sparse.ba_backend = "cuda".into();
1042        self.plan.dense.undistort_backend = "cuda".into();
1043        self
1044    }
1045
1046    /// Triangulate only newly registered images.
1047    pub fn incremental_triangulation(mut self, on: bool) -> Self {
1048        self.plan.sparse.incremental_triangulation = on;
1049        self
1050    }
1051
1052    /// Stable display (see [`SessionConfig::stable_display`]).
1053    pub fn stable_display(mut self, on: bool) -> Self {
1054        self.plan.sparse.stable_display = on;
1055        self
1056    }
1057
1058    /// Stable display settling: intrinsics change tolerance and the last zone that may still settle
1059    /// (see [`SessionConfig::settle_tol`], [`SessionConfig::settle_max_zone`]).
1060    pub fn settle(mut self, tol: f64, max_zone: usize) -> Self {
1061        self.plan.sparse.settle_tol = tol;
1062        self.plan.sparse.settle_max_zone = max_zone;
1063        self
1064    }
1065
1066    /// Positions whose features are extracted ahead of the position loop (see [`SessionConfig::lookahead`]; 0 = off, the default).
1067    pub fn lookahead(mut self, positions: usize) -> Self {
1068        self.plan.live.lookahead = positions;
1069        self
1070    }
1071
1072    /// Stable display: sparse preview of every zone when it closes (see [`SessionConfig::preview`]).
1073    pub fn preview(mut self, on: bool) -> Self {
1074        self.plan.live.preview = on;
1075        self
1076    }
1077
1078    /// Densification plan (on).
1079    pub fn dense(mut self, d: Dense) -> Self {
1080        self.plan.dense = Dense { enabled: true, ..d };
1081        self
1082    }
1083
1084    /// Densification off.
1085    pub fn no_dense(mut self) -> Self {
1086        self.plan.dense.enabled = false;
1087        self
1088    }
1089
1090    /// Output hook plan.
1091    pub fn sinks(mut self, s: Sinks) -> Self {
1092        self.plan.sinks = s;
1093        self
1094    }
1095
1096    /// RANSAC seed.
1097    pub fn seed(mut self, seed: u64) -> Self {
1098        self.plan.seed = Some(seed);
1099        self
1100    }
1101
1102    /// Number of rayon threads (0 = number of cores).
1103    pub fn threads(mut self, n: usize) -> Self {
1104        self.plan.threads = n;
1105        self
1106    }
1107
1108    fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + 'static) -> Self {
1109        self.attach.push(Box::new(f));
1110        self
1111    }
1112
1113    /// A hook for a specific kind ([`Pipeline::on`]).
1114    /// Choosing `EventKind::FrameDecoded` turns frame decoding on.
1115    pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
1116    where
1117        F: FnMut(&crate::events::Event) + Send + 'static,
1118    {
1119        self.frames |= kind == EventKind::FrameDecoded;
1120        self.hook(move |p| p.on(kind, f))
1121    }
1122
1123    /// Frame hook ([`Pipeline::on_frame`]): receives each decoded input frame (RGB), one camera at a time, before the position is
1124    /// processed.
1125    /// Registering one turns frame decoding on.
1126    pub fn on_frame<F>(mut self, f: F) -> Self
1127    where
1128        F: FnMut(&Arc<Frame>) + Send + 'static,
1129    {
1130        self.frames = true;
1131        self.hook(move |p| p.on_frame(f))
1132    }
1133
1134    /// Hook for all events ([`Pipeline::on_any`]).
1135    pub fn on_any<F>(self, f: F) -> Self
1136    where
1137        F: FnMut(&crate::events::Event) + Send + 'static,
1138    {
1139        self.hook(move |p| p.on_any(f))
1140    }
1141
1142    /// Zone preview point-cloud hook ([`Pipeline::on_zone_preview`]).
1143    pub fn on_zone_preview<F>(self, f: F) -> Self
1144    where
1145        F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
1146    {
1147        self.hook(move |p| p.on_zone_preview(f))
1148    }
1149
1150    /// Zone refined point-cloud hook ([`Pipeline::on_zone_refined`]).
1151    pub fn on_zone_refined<F>(self, f: F) -> Self
1152    where
1153        F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
1154    {
1155        self.hook(move |p| p.on_zone_refined(f))
1156    }
1157
1158    /// Snapshot hook ([`Pipeline::on_snapshot`]).
1159    pub fn on_snapshot<F>(self, f: F) -> Self
1160    where
1161        F: FnMut(usize, &Arc<PointCloud>) + Send + 'static,
1162    {
1163        self.hook(move |p| p.on_snapshot(f))
1164    }
1165
1166    /// Position-done hook ([`Pipeline::on_position_done`]).
1167    pub fn on_position_done<F>(self, f: F) -> Self
1168    where
1169        F: FnMut(usize, usize, usize) + Send + 'static,
1170    {
1171        self.hook(move |p| p.on_position_done(f))
1172    }
1173
1174    /// Warning/error message hook ([`Pipeline::on_message`]).
1175    pub fn on_message<F>(self, f: F) -> Self
1176    where
1177        F: FnMut(EventKind, &str) + Send + 'static,
1178    {
1179        self.hook(move |p| p.on_message(f))
1180    }
1181
1182    /// Per-kind queue policy ([`Pipeline::policy`]).
1183    pub fn policy(self, kind: EventKind, p: QueuePolicy) -> Self {
1184        self.hook(move |pl| pl.policy(kind, p))
1185    }
1186
1187    /// Validate the plan -> an executable [`Recon`]. It neither creates nor deletes files or folders (it only reads the input folder
1188    /// listing and the GPS file).
1189    ///
1190    /// Checks: input and camera folders exist, number of selected positions, alignment and GPS file, backend names and device (when CUDA
1191    /// is requested),
1192    /// output folder writable, option value ranges. All problems are collected and returned at once as a [`PlanError`].
1193    pub fn build(self) -> Result<Recon, PlanError> {
1194        let ReconBuilder { plan, attach, pending, frames } = self;
1195        let mut pr = Problems(pending);
1196        let (layout, gps) = check(&plan, &mut pr);
1197        if !pr.0.is_empty() {
1198            return Err(PlanError { problems: pr.0 });
1199        }
1200        let (Some(layout), Some(gps)) = (layout, gps) else {
1201            unreachable!("문제가 없으면 입력과 GPS 를 읽었다");
1202        };
1203        Ok(Recon { plan, attach, layout, gps, frames })
1204    }
1205}
1206
1207fn backend_ok(pr: &mut Problems, field: &str, name: &str, allowed: &[&str]) {
1208    if !allowed.contains(&name) {
1209        pr.add(field, format!("알 수 없는 백엔드 {name} ({})", allowed.join("|")));
1210    } else if name == "cuda" && !cumulus3d_cuda::is_available() {
1211        pr.add(field, "CUDA 장치를 찾지 못함 — cuda 백엔드를 쓸 수 없다");
1212    }
1213}
1214
1215/// Whether the nearest existing ancestor folder is writable (checked without creating anything).
1216fn writable_target(p: &Path) -> Result<(), String> {
1217    if p.exists() {
1218        let m = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?;
1219        if !m.is_dir() {
1220            return Err(format!("{} 는 폴더가 아님", p.display()));
1221        }
1222        if m.permissions().readonly() {
1223            return Err(format!("{} 에 쓸 수 없음", p.display()));
1224        }
1225        return Ok(());
1226    }
1227    let mut a = p.parent();
1228    while let Some(d) = a {
1229        let d = if d.as_os_str().is_empty() { Path::new(".") } else { d };
1230        if d.exists() {
1231            let m = std::fs::metadata(d).map_err(|e| format!("{}: {e}", d.display()))?;
1232            if !m.is_dir() {
1233                return Err(format!("{} 는 폴더가 아님", d.display()));
1234            }
1235            if m.permissions().readonly() {
1236                return Err(format!("{} 에 쓸 수 없음", d.display()));
1237            }
1238            return Ok(());
1239        }
1240        a = d.parent();
1241    }
1242    Ok(())
1243}
1244
1245fn check(plan: &Plan, pr: &mut Problems) -> (Option<Layout>, Option<Vec<GpsRecord>>) {
1246    // Value ranges.
1247    let inp = &plan.input;
1248    pr.check(inp.stride >= 1, "input.stride", || "1 이상이어야 함".into());
1249    pr.check(inp.positions != Some(0), "input.positions", || "1 이상이어야 함".into());
1250    pr.check(!inp.cameras.is_empty(), "input.cameras", || "카메라 폴더 목록이 비어 있음".into());
1251    let mut seen = std::collections::BTreeSet::new();
1252    for c in &inp.cameras {
1253        if c.is_empty() || c.contains(['/', '\\']) {
1254            pr.add("input.cameras", format!("잘못된 카메라 폴더 이름 {c:?}"));
1255        } else if !seen.insert(c.as_str()) {
1256            pr.add("input.cameras", format!("카메라 {c} 중복"));
1257        }
1258    }
1259    let z = plan.zones;
1260    pr.check(z.span >= 1, "zones.span", || "1 이상이어야 함".into());
1261    pr.check(z.overlap < z.span.max(1), "zones.overlap", || format!("구역 크기({})보다 작아야 함 (지금 {})", z.span, z.overlap));
1262    let f = &plan.features;
1263    backend_ok(pr, "features.backend", &f.backend, &["cpu", "cuda"]);
1264    pr.check(f.max_num_features >= 1, "features.max_num_features", || "1 이상이어야 함".into());
1265    pr.check(f.max_image_size >= 16, "features.max_image_size", || "16 이상이어야 함".into());
1266    backend_ok(pr, "matching.backend", &plan.matching.backend, &["cpu", "cuda"]);
1267    backend_ok(pr, "sparse.backend", &plan.sparse.backend, &["cpu", "cuda"]);
1268    backend_ok(pr, "sparse.ba_backend", &plan.sparse.ba_backend, &["cpu", "cuda"]);
1269    backend_ok(pr, "dense.undistort_backend", &plan.dense.undistort_backend, &["cpu", "cuda"]);
1270
1271    let d = &plan.dense;
1272    if d.enabled {
1273        if d.backend != "cuda" {
1274            pr.add("dense.backend", format!("알 수 없는 백엔드 {} (cuda)", d.backend));
1275        } else if !cumulus3d_cuda::is_available() {
1276            pr.add("dense.backend", "조밀화에는 CUDA 장치가 필요한데 장치를 찾지 못함 — `.no_dense()` 또는 [dense] enabled = false");
1277        }
1278        if let Err(e) = parse_profile(&d.profile) {
1279            pr.add("dense.profile", e);
1280        }
1281        pr.check(d.max_image_size >= 16, "dense.max_image_size", || "16 이상이어야 함".into());
1282        pr.check((1..=32).contains(&d.views), "dense.views", || format!("1..=32 이어야 함 (지금 {})", d.views));
1283        if let Some(v) = d.max_levels {
1284            pr.check(v >= 1, "dense.max_levels", || "1 이상이어야 함".into());
1285        }
1286        let fu = &d.fusion;
1287        if let Some(m) = &fu.mode {
1288            if let Err(e) = parse_fusion_mode(m) {
1289                pr.add("dense.fusion.mode", e);
1290            }
1291        }
1292        if let Some(m) = &fu.residual {
1293            pr.check(FusionResidual::parse(m).is_some(), "dense.fusion.residual", || {
1294                format!("알 수 없는 처리 {m} (none|release|second-pass)")
1295            });
1296        }
1297        if let Some(v) = fu.min_views {
1298            pr.check(v >= 1, "dense.fusion.min_views", || "1 이상이어야 함".into());
1299        }
1300        if let Some(v) = fu.depth_error {
1301            pr.check(v > 0.0 && v.is_finite(), "dense.fusion.depth_error", || format!("양수여야 함 (지금 {v})"));
1302        }
1303        if let Some(v) = fu.normal_error {
1304            pr.check(v > 0.0 && v <= 180.0, "dense.fusion.normal_error", || format!("(0, 180] 이어야 함 (지금 {v})"));
1305        }
1306        if let Some(v) = fu.reproj_error {
1307            pr.check(v > 0.0 && v.is_finite(), "dense.fusion.reproj_error", || format!("양수여야 함 (지금 {v})"));
1308        }
1309        if let Some(v) = fu.visibility_tau {
1310            pr.check(v > 0.0 && v < 1.0, "dense.fusion.visibility_tau", || format!("(0, 1) 이어야 함 (지금 {v})"));
1311        }
1312        if let Some(v) = fu.smooth_radius {
1313            pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_radius", || format!("양수여야 함 (지금 {v})"));
1314        }
1315        if let Some(v) = fu.smooth_range {
1316            pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_range", || format!("양수여야 함 (지금 {v})"));
1317        }
1318        if let Some(v) = fu.smooth_normal {
1319            pr.check(v > 0.0 && v <= 180.0, "dense.fusion.smooth_normal", || format!("(0, 180] 이어야 함 (지금 {v})"));
1320        }
1321        let fi = &d.filter;
1322        if let Some(v) = fi.geom_error {
1323            pr.check(v > 0.0 && v.is_finite(), "dense.filter.geom_error", || format!("양수여야 함 (지금 {v})"));
1324        }
1325        if let Some(v) = fi.min_ncc {
1326            pr.check((-1.0..=1.0).contains(&v), "dense.filter.min_ncc", || format!("[-1, 1] 이어야 함 (지금 {v})"));
1327        }
1328    }
1329
1330    // Intrinsics prior file.
1331    if let Some(p) = &plan.sparse.intrinsics_prior {
1332        if let Err(e) = IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma) {
1333            pr.add("sparse.intrinsics_prior", e);
1334        }
1335    }
1336
1337    // Output.
1338    let s = &plan.sinks;
1339    pr.check(s.live_stride >= 1, "sinks.live_stride", || "must be >= 1 (1 = keep all)".into());
1340    if let Some(out) = &s.out {
1341        if let Err(e) = writable_target(out) {
1342            pr.add("sinks.out", e);
1343        }
1344        if s.clean && (inp.images.starts_with(out) || plan.gps.as_ref().is_some_and(|g| g.starts_with(out))) {
1345            pr.add("sinks.out", format!("시작 때 지울 출력 폴더 {} 안에 입력이 있음", out.display()));
1346        }
1347    }
1348
1349    // Input folders.
1350    let mut layout = None;
1351    if !inp.images.is_dir() {
1352        pr.add("input.images", format!("영상 폴더 없음: {}", inp.images.display()));
1353    } else {
1354        let missing: Vec<&String> = inp.cameras.iter().filter(|c| !inp.images.join(c).is_dir()).collect();
1355        for c in &missing {
1356            pr.add("input.cameras", format!("카메라 폴더 없음: {}", inp.images.join(c).display()));
1357        }
1358        if missing.is_empty() && !inp.cameras.is_empty() && inp.stride >= 1 {
1359            match Layout::discover_in(&inp.images, &inp.cameras, inp.stride) {
1360                Ok(mut l) => {
1361                    if let Some(n) = inp.positions {
1362                        l.truncate(n);
1363                    }
1364                    if l.frames.is_empty() {
1365                        pr.add(
1366                            "input",
1367                            format!(
1368                                "선택된 영상 없음: {}/{c}/{c}_NNNN.jpg 중 번호 % {} == 0 인 파일",
1369                                inp.images.display(),
1370                                inp.stride,
1371                                c = inp.cameras[0]
1372                            ),
1373                        );
1374                    } else {
1375                        layout = Some(l);
1376                    }
1377                }
1378                Err(e) => pr.add("input.images", e),
1379            }
1380        }
1381    }
1382
1383    // GPS and alignment.
1384    let mut gps = None;
1385    match (plan.align.frame, &plan.gps) {
1386        (AlignFrame::None, _) => gps = Some(Vec::new()),
1387        (AlignFrame::Enu, None) => pr.add("gps", "ENU 정렬에는 GPS 파일이 필요함 — `.gps(경로)` 또는 정렬 끔([align] frame = \"none\")"),
1388        (AlignFrame::Enu, Some(p)) => match read_gps_file(p) {
1389            Err(e) => pr.add("gps", format!("{}: {e}", p.display())),
1390            Ok(all) => {
1391                if let Some(l) = &layout {
1392                    let sel: Vec<GpsRecord> = all.into_iter().filter(|g| l.pos_of.contains_key(&g.name)).collect();
1393                    if sel.is_empty() {
1394                        pr.add("gps", format!("{}: 선택된 영상의 GPS 기록이 하나도 없음", p.display()));
1395                    } else {
1396                        gps = Some(sel);
1397                    }
1398                } else {
1399                    gps = Some(Vec::new());
1400                }
1401            }
1402        },
1403    }
1404    (layout, gps)
1405}
1406
1407// ============================================================================================
1408// Execution
1409// ============================================================================================
1410
1411/// An executable reconstruction that has passed validation. Run it with [`Recon::run`].
1412pub struct Recon {
1413    plan: Plan,
1414    attach: Vec<Attach>,
1415    layout: Layout,
1416    gps: Vec<GpsRecord>,
1417    frames: bool,
1418}
1419
1420impl fmt::Debug for Recon {
1421    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1422        f.debug_struct("Recon")
1423            .field("plan", &self.plan)
1424            .field("hooks", &self.attach.len())
1425            .field("positions", &self.layout.frames.len())
1426            .finish()
1427    }
1428}
1429
1430impl Recon {
1431    /// Start recording with the default plan ([`Plan::default`]).
1432    pub fn declare() -> ReconBuilder {
1433        Self::from_plan(Plan::default())
1434    }
1435
1436    /// Start recording with the given plan (e.g. a plan read from TOML).
1437    pub fn from_plan(plan: Plan) -> ReconBuilder {
1438        ReconBuilder { plan, attach: Vec::new(), pending: Vec::new(), frames: false }
1439    }
1440
1441    /// The plan.
1442    pub fn plan(&self) -> &Plan {
1443        &self.plan
1444    }
1445
1446    /// Number of selected positions.
1447    pub fn positions(&self) -> usize {
1448        self.layout.frames.len()
1449    }
1450
1451    /// Position <-> image name layout.
1452    pub fn layout(&self) -> &Layout {
1453        &self.layout
1454    }
1455
1456    /// Run: prepare the output folder -> session + pipeline + default output hook + user hooks -> position loop -> wait for background
1457    /// jobs and shut down.
1458    /// On a fatal error (session failure) it returns that error without post-processing.
1459    pub fn run(self) -> Result<Summary, String> {
1460        let Recon { plan, attach, layout, gps, frames } = self;
1461        if plan.threads > 0 {
1462            // Ignore if it was already created (tests etc.).
1463            let _ = rayon::ThreadPoolBuilder::new().num_threads(plan.threads).build_global();
1464        }
1465        // Without a backend, record an error in the densification stage.
1466        let dense = if plan.dense.enabled { Some(plan.dense.config()?) } else { None };
1467        let prior = match &plan.sparse.intrinsics_prior {
1468            Some(p) => {
1469                Some(Arc::new(IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma)?))
1470            }
1471            None => None,
1472        };
1473        let s = &plan.sinks;
1474        let sinks = match &s.out {
1475            Some(out) => {
1476                if s.clean && out.exists() {
1477                    std::fs::remove_dir_all(out).map_err(|e| format!("{}: {e}", out.display()))?;
1478                }
1479                let mut set = s.set();
1480                // Live diff stream: stable display only. In stable display the display frame is the ENU frame of the first
1481                // GPS record (zone 0 is brought into ENU and every later zone keeps that frame through the GPS priors).
1482                if plan.sparse.stable_display {
1483                    let origin = gps.first().map(|g| [g.lat, g.lon, g.alt]);
1484                    set.live_stream = Some(LiveStream { enu: origin.is_some(), origin_gps: origin });
1485                }
1486                Some(DefaultSinks::with_set(out, s.echo, set).map_err(|e| e.to_string())?)
1487            }
1488            None => None,
1489        };
1490        let npos = layout.frames.len();
1491        let mut sc = SessionConfig::new(plan.input.images.clone());
1492        sc.gps = gps;
1493        sc.span = plan.zones.span;
1494        sc.overlap = plan.zones.overlap;
1495        sc.total_positions = Some(npos);
1496        sc.incremental_triangulation = plan.sparse.incremental_triangulation;
1497        sc.stable_display = plan.sparse.stable_display;
1498        sc.settle_tol = plan.sparse.settle_tol;
1499        sc.settle_max_zone = plan.sparse.settle_max_zone;
1500        sc.intrinsics_prior = prior;
1501        sc.fixed_enu_origin = plan.align.fixed_origin;
1502        sc.seed = plan.seed;
1503        sc.extraction.sift.max_num_features = plan.features.max_num_features;
1504        sc.extraction.reader.max_image_size = plan.features.max_image_size;
1505        sc.sift = make_sift_backend(&plan.features.backend)?;
1506        sc.matcher = Arc::from(make_match_backend(&plan.matching.backend)?);
1507        sc.sparse = make_sparse_backend(&plan.sparse.backend)?;
1508        sc.ba_backend = make_ba_backend(&plan.sparse.ba_backend)?;
1509        sc.dense = dense;
1510        // Do not rewind (saves memory).
1511        sc.history = 0;
1512        sc.decode_frames = frames;
1513        sc.lookahead = plan.live.lookahead;
1514        sc.preview = plan.live.preview;
1515
1516        let mut pl = Pipeline::new(Session::new(sc));
1517        if let Some(sinks) = sinks {
1518            // There is a single output hook (on_any), so even if asynchronous, events are processed in order.
1519            pl = pl.on_any(sinks.into_hook());
1520        }
1521        for a in attach {
1522            pl = a(pl);
1523        }
1524
1525        let (span, ovl) = (plan.zones.span, plan.zones.overlap);
1526        let mut reg_end: BTreeMap<usize, Vec<ZoneRange>> = BTreeMap::new();
1527        for z in zones_upto(npos, span, ovl) {
1528            reg_end.entry(z.hi - 1).or_default().push(z);
1529        }
1530        pl.push(Input::Log(format!(
1531            "regions: {}",
1532            reg_end
1533                .iter()
1534                .map(|(e, v)| format!("{e} ->{}", v.iter().map(|z| format!(" {}:{}:{}", z.zone, z.lo, z.hi)).collect::<String>()))
1535                .collect::<Vec<_>>()
1536                .join(", ")
1537        )));
1538        let d = &plan.dense;
1539        pl.push(Input::Log(format!(
1540            "설정: span {span} overlap {ovl} stride {} 증분삼각측량 {} 깊이캐시 {} 고정원점 {} pm {}({})/sift {}/match {}/ba {} 직렬조밀화 {} 왜곡보정최대 {} 뷰 {} 스레드 {}",
1541            plan.input.stride,
1542            plan.sparse.incremental_triangulation,
1543            d.depth_cache,
1544            plan.align.fixed_origin,
1545            d.backend,
1546            d.profile,
1547            plan.features.backend,
1548            plan.matching.backend,
1549            plan.sparse.ba_backend,
1550            d.serialize,
1551            d.max_image_size,
1552            d.views,
1553            rayon::current_num_threads()
1554        )));
1555        let ahead = pl.reducer().lookahead();
1556        for p in 0..npos {
1557            // Extract the features of the next positions while this one is matched, registered and triangulated.
1558            for q in p..npos.min(p + 1 + plan.live.lookahead) {
1559                if !ahead.hint(&frame_set_of(&layout, &plan.input.cameras, q)) {
1560                    break;
1561                }
1562            }
1563            pl.push(frame_set_of(&layout, &plan.input.cameras, p).into());
1564            if let Some(e) = pl.reducer().failed() {
1565                // Fatal error: stop without post-processing (the hook queue is drained when the pipeline is dropped).
1566                return Err(e.to_string());
1567            }
1568        }
1569        let (_session, summary) = pl.finish();
1570        Ok(summary)
1571    }
1572}
1573
1574#[cfg(test)]
1575mod tests {
1576    use super::*;
1577
1578    #[test]
1579    fn default_plan_toml_roundtrip() {
1580        let p = Plan::default();
1581        let s = p.to_toml();
1582        assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
1583        // Empty document = default plan.
1584        assert_eq!(Plan::from_toml("").unwrap(), p);
1585    }
1586
1587    #[test]
1588    fn full_plan_toml_roundtrip() {
1589        let b = Recon::declare()
1590            .input("some/where")
1591            .cameras(["a", "b"])
1592            .stride(2)
1593            .positions(5)
1594            .zones(6, 1)
1595            .fixed_enu_origin(true)
1596            .incremental_triangulation(true)
1597            .dense(
1598                Dense::profile("quality")
1599                    .fusion_mode("traversal")
1600                    .fusion_min_views(3)
1601                    .fusion_depth_error(0.02)
1602                    .fusion_normal_error(12.5)
1603                    .fusion_reproj_error(1.5)
1604                    .fusion_residual("second-pass")
1605                    .fusion_smooth_normal(30.0)
1606                    .max_levels(1)
1607                    .filter_min_views(2)
1608                    .filter_geom_error(1.25)
1609                    .filter_min_ncc(0.1)
1610                    .median_filter(true)
1611                    .depth_cache(true)
1612                    .serialize(true),
1613            )
1614            .sinks(Sinks::default_files("o").models(true).echo(false))
1615            .seed(42)
1616            .threads(3);
1617        let p = b.plan().clone();
1618        let s = p.to_toml();
1619        assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
1620        assert!(s.contains("[dense.fusion]") && s.contains("min_views = 3"), "{s}");
1621        assert!(s.contains("smooth_normal = 30.0") && s.contains("max_levels = 1"), "{s}");
1622    }
1623
1624    #[test]
1625    fn example_plan_parses() {
1626        let s = include_str!("../../../examples/plans/aerial-formation.toml");
1627        let p = Plan::from_toml(s).unwrap();
1628        assert_eq!(p.input.cameras, ["camF", "camR", "camL"]);
1629        assert_eq!(p.pairing, Pairing::Formation);
1630        assert_eq!(Plan::from_toml(&p.to_toml()).unwrap(), p);
1631    }
1632
1633    #[test]
1634    fn unknown_field_rejected() {
1635        assert!(Plan::from_toml("[zones]\nspam = 3\n").is_err());
1636        assert!(Plan::from_toml("pairing = \"grid\"\n").is_err());
1637    }
1638
1639    #[test]
1640    fn stream_plan_matches_builder() {
1641        let mut c = StreamConfig::new("src".into(), "dst".into());
1642        (c.stride, c.span, c.overlap, c.seed, c.save_models, c.echo) = (1, 4, 1, Some(7), true, false);
1643        let b = Recon::declare()
1644            .input("src")
1645            .preset(DEFAULT_PRESET)
1646            .stride(1)
1647            .zones(4, 1)
1648            .seed(7)
1649            .sinks(Sinks::default_files("dst").models(true).echo(false));
1650        assert_eq!(Plan::from_stream(&c), *b.plan());
1651    }
1652
1653    #[test]
1654    fn build_collects_all_problems() {
1655        let tmp = std::env::temp_dir().join(format!("cumulus3d_declare_missing_{}", std::process::id()));
1656        let e = Recon::declare()
1657            .input(tmp.join("in"))
1658            .preset("grid")
1659            .stride(0)
1660            .zones(4, 4)
1661            .dense(Dense::profile("slow").views(40).max_levels(0).fusion_smooth_normal(0.0).fusion_mode("magic").filter_min_ncc(2.0))
1662            .features(Features { backend: "fpga".into(), ..Features::default() })
1663            .sinks(Sinks::default_files(tmp.join("in")).clean(true))
1664            .build()
1665            .unwrap_err();
1666        for f in [
1667            "preset",
1668            "input.stride",
1669            "zones.overlap",
1670            "features.backend",
1671            "dense.profile",
1672            "dense.views",
1673            "dense.fusion.mode",
1674            "dense.max_levels",
1675            "dense.fusion.smooth_normal",
1676            "dense.filter.min_ncc",
1677            "sinks.out",
1678            "input.images",
1679            "gps",
1680        ] {
1681            assert!(e.has(f), "{f} 없음:\n{e}");
1682        }
1683        assert_eq!(e.has("dense.backend"), !cumulus3d_cuda::is_available());
1684        assert!(!tmp.exists());
1685        assert!(e.to_string().starts_with("계획 검사 실패("));
1686    }
1687
1688    #[test]
1689    fn live_stride_roundtrip_and_validation() {
1690        assert_eq!(Sinks::default().live_stride, 6);
1691        let p = Plan { sinks: Sinks::default_files("o").live_stride(3), ..Plan::default() };
1692        assert_eq!(Plan::from_toml(&p.to_toml()).unwrap().sinks.live_stride, 3);
1693        let e = Recon::declare().sinks(Sinks::default_files("o").live_stride(0)).no_dense().build().unwrap_err();
1694        assert!(e.has("sinks.live_stride"), "{e}");
1695    }
1696
1697    #[test]
1698    fn enu_without_gps_is_error() {
1699        let e = Recon::declare().no_gps().no_dense().build().unwrap_err();
1700        assert!(e.has("gps"), "{e}");
1701        let e = Recon::declare().no_gps().align(AlignFrame::None).no_dense().build().unwrap_err();
1702        assert!(!e.has("gps"), "{e}");
1703    }
1704}