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            ..SinkSet::all()
728        }
729    }
730}
731
732impl Plan {
733    /// As a TOML string.
734    pub fn to_toml(&self) -> String {
735        toml::to_string(self).expect("계획은 항상 TOML 로 바꿀 수 있다")
736    }
737
738    /// TOML with the profile's effective dense defaults written as comments under empty
739    /// `[dense.fusion]` / `[dense.filter]` tables (what `cumulus3d plan --print-default` prints).
740    /// Comments do not change the plan: `from_toml` of the result equals `self`.
741    pub fn to_toml_annotated(&self) -> String {
742        let s = self.to_toml();
743        let Ok(profile) = parse_profile(&self.dense.profile) else { return s };
744        let o = DensifyOptions::with_profile(profile);
745        let (f, g) = (&o.fusion, &o.filter);
746        let mode = match f.mode {
747            FusionMode::Consistency => "consistency",
748            FusionMode::Traversal => "traversal",
749        };
750        let fusion = format!(
751            "# {} 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",
752            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
753        );
754        let filter = format!(
755            "# {} profile defaults:\n# min_views = {}\n# geom_error = {}\n# min_ncc = {}\n# median = {}\n# fill_holes = {}\n",
756            self.dense.profile, g.min_num_consistent, g.geom_max_cost, g.min_ncc, g.median_filter, o.post.fill_holes
757        );
758        let mut out = String::with_capacity(s.len() + fusion.len() + filter.len());
759        let mut lines = s.lines().peekable();
760        while let Some(l) = lines.next() {
761            out.push_str(l);
762            out.push('\n');
763            let empty = lines.peek().is_none_or(|n| n.trim().is_empty() || n.starts_with('['));
764            if empty && l == "[dense.fusion]" {
765                out.push_str(&fusion);
766            } else if empty && l == "[dense.filter]" {
767                out.push_str(&filter);
768            }
769        }
770        out
771    }
772
773    /// From a TOML string (missing items take defaults, unknown items are an error).
774    pub fn from_toml(s: &str) -> Result<Self, String> {
775        toml::from_str(s).map_err(|e| format!("계획 TOML: {e}"))
776    }
777
778    /// From a TOML file.
779    pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
780        let p = path.as_ref();
781        let s = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
782        Self::from_toml(&s).map_err(|e| format!("{}: {e}", p.display()))
783    }
784
785    /// The plan equivalent to the `cumulus3d stream` settings (`<src>/images`, `<src>/gps_ref.txt`, aerial-formation cameras).
786    pub fn from_stream(c: &StreamConfig) -> Self {
787        Self {
788            seed: c.seed,
789            threads: c.threads,
790            gps: Some(c.src.join("gps_ref.txt")),
791            pairing: Pairing::Formation,
792            input: Source {
793                images: c.src.join("images"),
794                cameras: CAMS.iter().map(|s| s.to_string()).collect(),
795                stride: c.stride,
796                positions: c.max_positions,
797            },
798            zones: Zones { span: c.span, overlap: c.overlap },
799            align: Align { frame: AlignFrame::Enu, fixed_origin: c.fixed_enu_origin },
800            features: Features {
801                backend: c.sift_backend.clone(),
802                max_num_features: c.max_num_features,
803                max_image_size: c.sift_max_image_size,
804            },
805            matching: Matching { backend: c.match_backend.clone() },
806            sparse: Sparse {
807                incremental_triangulation: c.incremental_triangulation,
808                stable_display: c.stable_display,
809                settle_tol: c.settle_tol,
810                settle_max_zone: c.settle_max_zone,
811                intrinsics_prior: c.intrinsics_prior.clone(),
812                intrinsics_prior_sigma: c.intrinsics_prior_sigma,
813                intrinsics_prior_dist_sigma: c.intrinsics_prior_dist_sigma,
814                ..Default::default()
815            },
816            dense: Dense {
817                enabled: !c.no_dense,
818                backend: c.pm_backend.clone(),
819                undistort_backend: c.undistort_backend.clone(),
820                profile: c.mvs_profile.clone(),
821                max_image_size: c.dense_max_image_size,
822                views: c.number_views,
823                max_levels: c.max_levels,
824                serialize: c.serialize_dense,
825                depth_cache: c.depth_cache,
826                fusion: c.fusion.clone(),
827                filter: Filter::default(),
828            },
829            sinks: Sinks { models: c.save_models, echo: c.echo, live_stride: c.live_stride, ..Sinks::default_files(&c.out) },
830        }
831    }
832}
833
834// ============================================================================================
835// Validation errors
836// ============================================================================================
837
838/// One plan validation problem.
839#[derive(Clone, Debug, PartialEq, Eq)]
840pub struct Problem {
841    /// Plan item (TOML path, e.g. `zones.overlap`).
842    pub field: String,
843    /// Description.
844    pub message: String,
845}
846
847/// Validation error of [`ReconBuilder::build`]. It holds all the problems found.
848#[derive(Clone, Debug, PartialEq, Eq)]
849pub struct PlanError {
850    /// List of problems (in the order found).
851    pub problems: Vec<Problem>,
852}
853
854impl PlanError {
855    /// Whether there is a problem for the given item.
856    pub fn has(&self, field: &str) -> bool {
857        self.problems.iter().any(|p| p.field == field)
858    }
859}
860
861impl fmt::Display for PlanError {
862    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
863        write!(f, "계획 검사 실패({} 건)", self.problems.len())?;
864        for p in &self.problems {
865            write!(f, "\n  - {}: {}", p.field, p.message)?;
866        }
867        Ok(())
868    }
869}
870
871impl std::error::Error for PlanError {}
872
873#[derive(Default)]
874struct Problems(Vec<Problem>);
875
876impl Problems {
877    fn add(&mut self, field: &str, message: impl Into<String>) {
878        self.0.push(Problem { field: field.to_string(), message: message.into() });
879    }
880    fn check(&mut self, ok: bool, field: &str, message: impl FnOnce() -> String) {
881        if !ok {
882            self.add(field, message());
883        }
884    }
885}
886
887// ============================================================================================
888// Builder
889// ============================================================================================
890
891type Attach = Box<dyn FnOnce(Pipeline<Session>) -> Pipeline<Session>>;
892
893/// Plan recorder. Methods only record and do not run anything. Validate with [`ReconBuilder::build`].
894pub struct ReconBuilder {
895    plan: Plan,
896    attach: Vec<Attach>,
897    pending: Vec<Problem>,
898    frames: bool,
899}
900
901impl fmt::Debug for ReconBuilder {
902    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
903        f.debug_struct("ReconBuilder").field("plan", &self.plan).field("hooks", &self.attach.len()).field("pending", &self.pending).finish()
904    }
905}
906
907impl ReconBuilder {
908    /// The plan recorded so far.
909    pub fn plan(&self) -> &Plan {
910        &self.plan
911    }
912
913    /// Input folder: images `<src>/images/<camera>/`, GPS `<src>/gps_ref.txt`.
914    pub fn input(mut self, src: impl AsRef<Path>) -> Self {
915        let src = src.as_ref();
916        self.plan.input.images = src.join("images");
917        self.plan.gps = Some(src.join("gps_ref.txt"));
918        self
919    }
920
921    /// Only specify the image folder (parent of the camera folders).
922    pub fn images(mut self, dir: impl Into<PathBuf>) -> Self {
923        self.plan.input.images = dir.into();
924        self
925    }
926
927    /// List of camera folders (frame-bundle order).
928    pub fn cameras<S: Into<String>>(mut self, cams: impl IntoIterator<Item = S>) -> Self {
929        self.plan.input.cameras = cams.into_iter().map(Into::into).collect();
930        self
931    }
932
933    /// Apply a preset (cameras, pairing strategy, stride). An unknown name is an error in `build`.
934    /// `aerial-formation`: camF/camR/camL, formation pairing rule, stride 3.
935    pub fn preset(mut self, name: &str) -> Self {
936        match name {
937            DEFAULT_PRESET => {
938                self.plan.input.cameras = CAMS.iter().map(|c| c.to_string()).collect();
939                self.plan.input.stride = 3;
940                self.plan.pairing = Pairing::Formation;
941            }
942            other => self
943                .pending
944                .push(Problem { field: "preset".into(), message: format!("알 수 없는 프리셋 {other} ({})", PRESETS.join("|")) }),
945        }
946        self
947    }
948
949    /// File number interval.
950    pub fn stride(mut self, n: usize) -> Self {
951        self.plan.input.stride = n;
952        self
953    }
954
955    /// Only the first N positions.
956    pub fn positions(mut self, n: usize) -> Self {
957        self.plan.input.positions = Some(n);
958        self
959    }
960
961    /// Pairing strategy.
962    pub fn pairing(mut self, p: Pairing) -> Self {
963        self.plan.pairing = p;
964        self
965    }
966
967    /// Zone size and overlap (number of positions).
968    pub fn zones(mut self, span: usize, overlap: usize) -> Self {
969        self.plan.zones = Zones { span, overlap };
970        self
971    }
972
973    /// GPS record file.
974    pub fn gps(mut self, path: impl Into<PathBuf>) -> Self {
975        self.plan.gps = Some(path.into());
976        self
977    }
978
979    /// No GPS file (to turn off alignment too, pass [`AlignFrame::None`] to [`ReconBuilder::align`]).
980    pub fn no_gps(mut self) -> Self {
981        self.plan.gps = None;
982        self
983    }
984
985    /// Alignment coordinate frame.
986    pub fn align(mut self, frame: AlignFrame) -> Self {
987        self.plan.align.frame = frame;
988        self
989    }
990
991    /// Fix the session ENU origin to the first GPS line.
992    pub fn fixed_enu_origin(mut self, on: bool) -> Self {
993        self.plan.align.fixed_origin = on;
994        self
995    }
996
997    /// Feature extraction settings.
998    pub fn features(mut self, f: Features) -> Self {
999        self.plan.features = f;
1000        self
1001    }
1002
1003    /// Matching backend (cpu|cuda).
1004    pub fn match_backend(mut self, name: &str) -> Self {
1005        self.plan.matching.backend = name.to_string();
1006        self
1007    }
1008
1009    /// Set the SIFT, matching, sparse-stage, PatchMatch, undistortion and bundle adjustment backends all to cuda.
1010    pub fn gpu(mut self) -> Self {
1011        self.plan.features.backend = "cuda".into();
1012        self.plan.matching.backend = "cuda".into();
1013        self.plan.sparse.backend = "cuda".into();
1014        self.plan.dense.backend = "cuda".into();
1015        self.plan.sparse.ba_backend = "cuda".into();
1016        self.plan.dense.undistort_backend = "cuda".into();
1017        self
1018    }
1019
1020    /// Triangulate only newly registered images.
1021    pub fn incremental_triangulation(mut self, on: bool) -> Self {
1022        self.plan.sparse.incremental_triangulation = on;
1023        self
1024    }
1025
1026    /// Stable display (see [`SessionConfig::stable_display`]).
1027    pub fn stable_display(mut self, on: bool) -> Self {
1028        self.plan.sparse.stable_display = on;
1029        self
1030    }
1031
1032    /// Stable display settling: intrinsics change tolerance and the last zone that may still settle
1033    /// (see [`SessionConfig::settle_tol`], [`SessionConfig::settle_max_zone`]).
1034    pub fn settle(mut self, tol: f64, max_zone: usize) -> Self {
1035        self.plan.sparse.settle_tol = tol;
1036        self.plan.sparse.settle_max_zone = max_zone;
1037        self
1038    }
1039
1040    /// Densification plan (on).
1041    pub fn dense(mut self, d: Dense) -> Self {
1042        self.plan.dense = Dense { enabled: true, ..d };
1043        self
1044    }
1045
1046    /// Densification off.
1047    pub fn no_dense(mut self) -> Self {
1048        self.plan.dense.enabled = false;
1049        self
1050    }
1051
1052    /// Output hook plan.
1053    pub fn sinks(mut self, s: Sinks) -> Self {
1054        self.plan.sinks = s;
1055        self
1056    }
1057
1058    /// RANSAC seed.
1059    pub fn seed(mut self, seed: u64) -> Self {
1060        self.plan.seed = Some(seed);
1061        self
1062    }
1063
1064    /// Number of rayon threads (0 = number of cores).
1065    pub fn threads(mut self, n: usize) -> Self {
1066        self.plan.threads = n;
1067        self
1068    }
1069
1070    fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + 'static) -> Self {
1071        self.attach.push(Box::new(f));
1072        self
1073    }
1074
1075    /// A hook for a specific kind ([`Pipeline::on`]).
1076    /// Choosing `EventKind::FrameDecoded` turns frame decoding on.
1077    pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
1078    where
1079        F: FnMut(&crate::events::Event) + Send + 'static,
1080    {
1081        self.frames |= kind == EventKind::FrameDecoded;
1082        self.hook(move |p| p.on(kind, f))
1083    }
1084
1085    /// Frame hook ([`Pipeline::on_frame`]): receives each decoded input frame (RGB), one camera at a time, before the position is
1086    /// processed.
1087    /// Registering one turns frame decoding on.
1088    pub fn on_frame<F>(mut self, f: F) -> Self
1089    where
1090        F: FnMut(&Arc<Frame>) + Send + 'static,
1091    {
1092        self.frames = true;
1093        self.hook(move |p| p.on_frame(f))
1094    }
1095
1096    /// Hook for all events ([`Pipeline::on_any`]).
1097    pub fn on_any<F>(self, f: F) -> Self
1098    where
1099        F: FnMut(&crate::events::Event) + Send + 'static,
1100    {
1101        self.hook(move |p| p.on_any(f))
1102    }
1103
1104    /// Zone preview point-cloud hook ([`Pipeline::on_zone_preview`]).
1105    pub fn on_zone_preview<F>(self, f: F) -> Self
1106    where
1107        F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
1108    {
1109        self.hook(move |p| p.on_zone_preview(f))
1110    }
1111
1112    /// Zone refined point-cloud hook ([`Pipeline::on_zone_refined`]).
1113    pub fn on_zone_refined<F>(self, f: F) -> Self
1114    where
1115        F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
1116    {
1117        self.hook(move |p| p.on_zone_refined(f))
1118    }
1119
1120    /// Snapshot hook ([`Pipeline::on_snapshot`]).
1121    pub fn on_snapshot<F>(self, f: F) -> Self
1122    where
1123        F: FnMut(usize, &Arc<PointCloud>) + Send + 'static,
1124    {
1125        self.hook(move |p| p.on_snapshot(f))
1126    }
1127
1128    /// Position-done hook ([`Pipeline::on_position_done`]).
1129    pub fn on_position_done<F>(self, f: F) -> Self
1130    where
1131        F: FnMut(usize, usize, usize) + Send + 'static,
1132    {
1133        self.hook(move |p| p.on_position_done(f))
1134    }
1135
1136    /// Warning/error message hook ([`Pipeline::on_message`]).
1137    pub fn on_message<F>(self, f: F) -> Self
1138    where
1139        F: FnMut(EventKind, &str) + Send + 'static,
1140    {
1141        self.hook(move |p| p.on_message(f))
1142    }
1143
1144    /// Per-kind queue policy ([`Pipeline::policy`]).
1145    pub fn policy(self, kind: EventKind, p: QueuePolicy) -> Self {
1146        self.hook(move |pl| pl.policy(kind, p))
1147    }
1148
1149    /// Validate the plan -> an executable [`Recon`]. It neither creates nor deletes files or folders (it only reads the input folder
1150    /// listing and the GPS file).
1151    ///
1152    /// Checks: input and camera folders exist, number of selected positions, alignment and GPS file, backend names and device (when CUDA
1153    /// is requested),
1154    /// output folder writable, option value ranges. All problems are collected and returned at once as a [`PlanError`].
1155    pub fn build(self) -> Result<Recon, PlanError> {
1156        let ReconBuilder { plan, attach, pending, frames } = self;
1157        let mut pr = Problems(pending);
1158        let (layout, gps) = check(&plan, &mut pr);
1159        if !pr.0.is_empty() {
1160            return Err(PlanError { problems: pr.0 });
1161        }
1162        let (Some(layout), Some(gps)) = (layout, gps) else {
1163            unreachable!("문제가 없으면 입력과 GPS 를 읽었다");
1164        };
1165        Ok(Recon { plan, attach, layout, gps, frames })
1166    }
1167}
1168
1169fn backend_ok(pr: &mut Problems, field: &str, name: &str, allowed: &[&str]) {
1170    if !allowed.contains(&name) {
1171        pr.add(field, format!("알 수 없는 백엔드 {name} ({})", allowed.join("|")));
1172    } else if name == "cuda" && !cumulus3d_cuda::is_available() {
1173        pr.add(field, "CUDA 장치를 찾지 못함 — cuda 백엔드를 쓸 수 없다");
1174    }
1175}
1176
1177/// Whether the nearest existing ancestor folder is writable (checked without creating anything).
1178fn writable_target(p: &Path) -> Result<(), String> {
1179    if p.exists() {
1180        let m = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?;
1181        if !m.is_dir() {
1182            return Err(format!("{} 는 폴더가 아님", p.display()));
1183        }
1184        if m.permissions().readonly() {
1185            return Err(format!("{} 에 쓸 수 없음", p.display()));
1186        }
1187        return Ok(());
1188    }
1189    let mut a = p.parent();
1190    while let Some(d) = a {
1191        let d = if d.as_os_str().is_empty() { Path::new(".") } else { d };
1192        if d.exists() {
1193            let m = std::fs::metadata(d).map_err(|e| format!("{}: {e}", d.display()))?;
1194            if !m.is_dir() {
1195                return Err(format!("{} 는 폴더가 아님", d.display()));
1196            }
1197            if m.permissions().readonly() {
1198                return Err(format!("{} 에 쓸 수 없음", d.display()));
1199            }
1200            return Ok(());
1201        }
1202        a = d.parent();
1203    }
1204    Ok(())
1205}
1206
1207fn check(plan: &Plan, pr: &mut Problems) -> (Option<Layout>, Option<Vec<GpsRecord>>) {
1208    // Value ranges.
1209    let inp = &plan.input;
1210    pr.check(inp.stride >= 1, "input.stride", || "1 이상이어야 함".into());
1211    pr.check(inp.positions != Some(0), "input.positions", || "1 이상이어야 함".into());
1212    pr.check(!inp.cameras.is_empty(), "input.cameras", || "카메라 폴더 목록이 비어 있음".into());
1213    let mut seen = std::collections::BTreeSet::new();
1214    for c in &inp.cameras {
1215        if c.is_empty() || c.contains(['/', '\\']) {
1216            pr.add("input.cameras", format!("잘못된 카메라 폴더 이름 {c:?}"));
1217        } else if !seen.insert(c.as_str()) {
1218            pr.add("input.cameras", format!("카메라 {c} 중복"));
1219        }
1220    }
1221    let z = plan.zones;
1222    pr.check(z.span >= 1, "zones.span", || "1 이상이어야 함".into());
1223    pr.check(z.overlap < z.span.max(1), "zones.overlap", || format!("구역 크기({})보다 작아야 함 (지금 {})", z.span, z.overlap));
1224    let f = &plan.features;
1225    backend_ok(pr, "features.backend", &f.backend, &["cpu", "cuda"]);
1226    pr.check(f.max_num_features >= 1, "features.max_num_features", || "1 이상이어야 함".into());
1227    pr.check(f.max_image_size >= 16, "features.max_image_size", || "16 이상이어야 함".into());
1228    backend_ok(pr, "matching.backend", &plan.matching.backend, &["cpu", "cuda"]);
1229    backend_ok(pr, "sparse.backend", &plan.sparse.backend, &["cpu", "cuda"]);
1230    backend_ok(pr, "sparse.ba_backend", &plan.sparse.ba_backend, &["cpu", "cuda"]);
1231    backend_ok(pr, "dense.undistort_backend", &plan.dense.undistort_backend, &["cpu", "cuda"]);
1232
1233    let d = &plan.dense;
1234    if d.enabled {
1235        if d.backend != "cuda" {
1236            pr.add("dense.backend", format!("알 수 없는 백엔드 {} (cuda)", d.backend));
1237        } else if !cumulus3d_cuda::is_available() {
1238            pr.add("dense.backend", "조밀화에는 CUDA 장치가 필요한데 장치를 찾지 못함 — `.no_dense()` 또는 [dense] enabled = false");
1239        }
1240        if let Err(e) = parse_profile(&d.profile) {
1241            pr.add("dense.profile", e);
1242        }
1243        pr.check(d.max_image_size >= 16, "dense.max_image_size", || "16 이상이어야 함".into());
1244        pr.check((1..=32).contains(&d.views), "dense.views", || format!("1..=32 이어야 함 (지금 {})", d.views));
1245        if let Some(v) = d.max_levels {
1246            pr.check(v >= 1, "dense.max_levels", || "1 이상이어야 함".into());
1247        }
1248        let fu = &d.fusion;
1249        if let Some(m) = &fu.mode {
1250            if let Err(e) = parse_fusion_mode(m) {
1251                pr.add("dense.fusion.mode", e);
1252            }
1253        }
1254        if let Some(m) = &fu.residual {
1255            pr.check(FusionResidual::parse(m).is_some(), "dense.fusion.residual", || {
1256                format!("알 수 없는 처리 {m} (none|release|second-pass)")
1257            });
1258        }
1259        if let Some(v) = fu.min_views {
1260            pr.check(v >= 1, "dense.fusion.min_views", || "1 이상이어야 함".into());
1261        }
1262        if let Some(v) = fu.depth_error {
1263            pr.check(v > 0.0 && v.is_finite(), "dense.fusion.depth_error", || format!("양수여야 함 (지금 {v})"));
1264        }
1265        if let Some(v) = fu.normal_error {
1266            pr.check(v > 0.0 && v <= 180.0, "dense.fusion.normal_error", || format!("(0, 180] 이어야 함 (지금 {v})"));
1267        }
1268        if let Some(v) = fu.reproj_error {
1269            pr.check(v > 0.0 && v.is_finite(), "dense.fusion.reproj_error", || format!("양수여야 함 (지금 {v})"));
1270        }
1271        if let Some(v) = fu.visibility_tau {
1272            pr.check(v > 0.0 && v < 1.0, "dense.fusion.visibility_tau", || format!("(0, 1) 이어야 함 (지금 {v})"));
1273        }
1274        if let Some(v) = fu.smooth_radius {
1275            pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_radius", || format!("양수여야 함 (지금 {v})"));
1276        }
1277        if let Some(v) = fu.smooth_range {
1278            pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_range", || format!("양수여야 함 (지금 {v})"));
1279        }
1280        if let Some(v) = fu.smooth_normal {
1281            pr.check(v > 0.0 && v <= 180.0, "dense.fusion.smooth_normal", || format!("(0, 180] 이어야 함 (지금 {v})"));
1282        }
1283        let fi = &d.filter;
1284        if let Some(v) = fi.geom_error {
1285            pr.check(v > 0.0 && v.is_finite(), "dense.filter.geom_error", || format!("양수여야 함 (지금 {v})"));
1286        }
1287        if let Some(v) = fi.min_ncc {
1288            pr.check((-1.0..=1.0).contains(&v), "dense.filter.min_ncc", || format!("[-1, 1] 이어야 함 (지금 {v})"));
1289        }
1290    }
1291
1292    // Intrinsics prior file.
1293    if let Some(p) = &plan.sparse.intrinsics_prior {
1294        if let Err(e) = IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma) {
1295            pr.add("sparse.intrinsics_prior", e);
1296        }
1297    }
1298
1299    // Output.
1300    let s = &plan.sinks;
1301    pr.check(s.live_stride >= 1, "sinks.live_stride", || "must be >= 1 (1 = keep all)".into());
1302    if let Some(out) = &s.out {
1303        if let Err(e) = writable_target(out) {
1304            pr.add("sinks.out", e);
1305        }
1306        if s.clean && (inp.images.starts_with(out) || plan.gps.as_ref().is_some_and(|g| g.starts_with(out))) {
1307            pr.add("sinks.out", format!("시작 때 지울 출력 폴더 {} 안에 입력이 있음", out.display()));
1308        }
1309    }
1310
1311    // Input folders.
1312    let mut layout = None;
1313    if !inp.images.is_dir() {
1314        pr.add("input.images", format!("영상 폴더 없음: {}", inp.images.display()));
1315    } else {
1316        let missing: Vec<&String> = inp.cameras.iter().filter(|c| !inp.images.join(c).is_dir()).collect();
1317        for c in &missing {
1318            pr.add("input.cameras", format!("카메라 폴더 없음: {}", inp.images.join(c).display()));
1319        }
1320        if missing.is_empty() && !inp.cameras.is_empty() && inp.stride >= 1 {
1321            match Layout::discover_in(&inp.images, &inp.cameras, inp.stride) {
1322                Ok(mut l) => {
1323                    if let Some(n) = inp.positions {
1324                        l.truncate(n);
1325                    }
1326                    if l.frames.is_empty() {
1327                        pr.add(
1328                            "input",
1329                            format!(
1330                                "선택된 영상 없음: {}/{c}/{c}_NNNN.jpg 중 번호 % {} == 0 인 파일",
1331                                inp.images.display(),
1332                                inp.stride,
1333                                c = inp.cameras[0]
1334                            ),
1335                        );
1336                    } else {
1337                        layout = Some(l);
1338                    }
1339                }
1340                Err(e) => pr.add("input.images", e),
1341            }
1342        }
1343    }
1344
1345    // GPS and alignment.
1346    let mut gps = None;
1347    match (plan.align.frame, &plan.gps) {
1348        (AlignFrame::None, _) => gps = Some(Vec::new()),
1349        (AlignFrame::Enu, None) => pr.add("gps", "ENU 정렬에는 GPS 파일이 필요함 — `.gps(경로)` 또는 정렬 끔([align] frame = \"none\")"),
1350        (AlignFrame::Enu, Some(p)) => match read_gps_file(p) {
1351            Err(e) => pr.add("gps", format!("{}: {e}", p.display())),
1352            Ok(all) => {
1353                if let Some(l) = &layout {
1354                    let sel: Vec<GpsRecord> = all.into_iter().filter(|g| l.pos_of.contains_key(&g.name)).collect();
1355                    if sel.is_empty() {
1356                        pr.add("gps", format!("{}: 선택된 영상의 GPS 기록이 하나도 없음", p.display()));
1357                    } else {
1358                        gps = Some(sel);
1359                    }
1360                } else {
1361                    gps = Some(Vec::new());
1362                }
1363            }
1364        },
1365    }
1366    (layout, gps)
1367}
1368
1369// ============================================================================================
1370// Execution
1371// ============================================================================================
1372
1373/// An executable reconstruction that has passed validation. Run it with [`Recon::run`].
1374pub struct Recon {
1375    plan: Plan,
1376    attach: Vec<Attach>,
1377    layout: Layout,
1378    gps: Vec<GpsRecord>,
1379    frames: bool,
1380}
1381
1382impl fmt::Debug for Recon {
1383    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1384        f.debug_struct("Recon")
1385            .field("plan", &self.plan)
1386            .field("hooks", &self.attach.len())
1387            .field("positions", &self.layout.frames.len())
1388            .finish()
1389    }
1390}
1391
1392impl Recon {
1393    /// Start recording with the default plan ([`Plan::default`]).
1394    pub fn declare() -> ReconBuilder {
1395        Self::from_plan(Plan::default())
1396    }
1397
1398    /// Start recording with the given plan (e.g. a plan read from TOML).
1399    pub fn from_plan(plan: Plan) -> ReconBuilder {
1400        ReconBuilder { plan, attach: Vec::new(), pending: Vec::new(), frames: false }
1401    }
1402
1403    /// The plan.
1404    pub fn plan(&self) -> &Plan {
1405        &self.plan
1406    }
1407
1408    /// Number of selected positions.
1409    pub fn positions(&self) -> usize {
1410        self.layout.frames.len()
1411    }
1412
1413    /// Position <-> image name layout.
1414    pub fn layout(&self) -> &Layout {
1415        &self.layout
1416    }
1417
1418    /// Run: prepare the output folder -> session + pipeline + default output hook + user hooks -> position loop -> wait for background
1419    /// jobs and shut down.
1420    /// On a fatal error (session failure) it returns that error without post-processing.
1421    pub fn run(self) -> Result<Summary, String> {
1422        let Recon { plan, attach, layout, gps, frames } = self;
1423        if plan.threads > 0 {
1424            // Ignore if it was already created (tests etc.).
1425            let _ = rayon::ThreadPoolBuilder::new().num_threads(plan.threads).build_global();
1426        }
1427        // Without a backend, record an error in the densification stage.
1428        let dense = if plan.dense.enabled { Some(plan.dense.config()?) } else { None };
1429        let prior = match &plan.sparse.intrinsics_prior {
1430            Some(p) => {
1431                Some(Arc::new(IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma)?))
1432            }
1433            None => None,
1434        };
1435        let s = &plan.sinks;
1436        let sinks = match &s.out {
1437            Some(out) => {
1438                if s.clean && out.exists() {
1439                    std::fs::remove_dir_all(out).map_err(|e| format!("{}: {e}", out.display()))?;
1440                }
1441                let mut set = s.set();
1442                // Live diff stream: stable display only. In stable display the display frame is the ENU frame of the first
1443                // GPS record (zone 0 is brought into ENU and every later zone keeps that frame through the GPS priors).
1444                if plan.sparse.stable_display {
1445                    let origin = gps.first().map(|g| [g.lat, g.lon, g.alt]);
1446                    set.live_stream = Some(LiveStream { enu: origin.is_some(), origin_gps: origin });
1447                }
1448                Some(DefaultSinks::with_set(out, s.echo, set).map_err(|e| e.to_string())?)
1449            }
1450            None => None,
1451        };
1452        let npos = layout.frames.len();
1453        let mut sc = SessionConfig::new(plan.input.images.clone());
1454        sc.gps = gps;
1455        sc.span = plan.zones.span;
1456        sc.overlap = plan.zones.overlap;
1457        sc.total_positions = Some(npos);
1458        sc.incremental_triangulation = plan.sparse.incremental_triangulation;
1459        sc.stable_display = plan.sparse.stable_display;
1460        sc.settle_tol = plan.sparse.settle_tol;
1461        sc.settle_max_zone = plan.sparse.settle_max_zone;
1462        sc.intrinsics_prior = prior;
1463        sc.fixed_enu_origin = plan.align.fixed_origin;
1464        sc.seed = plan.seed;
1465        sc.extraction.sift.max_num_features = plan.features.max_num_features;
1466        sc.extraction.reader.max_image_size = plan.features.max_image_size;
1467        sc.sift = make_sift_backend(&plan.features.backend)?;
1468        sc.matcher = Arc::from(make_match_backend(&plan.matching.backend)?);
1469        sc.sparse = make_sparse_backend(&plan.sparse.backend)?;
1470        sc.ba_backend = make_ba_backend(&plan.sparse.ba_backend)?;
1471        sc.dense = dense;
1472        // Do not rewind (saves memory).
1473        sc.history = 0;
1474        sc.decode_frames = frames;
1475
1476        let mut pl = Pipeline::new(Session::new(sc));
1477        if let Some(sinks) = sinks {
1478            // There is a single output hook (on_any), so even if asynchronous, events are processed in order.
1479            pl = pl.on_any(sinks.into_hook());
1480        }
1481        for a in attach {
1482            pl = a(pl);
1483        }
1484
1485        let (span, ovl) = (plan.zones.span, plan.zones.overlap);
1486        let mut reg_end: BTreeMap<usize, Vec<ZoneRange>> = BTreeMap::new();
1487        for z in zones_upto(npos, span, ovl) {
1488            reg_end.entry(z.hi - 1).or_default().push(z);
1489        }
1490        pl.push(Input::Log(format!(
1491            "regions: {}",
1492            reg_end
1493                .iter()
1494                .map(|(e, v)| format!("{e} ->{}", v.iter().map(|z| format!(" {}:{}:{}", z.zone, z.lo, z.hi)).collect::<String>()))
1495                .collect::<Vec<_>>()
1496                .join(", ")
1497        )));
1498        let d = &plan.dense;
1499        pl.push(Input::Log(format!(
1500            "설정: span {span} overlap {ovl} stride {} 증분삼각측량 {} 깊이캐시 {} 고정원점 {} pm {}({})/sift {}/match {}/ba {} 직렬조밀화 {} 왜곡보정최대 {} 뷰 {} 스레드 {}",
1501            plan.input.stride,
1502            plan.sparse.incremental_triangulation,
1503            d.depth_cache,
1504            plan.align.fixed_origin,
1505            d.backend,
1506            d.profile,
1507            plan.features.backend,
1508            plan.matching.backend,
1509            plan.sparse.ba_backend,
1510            d.serialize,
1511            d.max_image_size,
1512            d.views,
1513            rayon::current_num_threads()
1514        )));
1515        for p in 0..npos {
1516            pl.push(frame_set_of(&layout, &plan.input.cameras, p).into());
1517            if let Some(e) = pl.reducer().failed() {
1518                // Fatal error: stop without post-processing (the hook queue is drained when the pipeline is dropped).
1519                return Err(e.to_string());
1520            }
1521        }
1522        let (_session, summary) = pl.finish();
1523        Ok(summary)
1524    }
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529    use super::*;
1530
1531    #[test]
1532    fn default_plan_toml_roundtrip() {
1533        let p = Plan::default();
1534        let s = p.to_toml();
1535        assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
1536        // Empty document = default plan.
1537        assert_eq!(Plan::from_toml("").unwrap(), p);
1538    }
1539
1540    #[test]
1541    fn full_plan_toml_roundtrip() {
1542        let b = Recon::declare()
1543            .input("some/where")
1544            .cameras(["a", "b"])
1545            .stride(2)
1546            .positions(5)
1547            .zones(6, 1)
1548            .fixed_enu_origin(true)
1549            .incremental_triangulation(true)
1550            .dense(
1551                Dense::profile("quality")
1552                    .fusion_mode("traversal")
1553                    .fusion_min_views(3)
1554                    .fusion_depth_error(0.02)
1555                    .fusion_normal_error(12.5)
1556                    .fusion_reproj_error(1.5)
1557                    .fusion_residual("second-pass")
1558                    .fusion_smooth_normal(30.0)
1559                    .max_levels(1)
1560                    .filter_min_views(2)
1561                    .filter_geom_error(1.25)
1562                    .filter_min_ncc(0.1)
1563                    .median_filter(true)
1564                    .depth_cache(true)
1565                    .serialize(true),
1566            )
1567            .sinks(Sinks::default_files("o").models(true).echo(false))
1568            .seed(42)
1569            .threads(3);
1570        let p = b.plan().clone();
1571        let s = p.to_toml();
1572        assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
1573        assert!(s.contains("[dense.fusion]") && s.contains("min_views = 3"), "{s}");
1574        assert!(s.contains("smooth_normal = 30.0") && s.contains("max_levels = 1"), "{s}");
1575    }
1576
1577    #[test]
1578    fn example_plan_parses() {
1579        let s = include_str!("../../../examples/plans/aerial-formation.toml");
1580        let p = Plan::from_toml(s).unwrap();
1581        assert_eq!(p.input.cameras, ["camF", "camR", "camL"]);
1582        assert_eq!(p.pairing, Pairing::Formation);
1583        assert_eq!(Plan::from_toml(&p.to_toml()).unwrap(), p);
1584    }
1585
1586    #[test]
1587    fn unknown_field_rejected() {
1588        assert!(Plan::from_toml("[zones]\nspam = 3\n").is_err());
1589        assert!(Plan::from_toml("pairing = \"grid\"\n").is_err());
1590    }
1591
1592    #[test]
1593    fn stream_plan_matches_builder() {
1594        let mut c = StreamConfig::new("src".into(), "dst".into());
1595        (c.stride, c.span, c.overlap, c.seed, c.save_models, c.echo) = (1, 4, 1, Some(7), true, false);
1596        let b = Recon::declare()
1597            .input("src")
1598            .preset(DEFAULT_PRESET)
1599            .stride(1)
1600            .zones(4, 1)
1601            .seed(7)
1602            .sinks(Sinks::default_files("dst").models(true).echo(false));
1603        assert_eq!(Plan::from_stream(&c), *b.plan());
1604    }
1605
1606    #[test]
1607    fn build_collects_all_problems() {
1608        let tmp = std::env::temp_dir().join(format!("cumulus3d_declare_missing_{}", std::process::id()));
1609        let e = Recon::declare()
1610            .input(tmp.join("in"))
1611            .preset("grid")
1612            .stride(0)
1613            .zones(4, 4)
1614            .dense(Dense::profile("slow").views(40).max_levels(0).fusion_smooth_normal(0.0).fusion_mode("magic").filter_min_ncc(2.0))
1615            .features(Features { backend: "fpga".into(), ..Features::default() })
1616            .sinks(Sinks::default_files(tmp.join("in")).clean(true))
1617            .build()
1618            .unwrap_err();
1619        for f in [
1620            "preset",
1621            "input.stride",
1622            "zones.overlap",
1623            "features.backend",
1624            "dense.profile",
1625            "dense.views",
1626            "dense.fusion.mode",
1627            "dense.max_levels",
1628            "dense.fusion.smooth_normal",
1629            "dense.filter.min_ncc",
1630            "sinks.out",
1631            "input.images",
1632            "gps",
1633        ] {
1634            assert!(e.has(f), "{f} 없음:\n{e}");
1635        }
1636        assert_eq!(e.has("dense.backend"), !cumulus3d_cuda::is_available());
1637        assert!(!tmp.exists());
1638        assert!(e.to_string().starts_with("계획 검사 실패("));
1639    }
1640
1641    #[test]
1642    fn live_stride_roundtrip_and_validation() {
1643        assert_eq!(Sinks::default().live_stride, 6);
1644        let p = Plan { sinks: Sinks::default_files("o").live_stride(3), ..Plan::default() };
1645        assert_eq!(Plan::from_toml(&p.to_toml()).unwrap().sinks.live_stride, 3);
1646        let e = Recon::declare().sinks(Sinks::default_files("o").live_stride(0)).no_dense().build().unwrap_err();
1647        assert!(e.has("sinks.live_stride"), "{e}");
1648    }
1649
1650    #[test]
1651    fn enu_without_gps_is_error() {
1652        let e = Recon::declare().no_gps().no_dense().build().unwrap_err();
1653        assert!(e.has("gps"), "{e}");
1654        let e = Recon::declare().no_gps().align(AlignFrame::None).no_dense().build().unwrap_err();
1655        assert!(!e.has("gps"), "{e}");
1656    }
1657}