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