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