1use 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
92pub const DEFAULT_PRESET: &str = "aerial-formation";
94
95pub const PRESETS: [&str; 1] = [DEFAULT_PRESET];
97
98#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107#[serde(default, deny_unknown_fields)]
108pub struct Plan {
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub seed: Option<u64>,
112 pub threads: usize,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub gps: Option<PathBuf>,
117 pub pairing: Pairing,
119 pub input: Source,
121 pub zones: Zones,
123 pub align: Align,
125 pub features: Features,
127 pub matching: Matching,
129 pub sparse: Sparse,
131 pub dense: Dense,
133 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
158#[serde(default, deny_unknown_fields)]
159pub struct Source {
160 pub images: PathBuf,
162 pub cameras: Vec<String>,
164 pub stride: usize,
166 #[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "kebab-case")]
180pub enum Pairing {
181 #[default]
183 Formation,
184}
185
186#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(default, deny_unknown_fields)]
189pub struct Zones {
190 pub span: usize,
192 pub overlap: usize,
194}
195
196impl Default for Zones {
197 fn default() -> Self {
198 Self { span: 12, overlap: 2 }
199 }
200}
201
202#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "kebab-case")]
205pub enum AlignFrame {
206 #[default]
208 Enu,
209 None,
211}
212
213#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(default, deny_unknown_fields)]
216pub struct Align {
217 pub frame: AlignFrame,
219 pub fixed_origin: bool,
221}
222
223#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(default, deny_unknown_fields)]
226pub struct Features {
227 pub backend: String,
229 pub max_num_features: usize,
231 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(default, deny_unknown_fields)]
244pub struct Matching {
245 pub backend: String,
247}
248
249impl Default for Matching {
250 fn default() -> Self {
251 Self { backend: "cpu".into() }
252 }
253}
254
255#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
257#[serde(default, deny_unknown_fields)]
258pub struct Sparse {
259 pub incremental_triangulation: bool,
261 pub backend: String,
263 pub ba_backend: String,
265 pub stable_display: bool,
267 pub settle_tol: f64,
269 pub settle_max_zone: usize,
271 #[serde(skip_serializing_if = "Option::is_none")]
273 pub intrinsics_prior: Option<PathBuf>,
274 pub intrinsics_prior_sigma: f64,
276 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
298#[serde(default, deny_unknown_fields)]
299pub struct Dense {
300 pub enabled: bool,
302 pub backend: String,
304 pub undistort_backend: String,
306 pub profile: String,
308 pub max_image_size: i64,
310 pub views: usize,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub max_levels: Option<usize>,
315 pub serialize: bool,
317 pub depth_cache: bool,
319 pub fusion: Fusion,
321 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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
345#[serde(default, deny_unknown_fields)]
346pub struct Fusion {
347 #[serde(skip_serializing_if = "Option::is_none")]
349 pub mode: Option<String>,
350 #[serde(skip_serializing_if = "Option::is_none")]
352 pub min_views: Option<usize>,
353 #[serde(skip_serializing_if = "Option::is_none")]
355 pub depth_error: Option<f64>,
356 #[serde(skip_serializing_if = "Option::is_none")]
358 pub normal_error: Option<f64>,
359 #[serde(skip_serializing_if = "Option::is_none")]
361 pub reproj_error: Option<f64>,
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub residual: Option<String>,
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub smooth_iters: Option<usize>,
368 #[serde(skip_serializing_if = "Option::is_none")]
370 pub smooth_radius: Option<f64>,
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub smooth_range: Option<f64>,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub smooth_normal: Option<f64>,
377 #[serde(skip_serializing_if = "Option::is_none")]
379 pub visibility_views: Option<usize>,
380 #[serde(skip_serializing_if = "Option::is_none")]
382 pub visibility_tau: Option<f64>,
383 #[serde(skip_serializing_if = "Option::is_none")]
385 pub visibility_min_violations: Option<usize>,
386}
387
388#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
391#[serde(default, deny_unknown_fields)]
392pub struct Filter {
393 #[serde(skip_serializing_if = "Option::is_none")]
395 pub min_views: Option<u32>,
396 #[serde(skip_serializing_if = "Option::is_none")]
398 pub geom_error: Option<f32>,
399 #[serde(skip_serializing_if = "Option::is_none")]
401 pub min_ncc: Option<f32>,
402 #[serde(skip_serializing_if = "Option::is_none")]
404 pub median: Option<bool>,
405 #[serde(skip_serializing_if = "Option::is_none")]
407 pub fill_holes: Option<bool>,
408}
409
410impl Dense {
411 pub fn profile(name: &str) -> Self {
413 Self { profile: name.to_string(), ..Self::default() }
414 }
415 pub fn off() -> Self {
417 Self { enabled: false, ..Self::default() }
418 }
419 pub fn backend(mut self, name: &str) -> Self {
421 self.backend = name.to_string();
422 self
423 }
424 pub fn undistort_backend(mut self, name: &str) -> Self {
426 self.undistort_backend = name.to_string();
427 self
428 }
429 pub fn max_image_size(mut self, n: i64) -> Self {
431 self.max_image_size = n;
432 self
433 }
434 pub fn views(mut self, n: usize) -> Self {
436 self.views = n;
437 self
438 }
439 pub fn serialize(mut self, on: bool) -> Self {
441 self.serialize = on;
442 self
443 }
444 pub fn depth_cache(mut self, on: bool) -> Self {
446 self.depth_cache = on;
447 self
448 }
449 pub fn fusion_mode(mut self, mode: &str) -> Self {
451 self.fusion.mode = Some(mode.to_string());
452 self
453 }
454 pub fn fusion_min_views(mut self, n: usize) -> Self {
456 self.fusion.min_views = Some(n);
457 self
458 }
459 pub fn fusion_depth_error(mut self, v: f64) -> Self {
461 self.fusion.depth_error = Some(v);
462 self
463 }
464 pub fn fusion_normal_error(mut self, deg: f64) -> Self {
466 self.fusion.normal_error = Some(deg);
467 self
468 }
469 pub fn fusion_reproj_error(mut self, px: f64) -> Self {
471 self.fusion.reproj_error = Some(px);
472 self
473 }
474 pub fn fusion_residual(mut self, mode: &str) -> Self {
476 self.fusion.residual = Some(mode.to_string());
477 self
478 }
479 pub fn fusion_smooth_iters(mut self, n: usize) -> Self {
481 self.fusion.smooth_iters = Some(n);
482 self
483 }
484 pub fn fusion_smooth_radius(mut self, r: f64) -> Self {
486 self.fusion.smooth_radius = Some(r);
487 self
488 }
489 pub fn fusion_smooth_range(mut self, r: f64) -> Self {
491 self.fusion.smooth_range = Some(r);
492 self
493 }
494 pub fn fusion_smooth_normal(mut self, deg: f64) -> Self {
496 self.fusion.smooth_normal = Some(deg);
497 self
498 }
499 pub fn max_levels(mut self, n: usize) -> Self {
501 self.max_levels = Some(n);
502 self
503 }
504 pub fn fill_holes(mut self, on: bool) -> Self {
506 self.filter.fill_holes = Some(on);
507 self
508 }
509 pub fn filter_min_views(mut self, n: u32) -> Self {
511 self.filter.min_views = Some(n);
512 self
513 }
514 pub fn filter_geom_error(mut self, px: f32) -> Self {
516 self.filter.geom_error = Some(px);
517 self
518 }
519 pub fn filter_min_ncc(mut self, v: f32) -> Self {
521 self.filter.min_ncc = Some(v);
522 self
523 }
524 pub fn median_filter(mut self, on: bool) -> Self {
526 self.filter.median = Some(on);
527 self
528 }
529
530 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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
619#[serde(default, deny_unknown_fields)]
620pub struct Sinks {
621 #[serde(skip_serializing_if = "Option::is_none")]
623 pub out: Option<PathBuf>,
624 pub clean: bool,
626 pub echo: bool,
628 pub timeline: bool,
630 pub run_log: bool,
632 pub zone_ply: bool,
634 pub snapshots: bool,
636 pub models: bool,
638 pub live_stride: usize,
640}
641
642impl Default for Sinks {
643 fn default() -> Self {
645 Self { out: None, ..Self::default_files("") }
646 }
647}
648
649impl Sinks {
650 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 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 pub fn clean(mut self, on: bool) -> Self {
680 self.clean = on;
681 self
682 }
683 pub fn echo(mut self, on: bool) -> Self {
685 self.echo = on;
686 self
687 }
688 pub fn timeline(mut self, on: bool) -> Self {
690 self.timeline = on;
691 self
692 }
693 pub fn run_log(mut self, on: bool) -> Self {
695 self.run_log = on;
696 self
697 }
698 pub fn zone_ply(mut self, on: bool) -> Self {
700 self.zone_ply = on;
701 self
702 }
703 pub fn snapshots(mut self, on: bool) -> Self {
705 self.snapshots = on;
706 self
707 }
708 pub fn models(mut self, on: bool) -> Self {
710 self.models = on;
711 self
712 }
713 pub fn live_stride(mut self, stride: usize) -> Self {
715 self.live_stride = stride;
716 self
717 }
718 fn set(&self) -> SinkSet {
719 SinkSet {
720 timeline: self.timeline,
721 run_log: self.run_log,
722 zone_ply: self.zone_ply,
723 models: self.models,
724 snapshots: self.snapshots,
725 live_stream: None,
726 live_stride: self.live_stride,
727 }
728 }
729}
730
731impl Plan {
732 pub fn to_toml(&self) -> String {
734 toml::to_string(self).expect("계획은 항상 TOML 로 바꿀 수 있다")
735 }
736
737 pub fn to_toml_annotated(&self) -> String {
741 let s = self.to_toml();
742 let Ok(profile) = parse_profile(&self.dense.profile) else { return s };
743 let o = DensifyOptions::with_profile(profile);
744 let (f, g) = (&o.fusion, &o.filter);
745 let mode = match f.mode {
746 FusionMode::Consistency => "consistency",
747 FusionMode::Traversal => "traversal",
748 };
749 let fusion = format!(
750 "# {} profile defaults:\n# mode = \"{mode}\"\n# min_views = {}\n# depth_error = {}\n# normal_error = {}\n# reproj_error = {}\n# residual = \"{}\"\n# smooth_iters = {}\n# smooth_radius = {}\n# smooth_range = {}\n# smooth_normal = {}\n",
751 self.dense.profile, f.min_consistent_views, f.max_depth_error, f.max_normal_error_deg, f.max_reproj_error, f.residual.name(), f.smooth.iterations, f.smooth.radius_gsd, f.smooth.sigma_range_gsd, f.smooth.normal_deg
752 );
753 let filter = format!(
754 "# {} profile defaults:\n# min_views = {}\n# geom_error = {}\n# min_ncc = {}\n# median = {}\n# fill_holes = {}\n",
755 self.dense.profile, g.min_num_consistent, g.geom_max_cost, g.min_ncc, g.median_filter, o.post.fill_holes
756 );
757 let mut out = String::with_capacity(s.len() + fusion.len() + filter.len());
758 let mut lines = s.lines().peekable();
759 while let Some(l) = lines.next() {
760 out.push_str(l);
761 out.push('\n');
762 let empty = lines.peek().is_none_or(|n| n.trim().is_empty() || n.starts_with('['));
763 if empty && l == "[dense.fusion]" {
764 out.push_str(&fusion);
765 } else if empty && l == "[dense.filter]" {
766 out.push_str(&filter);
767 }
768 }
769 out
770 }
771
772 pub fn from_toml(s: &str) -> Result<Self, String> {
774 toml::from_str(s).map_err(|e| format!("계획 TOML: {e}"))
775 }
776
777 pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
779 let p = path.as_ref();
780 let s = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
781 Self::from_toml(&s).map_err(|e| format!("{}: {e}", p.display()))
782 }
783
784 pub fn from_stream(c: &StreamConfig) -> Self {
786 Self {
787 seed: c.seed,
788 threads: c.threads,
789 gps: Some(c.src.join("gps_ref.txt")),
790 pairing: Pairing::Formation,
791 input: Source {
792 images: c.src.join("images"),
793 cameras: CAMS.iter().map(|s| s.to_string()).collect(),
794 stride: c.stride,
795 positions: c.max_positions,
796 },
797 zones: Zones { span: c.span, overlap: c.overlap },
798 align: Align { frame: AlignFrame::Enu, fixed_origin: c.fixed_enu_origin },
799 features: Features {
800 backend: c.sift_backend.clone(),
801 max_num_features: c.max_num_features,
802 max_image_size: c.sift_max_image_size,
803 },
804 matching: Matching { backend: c.match_backend.clone() },
805 sparse: Sparse {
806 incremental_triangulation: c.incremental_triangulation,
807 stable_display: c.stable_display,
808 settle_tol: c.settle_tol,
809 settle_max_zone: c.settle_max_zone,
810 intrinsics_prior: c.intrinsics_prior.clone(),
811 intrinsics_prior_sigma: c.intrinsics_prior_sigma,
812 intrinsics_prior_dist_sigma: c.intrinsics_prior_dist_sigma,
813 ..Default::default()
814 },
815 dense: Dense {
816 enabled: !c.no_dense,
817 backend: c.pm_backend.clone(),
818 undistort_backend: c.undistort_backend.clone(),
819 profile: c.mvs_profile.clone(),
820 max_image_size: c.dense_max_image_size,
821 views: c.number_views,
822 max_levels: c.max_levels,
823 serialize: c.serialize_dense,
824 depth_cache: c.depth_cache,
825 fusion: c.fusion.clone(),
826 filter: Filter::default(),
827 },
828 sinks: Sinks { models: c.save_models, echo: c.echo, live_stride: c.live_stride, ..Sinks::default_files(&c.out) },
829 }
830 }
831}
832
833#[derive(Clone, Debug, PartialEq, Eq)]
839pub struct Problem {
840 pub field: String,
842 pub message: String,
844}
845
846#[derive(Clone, Debug, PartialEq, Eq)]
848pub struct PlanError {
849 pub problems: Vec<Problem>,
851}
852
853impl PlanError {
854 pub fn has(&self, field: &str) -> bool {
856 self.problems.iter().any(|p| p.field == field)
857 }
858}
859
860impl fmt::Display for PlanError {
861 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
862 write!(f, "계획 검사 실패({} 건)", self.problems.len())?;
863 for p in &self.problems {
864 write!(f, "\n - {}: {}", p.field, p.message)?;
865 }
866 Ok(())
867 }
868}
869
870impl std::error::Error for PlanError {}
871
872#[derive(Default)]
873struct Problems(Vec<Problem>);
874
875impl Problems {
876 fn add(&mut self, field: &str, message: impl Into<String>) {
877 self.0.push(Problem { field: field.to_string(), message: message.into() });
878 }
879 fn check(&mut self, ok: bool, field: &str, message: impl FnOnce() -> String) {
880 if !ok {
881 self.add(field, message());
882 }
883 }
884}
885
886type Attach = Box<dyn FnOnce(Pipeline<Session>) -> Pipeline<Session>>;
891
892pub struct ReconBuilder {
894 plan: Plan,
895 attach: Vec<Attach>,
896 pending: Vec<Problem>,
897 frames: bool,
898}
899
900impl fmt::Debug for ReconBuilder {
901 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
902 f.debug_struct("ReconBuilder").field("plan", &self.plan).field("hooks", &self.attach.len()).field("pending", &self.pending).finish()
903 }
904}
905
906impl ReconBuilder {
907 pub fn plan(&self) -> &Plan {
909 &self.plan
910 }
911
912 pub fn input(mut self, src: impl AsRef<Path>) -> Self {
914 let src = src.as_ref();
915 self.plan.input.images = src.join("images");
916 self.plan.gps = Some(src.join("gps_ref.txt"));
917 self
918 }
919
920 pub fn images(mut self, dir: impl Into<PathBuf>) -> Self {
922 self.plan.input.images = dir.into();
923 self
924 }
925
926 pub fn cameras<S: Into<String>>(mut self, cams: impl IntoIterator<Item = S>) -> Self {
928 self.plan.input.cameras = cams.into_iter().map(Into::into).collect();
929 self
930 }
931
932 pub fn preset(mut self, name: &str) -> Self {
935 match name {
936 DEFAULT_PRESET => {
937 self.plan.input.cameras = CAMS.iter().map(|c| c.to_string()).collect();
938 self.plan.input.stride = 3;
939 self.plan.pairing = Pairing::Formation;
940 }
941 other => self
942 .pending
943 .push(Problem { field: "preset".into(), message: format!("알 수 없는 프리셋 {other} ({})", PRESETS.join("|")) }),
944 }
945 self
946 }
947
948 pub fn stride(mut self, n: usize) -> Self {
950 self.plan.input.stride = n;
951 self
952 }
953
954 pub fn positions(mut self, n: usize) -> Self {
956 self.plan.input.positions = Some(n);
957 self
958 }
959
960 pub fn pairing(mut self, p: Pairing) -> Self {
962 self.plan.pairing = p;
963 self
964 }
965
966 pub fn zones(mut self, span: usize, overlap: usize) -> Self {
968 self.plan.zones = Zones { span, overlap };
969 self
970 }
971
972 pub fn gps(mut self, path: impl Into<PathBuf>) -> Self {
974 self.plan.gps = Some(path.into());
975 self
976 }
977
978 pub fn no_gps(mut self) -> Self {
980 self.plan.gps = None;
981 self
982 }
983
984 pub fn align(mut self, frame: AlignFrame) -> Self {
986 self.plan.align.frame = frame;
987 self
988 }
989
990 pub fn fixed_enu_origin(mut self, on: bool) -> Self {
992 self.plan.align.fixed_origin = on;
993 self
994 }
995
996 pub fn features(mut self, f: Features) -> Self {
998 self.plan.features = f;
999 self
1000 }
1001
1002 pub fn match_backend(mut self, name: &str) -> Self {
1004 self.plan.matching.backend = name.to_string();
1005 self
1006 }
1007
1008 pub fn gpu(mut self) -> Self {
1010 self.plan.features.backend = "cuda".into();
1011 self.plan.matching.backend = "cuda".into();
1012 self.plan.sparse.backend = "cuda".into();
1013 self.plan.dense.backend = "cuda".into();
1014 self.plan.sparse.ba_backend = "cuda".into();
1015 self.plan.dense.undistort_backend = "cuda".into();
1016 self
1017 }
1018
1019 pub fn incremental_triangulation(mut self, on: bool) -> Self {
1021 self.plan.sparse.incremental_triangulation = on;
1022 self
1023 }
1024
1025 pub fn stable_display(mut self, on: bool) -> Self {
1027 self.plan.sparse.stable_display = on;
1028 self
1029 }
1030
1031 pub fn settle(mut self, tol: f64, max_zone: usize) -> Self {
1034 self.plan.sparse.settle_tol = tol;
1035 self.plan.sparse.settle_max_zone = max_zone;
1036 self
1037 }
1038
1039 pub fn dense(mut self, d: Dense) -> Self {
1041 self.plan.dense = Dense { enabled: true, ..d };
1042 self
1043 }
1044
1045 pub fn no_dense(mut self) -> Self {
1047 self.plan.dense.enabled = false;
1048 self
1049 }
1050
1051 pub fn sinks(mut self, s: Sinks) -> Self {
1053 self.plan.sinks = s;
1054 self
1055 }
1056
1057 pub fn seed(mut self, seed: u64) -> Self {
1059 self.plan.seed = Some(seed);
1060 self
1061 }
1062
1063 pub fn threads(mut self, n: usize) -> Self {
1065 self.plan.threads = n;
1066 self
1067 }
1068
1069 fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + 'static) -> Self {
1070 self.attach.push(Box::new(f));
1071 self
1072 }
1073
1074 pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
1077 where
1078 F: FnMut(&crate::events::Event) + Send + 'static,
1079 {
1080 self.frames |= kind == EventKind::FrameDecoded;
1081 self.hook(move |p| p.on(kind, f))
1082 }
1083
1084 pub fn on_frame<F>(mut self, f: F) -> Self
1088 where
1089 F: FnMut(&Arc<Frame>) + Send + 'static,
1090 {
1091 self.frames = true;
1092 self.hook(move |p| p.on_frame(f))
1093 }
1094
1095 pub fn on_any<F>(self, f: F) -> Self
1097 where
1098 F: FnMut(&crate::events::Event) + Send + 'static,
1099 {
1100 self.hook(move |p| p.on_any(f))
1101 }
1102
1103 pub fn on_zone_preview<F>(self, f: F) -> Self
1105 where
1106 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
1107 {
1108 self.hook(move |p| p.on_zone_preview(f))
1109 }
1110
1111 pub fn on_zone_refined<F>(self, f: F) -> Self
1113 where
1114 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
1115 {
1116 self.hook(move |p| p.on_zone_refined(f))
1117 }
1118
1119 pub fn on_snapshot<F>(self, f: F) -> Self
1121 where
1122 F: FnMut(usize, &Arc<PointCloud>) + Send + 'static,
1123 {
1124 self.hook(move |p| p.on_snapshot(f))
1125 }
1126
1127 pub fn on_position_done<F>(self, f: F) -> Self
1129 where
1130 F: FnMut(usize, usize, usize) + Send + 'static,
1131 {
1132 self.hook(move |p| p.on_position_done(f))
1133 }
1134
1135 pub fn on_message<F>(self, f: F) -> Self
1137 where
1138 F: FnMut(EventKind, &str) + Send + 'static,
1139 {
1140 self.hook(move |p| p.on_message(f))
1141 }
1142
1143 pub fn policy(self, kind: EventKind, p: QueuePolicy) -> Self {
1145 self.hook(move |pl| pl.policy(kind, p))
1146 }
1147
1148 pub fn build(self) -> Result<Recon, PlanError> {
1155 let ReconBuilder { plan, attach, pending, frames } = self;
1156 let mut pr = Problems(pending);
1157 let (layout, gps) = check(&plan, &mut pr);
1158 if !pr.0.is_empty() {
1159 return Err(PlanError { problems: pr.0 });
1160 }
1161 let (Some(layout), Some(gps)) = (layout, gps) else {
1162 unreachable!("문제가 없으면 입력과 GPS 를 읽었다");
1163 };
1164 Ok(Recon { plan, attach, layout, gps, frames })
1165 }
1166}
1167
1168fn backend_ok(pr: &mut Problems, field: &str, name: &str, allowed: &[&str]) {
1169 if !allowed.contains(&name) {
1170 pr.add(field, format!("알 수 없는 백엔드 {name} ({})", allowed.join("|")));
1171 } else if name == "cuda" && !cumulus3d_cuda::is_available() {
1172 pr.add(field, "CUDA 장치를 찾지 못함 — cuda 백엔드를 쓸 수 없다");
1173 }
1174}
1175
1176fn writable_target(p: &Path) -> Result<(), String> {
1178 if p.exists() {
1179 let m = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?;
1180 if !m.is_dir() {
1181 return Err(format!("{} 는 폴더가 아님", p.display()));
1182 }
1183 if m.permissions().readonly() {
1184 return Err(format!("{} 에 쓸 수 없음", p.display()));
1185 }
1186 return Ok(());
1187 }
1188 let mut a = p.parent();
1189 while let Some(d) = a {
1190 let d = if d.as_os_str().is_empty() { Path::new(".") } else { d };
1191 if d.exists() {
1192 let m = std::fs::metadata(d).map_err(|e| format!("{}: {e}", d.display()))?;
1193 if !m.is_dir() {
1194 return Err(format!("{} 는 폴더가 아님", d.display()));
1195 }
1196 if m.permissions().readonly() {
1197 return Err(format!("{} 에 쓸 수 없음", d.display()));
1198 }
1199 return Ok(());
1200 }
1201 a = d.parent();
1202 }
1203 Ok(())
1204}
1205
1206fn check(plan: &Plan, pr: &mut Problems) -> (Option<Layout>, Option<Vec<GpsRecord>>) {
1207 let inp = &plan.input;
1209 pr.check(inp.stride >= 1, "input.stride", || "1 이상이어야 함".into());
1210 pr.check(inp.positions != Some(0), "input.positions", || "1 이상이어야 함".into());
1211 pr.check(!inp.cameras.is_empty(), "input.cameras", || "카메라 폴더 목록이 비어 있음".into());
1212 let mut seen = std::collections::BTreeSet::new();
1213 for c in &inp.cameras {
1214 if c.is_empty() || c.contains(['/', '\\']) {
1215 pr.add("input.cameras", format!("잘못된 카메라 폴더 이름 {c:?}"));
1216 } else if !seen.insert(c.as_str()) {
1217 pr.add("input.cameras", format!("카메라 {c} 중복"));
1218 }
1219 }
1220 let z = plan.zones;
1221 pr.check(z.span >= 1, "zones.span", || "1 이상이어야 함".into());
1222 pr.check(z.overlap < z.span.max(1), "zones.overlap", || format!("구역 크기({})보다 작아야 함 (지금 {})", z.span, z.overlap));
1223 let f = &plan.features;
1224 backend_ok(pr, "features.backend", &f.backend, &["cpu", "cuda"]);
1225 pr.check(f.max_num_features >= 1, "features.max_num_features", || "1 이상이어야 함".into());
1226 pr.check(f.max_image_size >= 16, "features.max_image_size", || "16 이상이어야 함".into());
1227 backend_ok(pr, "matching.backend", &plan.matching.backend, &["cpu", "cuda"]);
1228 backend_ok(pr, "sparse.backend", &plan.sparse.backend, &["cpu", "cuda"]);
1229 backend_ok(pr, "sparse.ba_backend", &plan.sparse.ba_backend, &["cpu", "cuda"]);
1230 backend_ok(pr, "dense.undistort_backend", &plan.dense.undistort_backend, &["cpu", "cuda"]);
1231
1232 let d = &plan.dense;
1233 if d.enabled {
1234 if d.backend != "cuda" {
1235 pr.add("dense.backend", format!("알 수 없는 백엔드 {} (cuda)", d.backend));
1236 } else if !cumulus3d_cuda::is_available() {
1237 pr.add("dense.backend", "조밀화에는 CUDA 장치가 필요한데 장치를 찾지 못함 — `.no_dense()` 또는 [dense] enabled = false");
1238 }
1239 if let Err(e) = parse_profile(&d.profile) {
1240 pr.add("dense.profile", e);
1241 }
1242 pr.check(d.max_image_size >= 16, "dense.max_image_size", || "16 이상이어야 함".into());
1243 pr.check((1..=32).contains(&d.views), "dense.views", || format!("1..=32 이어야 함 (지금 {})", d.views));
1244 if let Some(v) = d.max_levels {
1245 pr.check(v >= 1, "dense.max_levels", || "1 이상이어야 함".into());
1246 }
1247 let fu = &d.fusion;
1248 if let Some(m) = &fu.mode {
1249 if let Err(e) = parse_fusion_mode(m) {
1250 pr.add("dense.fusion.mode", e);
1251 }
1252 }
1253 if let Some(m) = &fu.residual {
1254 pr.check(FusionResidual::parse(m).is_some(), "dense.fusion.residual", || {
1255 format!("알 수 없는 처리 {m} (none|release|second-pass)")
1256 });
1257 }
1258 if let Some(v) = fu.min_views {
1259 pr.check(v >= 1, "dense.fusion.min_views", || "1 이상이어야 함".into());
1260 }
1261 if let Some(v) = fu.depth_error {
1262 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.depth_error", || format!("양수여야 함 (지금 {v})"));
1263 }
1264 if let Some(v) = fu.normal_error {
1265 pr.check(v > 0.0 && v <= 180.0, "dense.fusion.normal_error", || format!("(0, 180] 이어야 함 (지금 {v})"));
1266 }
1267 if let Some(v) = fu.reproj_error {
1268 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.reproj_error", || format!("양수여야 함 (지금 {v})"));
1269 }
1270 if let Some(v) = fu.visibility_tau {
1271 pr.check(v > 0.0 && v < 1.0, "dense.fusion.visibility_tau", || format!("(0, 1) 이어야 함 (지금 {v})"));
1272 }
1273 if let Some(v) = fu.smooth_radius {
1274 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_radius", || format!("양수여야 함 (지금 {v})"));
1275 }
1276 if let Some(v) = fu.smooth_range {
1277 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_range", || format!("양수여야 함 (지금 {v})"));
1278 }
1279 if let Some(v) = fu.smooth_normal {
1280 pr.check(v > 0.0 && v <= 180.0, "dense.fusion.smooth_normal", || format!("(0, 180] 이어야 함 (지금 {v})"));
1281 }
1282 let fi = &d.filter;
1283 if let Some(v) = fi.geom_error {
1284 pr.check(v > 0.0 && v.is_finite(), "dense.filter.geom_error", || format!("양수여야 함 (지금 {v})"));
1285 }
1286 if let Some(v) = fi.min_ncc {
1287 pr.check((-1.0..=1.0).contains(&v), "dense.filter.min_ncc", || format!("[-1, 1] 이어야 함 (지금 {v})"));
1288 }
1289 }
1290
1291 if let Some(p) = &plan.sparse.intrinsics_prior {
1293 if let Err(e) = IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma) {
1294 pr.add("sparse.intrinsics_prior", e);
1295 }
1296 }
1297
1298 let s = &plan.sinks;
1300 pr.check(s.live_stride >= 1, "sinks.live_stride", || "must be >= 1 (1 = keep all)".into());
1301 if let Some(out) = &s.out {
1302 if let Err(e) = writable_target(out) {
1303 pr.add("sinks.out", e);
1304 }
1305 if s.clean && (inp.images.starts_with(out) || plan.gps.as_ref().is_some_and(|g| g.starts_with(out))) {
1306 pr.add("sinks.out", format!("시작 때 지울 출력 폴더 {} 안에 입력이 있음", out.display()));
1307 }
1308 }
1309
1310 let mut layout = None;
1312 if !inp.images.is_dir() {
1313 pr.add("input.images", format!("영상 폴더 없음: {}", inp.images.display()));
1314 } else {
1315 let missing: Vec<&String> = inp.cameras.iter().filter(|c| !inp.images.join(c).is_dir()).collect();
1316 for c in &missing {
1317 pr.add("input.cameras", format!("카메라 폴더 없음: {}", inp.images.join(c).display()));
1318 }
1319 if missing.is_empty() && !inp.cameras.is_empty() && inp.stride >= 1 {
1320 match Layout::discover_in(&inp.images, &inp.cameras, inp.stride) {
1321 Ok(mut l) => {
1322 if let Some(n) = inp.positions {
1323 l.truncate(n);
1324 }
1325 if l.frames.is_empty() {
1326 pr.add(
1327 "input",
1328 format!(
1329 "선택된 영상 없음: {}/{c}/{c}_NNNN.jpg 중 번호 % {} == 0 인 파일",
1330 inp.images.display(),
1331 inp.stride,
1332 c = inp.cameras[0]
1333 ),
1334 );
1335 } else {
1336 layout = Some(l);
1337 }
1338 }
1339 Err(e) => pr.add("input.images", e),
1340 }
1341 }
1342 }
1343
1344 let mut gps = None;
1346 match (plan.align.frame, &plan.gps) {
1347 (AlignFrame::None, _) => gps = Some(Vec::new()),
1348 (AlignFrame::Enu, None) => pr.add("gps", "ENU 정렬에는 GPS 파일이 필요함 — `.gps(경로)` 또는 정렬 끔([align] frame = \"none\")"),
1349 (AlignFrame::Enu, Some(p)) => match read_gps_file(p) {
1350 Err(e) => pr.add("gps", format!("{}: {e}", p.display())),
1351 Ok(all) => {
1352 if let Some(l) = &layout {
1353 let sel: Vec<GpsRecord> = all.into_iter().filter(|g| l.pos_of.contains_key(&g.name)).collect();
1354 if sel.is_empty() {
1355 pr.add("gps", format!("{}: 선택된 영상의 GPS 기록이 하나도 없음", p.display()));
1356 } else {
1357 gps = Some(sel);
1358 }
1359 } else {
1360 gps = Some(Vec::new());
1361 }
1362 }
1363 },
1364 }
1365 (layout, gps)
1366}
1367
1368pub struct Recon {
1374 plan: Plan,
1375 attach: Vec<Attach>,
1376 layout: Layout,
1377 gps: Vec<GpsRecord>,
1378 frames: bool,
1379}
1380
1381impl fmt::Debug for Recon {
1382 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1383 f.debug_struct("Recon")
1384 .field("plan", &self.plan)
1385 .field("hooks", &self.attach.len())
1386 .field("positions", &self.layout.frames.len())
1387 .finish()
1388 }
1389}
1390
1391impl Recon {
1392 pub fn declare() -> ReconBuilder {
1394 Self::from_plan(Plan::default())
1395 }
1396
1397 pub fn from_plan(plan: Plan) -> ReconBuilder {
1399 ReconBuilder { plan, attach: Vec::new(), pending: Vec::new(), frames: false }
1400 }
1401
1402 pub fn plan(&self) -> &Plan {
1404 &self.plan
1405 }
1406
1407 pub fn positions(&self) -> usize {
1409 self.layout.frames.len()
1410 }
1411
1412 pub fn layout(&self) -> &Layout {
1414 &self.layout
1415 }
1416
1417 pub fn run(self) -> Result<Summary, String> {
1421 let Recon { plan, attach, layout, gps, frames } = self;
1422 if plan.threads > 0 {
1423 let _ = rayon::ThreadPoolBuilder::new().num_threads(plan.threads).build_global();
1425 }
1426 let dense = if plan.dense.enabled { Some(plan.dense.config()?) } else { None };
1428 let prior = match &plan.sparse.intrinsics_prior {
1429 Some(p) => {
1430 Some(Arc::new(IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma)?))
1431 }
1432 None => None,
1433 };
1434 let s = &plan.sinks;
1435 let sinks = match &s.out {
1436 Some(out) => {
1437 if s.clean && out.exists() {
1438 std::fs::remove_dir_all(out).map_err(|e| format!("{}: {e}", out.display()))?;
1439 }
1440 let mut set = s.set();
1441 if plan.sparse.stable_display {
1444 let origin = gps.first().map(|g| [g.lat, g.lon, g.alt]);
1445 set.live_stream = Some(LiveStream { enu: origin.is_some(), origin_gps: origin });
1446 }
1447 Some(DefaultSinks::with_set(out, s.echo, set).map_err(|e| e.to_string())?)
1448 }
1449 None => None,
1450 };
1451 let npos = layout.frames.len();
1452 let mut sc = SessionConfig::new(plan.input.images.clone());
1453 sc.gps = gps;
1454 sc.span = plan.zones.span;
1455 sc.overlap = plan.zones.overlap;
1456 sc.total_positions = Some(npos);
1457 sc.incremental_triangulation = plan.sparse.incremental_triangulation;
1458 sc.stable_display = plan.sparse.stable_display;
1459 sc.settle_tol = plan.sparse.settle_tol;
1460 sc.settle_max_zone = plan.sparse.settle_max_zone;
1461 sc.intrinsics_prior = prior;
1462 sc.fixed_enu_origin = plan.align.fixed_origin;
1463 sc.seed = plan.seed;
1464 sc.extraction.sift.max_num_features = plan.features.max_num_features;
1465 sc.extraction.reader.max_image_size = plan.features.max_image_size;
1466 sc.sift = make_sift_backend(&plan.features.backend)?;
1467 sc.matcher = Arc::from(make_match_backend(&plan.matching.backend)?);
1468 sc.sparse = make_sparse_backend(&plan.sparse.backend)?;
1469 sc.ba_backend = make_ba_backend(&plan.sparse.ba_backend)?;
1470 sc.dense = dense;
1471 sc.history = 0;
1473 sc.decode_frames = frames;
1474
1475 let mut pl = Pipeline::new(Session::new(sc));
1476 if let Some(sinks) = sinks {
1477 pl = pl.on_any(sinks.into_hook());
1479 }
1480 for a in attach {
1481 pl = a(pl);
1482 }
1483
1484 let (span, ovl) = (plan.zones.span, plan.zones.overlap);
1485 let mut reg_end: BTreeMap<usize, Vec<ZoneRange>> = BTreeMap::new();
1486 for z in zones_upto(npos, span, ovl) {
1487 reg_end.entry(z.hi - 1).or_default().push(z);
1488 }
1489 pl.push(Input::Log(format!(
1490 "regions: {}",
1491 reg_end
1492 .iter()
1493 .map(|(e, v)| format!("{e} ->{}", v.iter().map(|z| format!(" {}:{}:{}", z.zone, z.lo, z.hi)).collect::<String>()))
1494 .collect::<Vec<_>>()
1495 .join(", ")
1496 )));
1497 let d = &plan.dense;
1498 pl.push(Input::Log(format!(
1499 "설정: span {span} overlap {ovl} stride {} 증분삼각측량 {} 깊이캐시 {} 고정원점 {} pm {}({})/sift {}/match {}/ba {} 직렬조밀화 {} 왜곡보정최대 {} 뷰 {} 스레드 {}",
1500 plan.input.stride,
1501 plan.sparse.incremental_triangulation,
1502 d.depth_cache,
1503 plan.align.fixed_origin,
1504 d.backend,
1505 d.profile,
1506 plan.features.backend,
1507 plan.matching.backend,
1508 plan.sparse.ba_backend,
1509 d.serialize,
1510 d.max_image_size,
1511 d.views,
1512 rayon::current_num_threads()
1513 )));
1514 for p in 0..npos {
1515 pl.push(frame_set_of(&layout, &plan.input.cameras, p).into());
1516 if let Some(e) = pl.reducer().failed() {
1517 return Err(e.to_string());
1519 }
1520 }
1521 let (_session, summary) = pl.finish();
1522 Ok(summary)
1523 }
1524}
1525
1526#[cfg(test)]
1527mod tests {
1528 use super::*;
1529
1530 #[test]
1531 fn default_plan_toml_roundtrip() {
1532 let p = Plan::default();
1533 let s = p.to_toml();
1534 assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
1535 assert_eq!(Plan::from_toml("").unwrap(), p);
1537 }
1538
1539 #[test]
1540 fn full_plan_toml_roundtrip() {
1541 let b = Recon::declare()
1542 .input("some/where")
1543 .cameras(["a", "b"])
1544 .stride(2)
1545 .positions(5)
1546 .zones(6, 1)
1547 .fixed_enu_origin(true)
1548 .incremental_triangulation(true)
1549 .dense(
1550 Dense::profile("quality")
1551 .fusion_mode("traversal")
1552 .fusion_min_views(3)
1553 .fusion_depth_error(0.02)
1554 .fusion_normal_error(12.5)
1555 .fusion_reproj_error(1.5)
1556 .fusion_residual("second-pass")
1557 .fusion_smooth_normal(30.0)
1558 .max_levels(1)
1559 .filter_min_views(2)
1560 .filter_geom_error(1.25)
1561 .filter_min_ncc(0.1)
1562 .median_filter(true)
1563 .depth_cache(true)
1564 .serialize(true),
1565 )
1566 .sinks(Sinks::default_files("o").models(true).echo(false))
1567 .seed(42)
1568 .threads(3);
1569 let p = b.plan().clone();
1570 let s = p.to_toml();
1571 assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
1572 assert!(s.contains("[dense.fusion]") && s.contains("min_views = 3"), "{s}");
1573 assert!(s.contains("smooth_normal = 30.0") && s.contains("max_levels = 1"), "{s}");
1574 }
1575
1576 #[test]
1577 fn example_plan_parses() {
1578 let s = include_str!("../../../examples/plans/aerial-formation.toml");
1579 let p = Plan::from_toml(s).unwrap();
1580 assert_eq!(p.input.cameras, ["camF", "camR", "camL"]);
1581 assert_eq!(p.pairing, Pairing::Formation);
1582 assert_eq!(Plan::from_toml(&p.to_toml()).unwrap(), p);
1583 }
1584
1585 #[test]
1586 fn unknown_field_rejected() {
1587 assert!(Plan::from_toml("[zones]\nspam = 3\n").is_err());
1588 assert!(Plan::from_toml("pairing = \"grid\"\n").is_err());
1589 }
1590
1591 #[test]
1592 fn stream_plan_matches_builder() {
1593 let mut c = StreamConfig::new("src".into(), "dst".into());
1594 (c.stride, c.span, c.overlap, c.seed, c.save_models, c.echo) = (1, 4, 1, Some(7), true, false);
1595 let b = Recon::declare()
1596 .input("src")
1597 .preset(DEFAULT_PRESET)
1598 .stride(1)
1599 .zones(4, 1)
1600 .seed(7)
1601 .sinks(Sinks::default_files("dst").models(true).echo(false));
1602 assert_eq!(Plan::from_stream(&c), *b.plan());
1603 }
1604
1605 #[test]
1606 fn build_collects_all_problems() {
1607 let tmp = std::env::temp_dir().join(format!("cumulus3d_declare_missing_{}", std::process::id()));
1608 let e = Recon::declare()
1609 .input(tmp.join("in"))
1610 .preset("grid")
1611 .stride(0)
1612 .zones(4, 4)
1613 .dense(Dense::profile("slow").views(40).max_levels(0).fusion_smooth_normal(0.0).fusion_mode("magic").filter_min_ncc(2.0))
1614 .features(Features { backend: "fpga".into(), ..Features::default() })
1615 .sinks(Sinks::default_files(tmp.join("in")).clean(true))
1616 .build()
1617 .unwrap_err();
1618 for f in [
1619 "preset",
1620 "input.stride",
1621 "zones.overlap",
1622 "features.backend",
1623 "dense.profile",
1624 "dense.views",
1625 "dense.fusion.mode",
1626 "dense.max_levels",
1627 "dense.fusion.smooth_normal",
1628 "dense.filter.min_ncc",
1629 "sinks.out",
1630 "input.images",
1631 "gps",
1632 ] {
1633 assert!(e.has(f), "{f} 없음:\n{e}");
1634 }
1635 assert_eq!(e.has("dense.backend"), !cumulus3d_cuda::is_available());
1636 assert!(!tmp.exists());
1637 assert!(e.to_string().starts_with("계획 검사 실패("));
1638 }
1639
1640 #[test]
1641 fn live_stride_roundtrip_and_validation() {
1642 assert_eq!(Sinks::default().live_stride, 6);
1643 let p = Plan { sinks: Sinks::default_files("o").live_stride(3), ..Plan::default() };
1644 assert_eq!(Plan::from_toml(&p.to_toml()).unwrap().sinks.live_stride, 3);
1645 let e = Recon::declare().sinks(Sinks::default_files("o").live_stride(0)).no_dense().build().unwrap_err();
1646 assert!(e.has("sinks.live_stride"), "{e}");
1647 }
1648
1649 #[test]
1650 fn enu_without_gps_is_error() {
1651 let e = Recon::declare().no_gps().no_dense().build().unwrap_err();
1652 assert!(e.has("gps"), "{e}");
1653 let e = Recon::declare().no_gps().align(AlignFrame::None).no_dense().build().unwrap_err();
1654 assert!(!e.has("gps"), "{e}");
1655 }
1656}