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