Skip to main content

cumulus3d_cli/
declare.rs

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