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 live: Live,
133 pub dense: Dense,
135 pub sinks: Sinks,
137}
138
139impl Default for Plan {
140 fn default() -> Self {
141 Self {
142 seed: None,
143 threads: 0,
144 gps: Some(PathBuf::from("data/gps_ref.txt")),
145 pairing: Pairing::Formation,
146 input: Source::default(),
147 zones: Zones::default(),
148 align: Align::default(),
149 features: Features::default(),
150 matching: Matching::default(),
151 sparse: Sparse::default(),
152 live: Live::default(),
153 dense: Dense::default(),
154 sinks: Sinks::default_files("out"),
155 }
156 }
157}
158
159#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
161#[serde(default, deny_unknown_fields)]
162pub struct Source {
163 pub images: PathBuf,
165 pub cameras: Vec<String>,
167 pub stride: usize,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub positions: Option<usize>,
172}
173
174impl Default for Source {
175 fn default() -> Self {
176 Self { images: PathBuf::from("data/images"), cameras: CAMS.iter().map(|c| c.to_string()).collect(), stride: 3, positions: None }
177 }
178}
179
180#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "kebab-case")]
183pub enum Pairing {
184 #[default]
186 Formation,
187}
188
189#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(default, deny_unknown_fields)]
192pub struct Zones {
193 pub span: usize,
195 pub overlap: usize,
197}
198
199impl Default for Zones {
200 fn default() -> Self {
201 Self { span: 12, overlap: 2 }
202 }
203}
204
205#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "kebab-case")]
208pub enum AlignFrame {
209 #[default]
211 Enu,
212 None,
214}
215
216#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(default, deny_unknown_fields)]
219pub struct Align {
220 pub frame: AlignFrame,
222 pub fixed_origin: bool,
224}
225
226#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(default, deny_unknown_fields)]
229pub struct Features {
230 pub backend: String,
232 pub max_num_features: usize,
234 pub max_image_size: usize,
236}
237
238impl Default for Features {
239 fn default() -> Self {
240 Self { backend: "cpu".into(), max_num_features: 8192, max_image_size: 3200 }
241 }
242}
243
244#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(default, deny_unknown_fields)]
247pub struct Matching {
248 pub backend: String,
250}
251
252impl Default for Matching {
253 fn default() -> Self {
254 Self { backend: "cpu".into() }
255 }
256}
257
258#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
260#[serde(default, deny_unknown_fields)]
261pub struct Sparse {
262 pub incremental_triangulation: bool,
264 pub backend: String,
266 pub ba_backend: String,
268 pub stable_display: bool,
270 pub settle_tol: f64,
272 pub settle_max_zone: usize,
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub intrinsics_prior: Option<PathBuf>,
277 pub intrinsics_prior_sigma: f64,
279 pub intrinsics_prior_dist_sigma: f64,
281}
282
283impl Default for Sparse {
284 fn default() -> Self {
285 Self {
286 incremental_triangulation: false,
287 backend: "cpu".into(),
288 ba_backend: "cpu".into(),
289 stable_display: false,
290 settle_tol: 0.01,
291 settle_max_zone: 3,
292 intrinsics_prior: None,
293 intrinsics_prior_sigma: crate::intrinsics::DEFAULT_FOCAL_SIGMA,
294 intrinsics_prior_dist_sigma: crate::intrinsics::DEFAULT_DIST_SIGMA,
295 }
296 }
297}
298
299#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(default, deny_unknown_fields)]
302pub struct Live {
303 pub lookahead: usize,
306 pub batch: usize,
310 pub preview: bool,
312}
313
314impl Live {
315 pub fn live_node() -> Self {
317 Self { lookahead: 4, batch: 1, preview: false }
318 }
319}
320
321#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
323#[serde(default, deny_unknown_fields)]
324pub struct Dense {
325 pub enabled: bool,
327 pub backend: String,
329 pub undistort_backend: String,
331 pub profile: String,
333 pub max_image_size: i64,
335 pub views: usize,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 pub max_levels: Option<usize>,
340 pub serialize: bool,
342 pub depth_cache: bool,
344 pub fusion: Fusion,
346 pub filter: Filter,
348}
349
350impl Default for Dense {
351 fn default() -> Self {
352 Self {
353 enabled: true,
354 backend: "cuda".into(),
355 undistort_backend: "cpu".into(),
356 profile: "fast".into(),
357 max_image_size: 960,
358 views: 10,
359 max_levels: None,
360 serialize: false,
361 depth_cache: false,
362 fusion: Fusion::default(),
363 filter: Filter::default(),
364 }
365 }
366}
367
368#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
370#[serde(default, deny_unknown_fields)]
371pub struct Fusion {
372 #[serde(skip_serializing_if = "Option::is_none")]
374 pub mode: Option<String>,
375 #[serde(skip_serializing_if = "Option::is_none")]
377 pub min_views: Option<usize>,
378 #[serde(skip_serializing_if = "Option::is_none")]
380 pub depth_error: Option<f64>,
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub normal_error: Option<f64>,
384 #[serde(skip_serializing_if = "Option::is_none")]
386 pub reproj_error: Option<f64>,
387 #[serde(skip_serializing_if = "Option::is_none")]
389 pub residual: Option<String>,
390 #[serde(skip_serializing_if = "Option::is_none")]
392 pub smooth_iters: Option<usize>,
393 #[serde(skip_serializing_if = "Option::is_none")]
395 pub smooth_radius: Option<f64>,
396 #[serde(skip_serializing_if = "Option::is_none")]
398 pub smooth_range: Option<f64>,
399 #[serde(skip_serializing_if = "Option::is_none")]
401 pub smooth_normal: Option<f64>,
402 #[serde(skip_serializing_if = "Option::is_none")]
404 pub visibility_views: Option<usize>,
405 #[serde(skip_serializing_if = "Option::is_none")]
407 pub visibility_tau: Option<f64>,
408 #[serde(skip_serializing_if = "Option::is_none")]
410 pub visibility_min_violations: Option<usize>,
411}
412
413#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
416#[serde(default, deny_unknown_fields)]
417pub struct Filter {
418 #[serde(skip_serializing_if = "Option::is_none")]
420 pub min_views: Option<u32>,
421 #[serde(skip_serializing_if = "Option::is_none")]
423 pub geom_error: Option<f32>,
424 #[serde(skip_serializing_if = "Option::is_none")]
426 pub min_ncc: Option<f32>,
427 #[serde(skip_serializing_if = "Option::is_none")]
429 pub median: Option<bool>,
430 #[serde(skip_serializing_if = "Option::is_none")]
432 pub fill_holes: Option<bool>,
433}
434
435impl Dense {
436 pub fn profile(name: &str) -> Self {
438 Self { profile: name.to_string(), ..Self::default() }
439 }
440 pub fn off() -> Self {
442 Self { enabled: false, ..Self::default() }
443 }
444 pub fn backend(mut self, name: &str) -> Self {
446 self.backend = name.to_string();
447 self
448 }
449 pub fn undistort_backend(mut self, name: &str) -> Self {
451 self.undistort_backend = name.to_string();
452 self
453 }
454 pub fn max_image_size(mut self, n: i64) -> Self {
456 self.max_image_size = n;
457 self
458 }
459 pub fn views(mut self, n: usize) -> Self {
461 self.views = n;
462 self
463 }
464 pub fn serialize(mut self, on: bool) -> Self {
466 self.serialize = on;
467 self
468 }
469 pub fn depth_cache(mut self, on: bool) -> Self {
471 self.depth_cache = on;
472 self
473 }
474 pub fn fusion_mode(mut self, mode: &str) -> Self {
476 self.fusion.mode = Some(mode.to_string());
477 self
478 }
479 pub fn fusion_min_views(mut self, n: usize) -> Self {
481 self.fusion.min_views = Some(n);
482 self
483 }
484 pub fn fusion_depth_error(mut self, v: f64) -> Self {
486 self.fusion.depth_error = Some(v);
487 self
488 }
489 pub fn fusion_normal_error(mut self, deg: f64) -> Self {
491 self.fusion.normal_error = Some(deg);
492 self
493 }
494 pub fn fusion_reproj_error(mut self, px: f64) -> Self {
496 self.fusion.reproj_error = Some(px);
497 self
498 }
499 pub fn fusion_residual(mut self, mode: &str) -> Self {
501 self.fusion.residual = Some(mode.to_string());
502 self
503 }
504 pub fn fusion_smooth_iters(mut self, n: usize) -> Self {
506 self.fusion.smooth_iters = Some(n);
507 self
508 }
509 pub fn fusion_smooth_radius(mut self, r: f64) -> Self {
511 self.fusion.smooth_radius = Some(r);
512 self
513 }
514 pub fn fusion_smooth_range(mut self, r: f64) -> Self {
516 self.fusion.smooth_range = Some(r);
517 self
518 }
519 pub fn fusion_smooth_normal(mut self, deg: f64) -> Self {
521 self.fusion.smooth_normal = Some(deg);
522 self
523 }
524 pub fn max_levels(mut self, n: usize) -> Self {
526 self.max_levels = Some(n);
527 self
528 }
529 pub fn fill_holes(mut self, on: bool) -> Self {
531 self.filter.fill_holes = Some(on);
532 self
533 }
534 pub fn filter_min_views(mut self, n: u32) -> Self {
536 self.filter.min_views = Some(n);
537 self
538 }
539 pub fn filter_geom_error(mut self, px: f32) -> Self {
541 self.filter.geom_error = Some(px);
542 self
543 }
544 pub fn filter_min_ncc(mut self, v: f32) -> Self {
546 self.filter.min_ncc = Some(v);
547 self
548 }
549 pub fn median_filter(mut self, on: bool) -> Self {
551 self.filter.median = Some(on);
552 self
553 }
554
555 pub fn apply(&self, o: &mut DensifyOptions) -> Result<(), String> {
557 let f = &self.fusion;
558 if let Some(v) = self.max_levels {
559 o.max_levels = v;
560 }
561 if let Some(m) = &f.mode {
562 o.set_fusion_mode(parse_fusion_mode(m)?);
563 }
564 if let Some(v) = f.min_views {
565 o.fusion.min_consistent_views = v;
566 }
567 if let Some(v) = f.depth_error {
568 o.fusion.max_depth_error = v;
569 }
570 if let Some(v) = f.normal_error {
571 o.fusion.max_normal_error_deg = v;
572 }
573 if let Some(v) = f.reproj_error {
574 o.fusion.max_reproj_error = v;
575 }
576 if let Some(v) = f.smooth_iters {
577 o.fusion.smooth.iterations = v;
578 }
579 if let Some(v) = f.smooth_radius {
580 o.fusion.smooth.radius_gsd = v;
581 }
582 if let Some(v) = f.smooth_range {
583 o.fusion.smooth.sigma_range_gsd = v;
584 }
585 if let Some(v) = f.smooth_normal {
586 o.fusion.smooth.normal_deg = v;
587 }
588 if let Some(v) = f.visibility_views {
589 o.fusion.visibility.check_views = v;
590 }
591 if let Some(v) = f.visibility_tau {
592 o.fusion.visibility.tau = v;
593 }
594 if let Some(v) = f.visibility_min_violations {
595 o.fusion.visibility.min_violations = v;
596 }
597 if let Some(m) = &f.residual {
598 o.fusion.residual =
599 FusionResidual::parse(m).ok_or_else(|| format!("알 수 없는 융합 남은 픽셀 처리 {m} (none|release|second-pass)"))?;
600 }
601 let g = &self.filter;
602 if let Some(v) = g.min_views {
603 o.filter.min_num_consistent = v;
604 }
605 if let Some(v) = g.geom_error {
606 o.filter.geom_max_cost = v;
607 }
608 if let Some(v) = g.min_ncc {
609 o.filter.min_ncc = v;
610 }
611 if let Some(v) = g.median {
612 o.filter.median_filter = v;
613 }
614 if let Some(v) = g.fill_holes {
615 o.post.fill_holes = v;
616 }
617 Ok(())
618 }
619
620 pub fn config(&self) -> Result<DenseConfig, String> {
623 let mut d = DenseConfig::new(make_pm_backend(&self.backend), parse_profile(&self.profile)?);
624 d.undistort_backend = make_undistort_backend(&self.undistort_backend)?;
625 d.undistort.max_image_size = self.max_image_size;
626 d.densify.neighbors.num_views = self.views;
627 self.apply(&mut d.densify)?;
628 if self.serialize {
629 d.lock = Some(Arc::new(Mutex::new(())));
630 }
631 if self.depth_cache {
632 d.depth_cache = Some(Arc::new(DepthMapCache::new()));
633 }
634 Ok(d)
635 }
636}
637
638fn parse_fusion_mode(m: &str) -> Result<FusionMode, String> {
639 FusionMode::parse(m).ok_or_else(|| format!("알 수 없는 융합 방식 {m} (consistency|traversal)"))
640}
641
642#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
644#[serde(default, deny_unknown_fields)]
645pub struct Sinks {
646 #[serde(skip_serializing_if = "Option::is_none")]
648 pub out: Option<PathBuf>,
649 pub clean: bool,
651 pub echo: bool,
653 pub timeline: bool,
655 pub run_log: bool,
657 pub zone_ply: bool,
659 pub snapshots: bool,
661 pub models: bool,
663 pub live_stride: usize,
665}
666
667impl Default for Sinks {
668 fn default() -> Self {
670 Self { out: None, ..Self::default_files("") }
671 }
672}
673
674impl Sinks {
675 pub fn default_files(out: impl Into<PathBuf>) -> Self {
677 Self {
678 out: Some(out.into()),
679 clean: true,
680 echo: true,
681 timeline: true,
682 run_log: true,
683 zone_ply: true,
684 snapshots: true,
685 models: false,
686 live_stride: crate::post::LiveOptions::default().stride,
687 }
688 }
689 pub fn none() -> Self {
691 Self {
692 out: None,
693 clean: false,
694 echo: false,
695 timeline: false,
696 run_log: false,
697 zone_ply: false,
698 snapshots: false,
699 models: false,
700 live_stride: crate::post::LiveOptions::default().stride,
701 }
702 }
703 pub fn clean(mut self, on: bool) -> Self {
705 self.clean = on;
706 self
707 }
708 pub fn echo(mut self, on: bool) -> Self {
710 self.echo = on;
711 self
712 }
713 pub fn timeline(mut self, on: bool) -> Self {
715 self.timeline = on;
716 self
717 }
718 pub fn run_log(mut self, on: bool) -> Self {
720 self.run_log = on;
721 self
722 }
723 pub fn zone_ply(mut self, on: bool) -> Self {
725 self.zone_ply = on;
726 self
727 }
728 pub fn snapshots(mut self, on: bool) -> Self {
730 self.snapshots = on;
731 self
732 }
733 pub fn models(mut self, on: bool) -> Self {
735 self.models = on;
736 self
737 }
738 pub fn live_stride(mut self, stride: usize) -> Self {
740 self.live_stride = stride;
741 self
742 }
743 fn set(&self) -> SinkSet {
744 SinkSet {
745 timeline: self.timeline,
746 run_log: self.run_log,
747 zone_ply: self.zone_ply,
748 models: self.models,
749 snapshots: self.snapshots,
750 live_stream: None,
751 live_stride: self.live_stride,
752 ..SinkSet::all()
753 }
754 }
755}
756
757impl Plan {
758 pub fn to_toml(&self) -> String {
760 toml::to_string(self).expect("계획은 항상 TOML 로 바꿀 수 있다")
761 }
762
763 pub fn to_toml_annotated(&self) -> String {
767 let s = self.to_toml();
768 let Ok(profile) = parse_profile(&self.dense.profile) else { return s };
769 let o = DensifyOptions::with_profile(profile);
770 let (f, g) = (&o.fusion, &o.filter);
771 let mode = match f.mode {
772 FusionMode::Consistency => "consistency",
773 FusionMode::Traversal => "traversal",
774 };
775 let fusion = format!(
776 "# {} 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",
777 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
778 );
779 let filter = format!(
780 "# {} profile defaults:\n# min_views = {}\n# geom_error = {}\n# min_ncc = {}\n# median = {}\n# fill_holes = {}\n",
781 self.dense.profile, g.min_num_consistent, g.geom_max_cost, g.min_ncc, g.median_filter, o.post.fill_holes
782 );
783 let mut out = String::with_capacity(s.len() + fusion.len() + filter.len());
784 let mut lines = s.lines().peekable();
785 while let Some(l) = lines.next() {
786 out.push_str(l);
787 out.push('\n');
788 let empty = lines.peek().is_none_or(|n| n.trim().is_empty() || n.starts_with('['));
789 if empty && l == "[dense.fusion]" {
790 out.push_str(&fusion);
791 } else if empty && l == "[dense.filter]" {
792 out.push_str(&filter);
793 }
794 }
795 out
796 }
797
798 pub fn from_toml(s: &str) -> Result<Self, String> {
800 toml::from_str(s).map_err(|e| format!("계획 TOML: {e}"))
801 }
802
803 pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
805 let p = path.as_ref();
806 let s = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
807 Self::from_toml(&s).map_err(|e| format!("{}: {e}", p.display()))
808 }
809
810 pub fn from_stream(c: &StreamConfig) -> Self {
812 Self {
813 seed: c.seed,
814 threads: c.threads,
815 gps: Some(c.src.join("gps_ref.txt")),
816 pairing: Pairing::Formation,
817 input: Source {
818 images: c.src.join("images"),
819 cameras: CAMS.iter().map(|s| s.to_string()).collect(),
820 stride: c.stride,
821 positions: c.max_positions,
822 },
823 zones: Zones { span: c.span, overlap: c.overlap },
824 align: Align { frame: AlignFrame::Enu, fixed_origin: c.fixed_enu_origin },
825 features: Features {
826 backend: c.sift_backend.clone(),
827 max_num_features: c.max_num_features,
828 max_image_size: c.sift_max_image_size,
829 },
830 matching: Matching { backend: c.match_backend.clone() },
831 sparse: Sparse {
832 incremental_triangulation: c.incremental_triangulation,
833 stable_display: c.stable_display,
834 settle_tol: c.settle_tol,
835 settle_max_zone: c.settle_max_zone,
836 intrinsics_prior: c.intrinsics_prior.clone(),
837 intrinsics_prior_sigma: c.intrinsics_prior_sigma,
838 intrinsics_prior_dist_sigma: c.intrinsics_prior_dist_sigma,
839 ..Default::default()
840 },
841 live: Live { lookahead: c.lookahead, preview: c.preview, ..Live::default() },
842 dense: Dense {
843 enabled: !c.no_dense,
844 backend: c.pm_backend.clone(),
845 undistort_backend: c.undistort_backend.clone(),
846 profile: c.mvs_profile.clone(),
847 max_image_size: c.dense_max_image_size,
848 views: c.number_views,
849 max_levels: c.max_levels,
850 serialize: c.serialize_dense,
851 depth_cache: c.depth_cache,
852 fusion: c.fusion.clone(),
853 filter: Filter::default(),
854 },
855 sinks: Sinks { models: c.save_models, echo: c.echo, live_stride: c.live_stride, ..Sinks::default_files(&c.out) },
856 }
857 }
858}
859
860#[derive(Clone, Debug, PartialEq, Eq)]
866pub struct Problem {
867 pub field: String,
869 pub message: String,
871}
872
873#[derive(Clone, Debug, PartialEq, Eq)]
875pub struct PlanError {
876 pub problems: Vec<Problem>,
878}
879
880impl PlanError {
881 pub fn has(&self, field: &str) -> bool {
883 self.problems.iter().any(|p| p.field == field)
884 }
885}
886
887impl fmt::Display for PlanError {
888 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
889 write!(f, "계획 검사 실패({} 건)", self.problems.len())?;
890 for p in &self.problems {
891 write!(f, "\n - {}: {}", p.field, p.message)?;
892 }
893 Ok(())
894 }
895}
896
897impl std::error::Error for PlanError {}
898
899#[derive(Default)]
900struct Problems(Vec<Problem>);
901
902impl Problems {
903 fn add(&mut self, field: &str, message: impl Into<String>) {
904 self.0.push(Problem { field: field.to_string(), message: message.into() });
905 }
906 fn check(&mut self, ok: bool, field: &str, message: impl FnOnce() -> String) {
907 if !ok {
908 self.add(field, message());
909 }
910 }
911}
912
913type Attach = Box<dyn FnOnce(Pipeline<Session>) -> Pipeline<Session>>;
918
919pub struct ReconBuilder {
921 plan: Plan,
922 attach: Vec<Attach>,
923 pending: Vec<Problem>,
924 frames: bool,
925}
926
927impl fmt::Debug for ReconBuilder {
928 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
929 f.debug_struct("ReconBuilder").field("plan", &self.plan).field("hooks", &self.attach.len()).field("pending", &self.pending).finish()
930 }
931}
932
933impl ReconBuilder {
934 pub fn plan(&self) -> &Plan {
936 &self.plan
937 }
938
939 pub fn input(mut self, src: impl AsRef<Path>) -> Self {
941 let src = src.as_ref();
942 self.plan.input.images = src.join("images");
943 self.plan.gps = Some(src.join("gps_ref.txt"));
944 self
945 }
946
947 pub fn images(mut self, dir: impl Into<PathBuf>) -> Self {
949 self.plan.input.images = dir.into();
950 self
951 }
952
953 pub fn cameras<S: Into<String>>(mut self, cams: impl IntoIterator<Item = S>) -> Self {
955 self.plan.input.cameras = cams.into_iter().map(Into::into).collect();
956 self
957 }
958
959 pub fn preset(mut self, name: &str) -> Self {
962 match name {
963 DEFAULT_PRESET => {
964 self.plan.input.cameras = CAMS.iter().map(|c| c.to_string()).collect();
965 self.plan.input.stride = 3;
966 self.plan.pairing = Pairing::Formation;
967 }
968 other => self
969 .pending
970 .push(Problem { field: "preset".into(), message: format!("알 수 없는 프리셋 {other} ({})", PRESETS.join("|")) }),
971 }
972 self
973 }
974
975 pub fn stride(mut self, n: usize) -> Self {
977 self.plan.input.stride = n;
978 self
979 }
980
981 pub fn positions(mut self, n: usize) -> Self {
983 self.plan.input.positions = Some(n);
984 self
985 }
986
987 pub fn pairing(mut self, p: Pairing) -> Self {
989 self.plan.pairing = p;
990 self
991 }
992
993 pub fn zones(mut self, span: usize, overlap: usize) -> Self {
995 self.plan.zones = Zones { span, overlap };
996 self
997 }
998
999 pub fn gps(mut self, path: impl Into<PathBuf>) -> Self {
1001 self.plan.gps = Some(path.into());
1002 self
1003 }
1004
1005 pub fn no_gps(mut self) -> Self {
1007 self.plan.gps = None;
1008 self
1009 }
1010
1011 pub fn align(mut self, frame: AlignFrame) -> Self {
1013 self.plan.align.frame = frame;
1014 self
1015 }
1016
1017 pub fn fixed_enu_origin(mut self, on: bool) -> Self {
1019 self.plan.align.fixed_origin = on;
1020 self
1021 }
1022
1023 pub fn features(mut self, f: Features) -> Self {
1025 self.plan.features = f;
1026 self
1027 }
1028
1029 pub fn match_backend(mut self, name: &str) -> Self {
1031 self.plan.matching.backend = name.to_string();
1032 self
1033 }
1034
1035 pub fn gpu(mut self) -> Self {
1037 self.plan.features.backend = "cuda".into();
1038 self.plan.matching.backend = "cuda".into();
1039 self.plan.sparse.backend = "cuda".into();
1040 self.plan.dense.backend = "cuda".into();
1041 self.plan.sparse.ba_backend = "cuda".into();
1042 self.plan.dense.undistort_backend = "cuda".into();
1043 self
1044 }
1045
1046 pub fn incremental_triangulation(mut self, on: bool) -> Self {
1048 self.plan.sparse.incremental_triangulation = on;
1049 self
1050 }
1051
1052 pub fn stable_display(mut self, on: bool) -> Self {
1054 self.plan.sparse.stable_display = on;
1055 self
1056 }
1057
1058 pub fn settle(mut self, tol: f64, max_zone: usize) -> Self {
1061 self.plan.sparse.settle_tol = tol;
1062 self.plan.sparse.settle_max_zone = max_zone;
1063 self
1064 }
1065
1066 pub fn lookahead(mut self, positions: usize) -> Self {
1068 self.plan.live.lookahead = positions;
1069 self
1070 }
1071
1072 pub fn preview(mut self, on: bool) -> Self {
1074 self.plan.live.preview = on;
1075 self
1076 }
1077
1078 pub fn dense(mut self, d: Dense) -> Self {
1080 self.plan.dense = Dense { enabled: true, ..d };
1081 self
1082 }
1083
1084 pub fn no_dense(mut self) -> Self {
1086 self.plan.dense.enabled = false;
1087 self
1088 }
1089
1090 pub fn sinks(mut self, s: Sinks) -> Self {
1092 self.plan.sinks = s;
1093 self
1094 }
1095
1096 pub fn seed(mut self, seed: u64) -> Self {
1098 self.plan.seed = Some(seed);
1099 self
1100 }
1101
1102 pub fn threads(mut self, n: usize) -> Self {
1104 self.plan.threads = n;
1105 self
1106 }
1107
1108 fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + 'static) -> Self {
1109 self.attach.push(Box::new(f));
1110 self
1111 }
1112
1113 pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
1116 where
1117 F: FnMut(&crate::events::Event) + Send + 'static,
1118 {
1119 self.frames |= kind == EventKind::FrameDecoded;
1120 self.hook(move |p| p.on(kind, f))
1121 }
1122
1123 pub fn on_frame<F>(mut self, f: F) -> Self
1127 where
1128 F: FnMut(&Arc<Frame>) + Send + 'static,
1129 {
1130 self.frames = true;
1131 self.hook(move |p| p.on_frame(f))
1132 }
1133
1134 pub fn on_any<F>(self, f: F) -> Self
1136 where
1137 F: FnMut(&crate::events::Event) + Send + 'static,
1138 {
1139 self.hook(move |p| p.on_any(f))
1140 }
1141
1142 pub fn on_zone_preview<F>(self, f: F) -> Self
1144 where
1145 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
1146 {
1147 self.hook(move |p| p.on_zone_preview(f))
1148 }
1149
1150 pub fn on_zone_refined<F>(self, f: F) -> Self
1152 where
1153 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
1154 {
1155 self.hook(move |p| p.on_zone_refined(f))
1156 }
1157
1158 pub fn on_snapshot<F>(self, f: F) -> Self
1160 where
1161 F: FnMut(usize, &Arc<PointCloud>) + Send + 'static,
1162 {
1163 self.hook(move |p| p.on_snapshot(f))
1164 }
1165
1166 pub fn on_position_done<F>(self, f: F) -> Self
1168 where
1169 F: FnMut(usize, usize, usize) + Send + 'static,
1170 {
1171 self.hook(move |p| p.on_position_done(f))
1172 }
1173
1174 pub fn on_message<F>(self, f: F) -> Self
1176 where
1177 F: FnMut(EventKind, &str) + Send + 'static,
1178 {
1179 self.hook(move |p| p.on_message(f))
1180 }
1181
1182 pub fn policy(self, kind: EventKind, p: QueuePolicy) -> Self {
1184 self.hook(move |pl| pl.policy(kind, p))
1185 }
1186
1187 pub fn build(self) -> Result<Recon, PlanError> {
1194 let ReconBuilder { plan, attach, pending, frames } = self;
1195 let mut pr = Problems(pending);
1196 let (layout, gps) = check(&plan, &mut pr);
1197 if !pr.0.is_empty() {
1198 return Err(PlanError { problems: pr.0 });
1199 }
1200 let (Some(layout), Some(gps)) = (layout, gps) else {
1201 unreachable!("문제가 없으면 입력과 GPS 를 읽었다");
1202 };
1203 Ok(Recon { plan, attach, layout, gps, frames })
1204 }
1205}
1206
1207fn backend_ok(pr: &mut Problems, field: &str, name: &str, allowed: &[&str]) {
1208 if !allowed.contains(&name) {
1209 pr.add(field, format!("알 수 없는 백엔드 {name} ({})", allowed.join("|")));
1210 } else if name == "cuda" && !cumulus3d_cuda::is_available() {
1211 pr.add(field, "CUDA 장치를 찾지 못함 — cuda 백엔드를 쓸 수 없다");
1212 }
1213}
1214
1215fn writable_target(p: &Path) -> Result<(), String> {
1217 if p.exists() {
1218 let m = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?;
1219 if !m.is_dir() {
1220 return Err(format!("{} 는 폴더가 아님", p.display()));
1221 }
1222 if m.permissions().readonly() {
1223 return Err(format!("{} 에 쓸 수 없음", p.display()));
1224 }
1225 return Ok(());
1226 }
1227 let mut a = p.parent();
1228 while let Some(d) = a {
1229 let d = if d.as_os_str().is_empty() { Path::new(".") } else { d };
1230 if d.exists() {
1231 let m = std::fs::metadata(d).map_err(|e| format!("{}: {e}", d.display()))?;
1232 if !m.is_dir() {
1233 return Err(format!("{} 는 폴더가 아님", d.display()));
1234 }
1235 if m.permissions().readonly() {
1236 return Err(format!("{} 에 쓸 수 없음", d.display()));
1237 }
1238 return Ok(());
1239 }
1240 a = d.parent();
1241 }
1242 Ok(())
1243}
1244
1245fn check(plan: &Plan, pr: &mut Problems) -> (Option<Layout>, Option<Vec<GpsRecord>>) {
1246 let inp = &plan.input;
1248 pr.check(inp.stride >= 1, "input.stride", || "1 이상이어야 함".into());
1249 pr.check(inp.positions != Some(0), "input.positions", || "1 이상이어야 함".into());
1250 pr.check(!inp.cameras.is_empty(), "input.cameras", || "카메라 폴더 목록이 비어 있음".into());
1251 let mut seen = std::collections::BTreeSet::new();
1252 for c in &inp.cameras {
1253 if c.is_empty() || c.contains(['/', '\\']) {
1254 pr.add("input.cameras", format!("잘못된 카메라 폴더 이름 {c:?}"));
1255 } else if !seen.insert(c.as_str()) {
1256 pr.add("input.cameras", format!("카메라 {c} 중복"));
1257 }
1258 }
1259 let z = plan.zones;
1260 pr.check(z.span >= 1, "zones.span", || "1 이상이어야 함".into());
1261 pr.check(z.overlap < z.span.max(1), "zones.overlap", || format!("구역 크기({})보다 작아야 함 (지금 {})", z.span, z.overlap));
1262 let f = &plan.features;
1263 backend_ok(pr, "features.backend", &f.backend, &["cpu", "cuda"]);
1264 pr.check(f.max_num_features >= 1, "features.max_num_features", || "1 이상이어야 함".into());
1265 pr.check(f.max_image_size >= 16, "features.max_image_size", || "16 이상이어야 함".into());
1266 backend_ok(pr, "matching.backend", &plan.matching.backend, &["cpu", "cuda"]);
1267 backend_ok(pr, "sparse.backend", &plan.sparse.backend, &["cpu", "cuda"]);
1268 backend_ok(pr, "sparse.ba_backend", &plan.sparse.ba_backend, &["cpu", "cuda"]);
1269 backend_ok(pr, "dense.undistort_backend", &plan.dense.undistort_backend, &["cpu", "cuda"]);
1270
1271 let d = &plan.dense;
1272 if d.enabled {
1273 if d.backend != "cuda" {
1274 pr.add("dense.backend", format!("알 수 없는 백엔드 {} (cuda)", d.backend));
1275 } else if !cumulus3d_cuda::is_available() {
1276 pr.add("dense.backend", "조밀화에는 CUDA 장치가 필요한데 장치를 찾지 못함 — `.no_dense()` 또는 [dense] enabled = false");
1277 }
1278 if let Err(e) = parse_profile(&d.profile) {
1279 pr.add("dense.profile", e);
1280 }
1281 pr.check(d.max_image_size >= 16, "dense.max_image_size", || "16 이상이어야 함".into());
1282 pr.check((1..=32).contains(&d.views), "dense.views", || format!("1..=32 이어야 함 (지금 {})", d.views));
1283 if let Some(v) = d.max_levels {
1284 pr.check(v >= 1, "dense.max_levels", || "1 이상이어야 함".into());
1285 }
1286 let fu = &d.fusion;
1287 if let Some(m) = &fu.mode {
1288 if let Err(e) = parse_fusion_mode(m) {
1289 pr.add("dense.fusion.mode", e);
1290 }
1291 }
1292 if let Some(m) = &fu.residual {
1293 pr.check(FusionResidual::parse(m).is_some(), "dense.fusion.residual", || {
1294 format!("알 수 없는 처리 {m} (none|release|second-pass)")
1295 });
1296 }
1297 if let Some(v) = fu.min_views {
1298 pr.check(v >= 1, "dense.fusion.min_views", || "1 이상이어야 함".into());
1299 }
1300 if let Some(v) = fu.depth_error {
1301 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.depth_error", || format!("양수여야 함 (지금 {v})"));
1302 }
1303 if let Some(v) = fu.normal_error {
1304 pr.check(v > 0.0 && v <= 180.0, "dense.fusion.normal_error", || format!("(0, 180] 이어야 함 (지금 {v})"));
1305 }
1306 if let Some(v) = fu.reproj_error {
1307 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.reproj_error", || format!("양수여야 함 (지금 {v})"));
1308 }
1309 if let Some(v) = fu.visibility_tau {
1310 pr.check(v > 0.0 && v < 1.0, "dense.fusion.visibility_tau", || format!("(0, 1) 이어야 함 (지금 {v})"));
1311 }
1312 if let Some(v) = fu.smooth_radius {
1313 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_radius", || format!("양수여야 함 (지금 {v})"));
1314 }
1315 if let Some(v) = fu.smooth_range {
1316 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_range", || format!("양수여야 함 (지금 {v})"));
1317 }
1318 if let Some(v) = fu.smooth_normal {
1319 pr.check(v > 0.0 && v <= 180.0, "dense.fusion.smooth_normal", || format!("(0, 180] 이어야 함 (지금 {v})"));
1320 }
1321 let fi = &d.filter;
1322 if let Some(v) = fi.geom_error {
1323 pr.check(v > 0.0 && v.is_finite(), "dense.filter.geom_error", || format!("양수여야 함 (지금 {v})"));
1324 }
1325 if let Some(v) = fi.min_ncc {
1326 pr.check((-1.0..=1.0).contains(&v), "dense.filter.min_ncc", || format!("[-1, 1] 이어야 함 (지금 {v})"));
1327 }
1328 }
1329
1330 if let Some(p) = &plan.sparse.intrinsics_prior {
1332 if let Err(e) = IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma) {
1333 pr.add("sparse.intrinsics_prior", e);
1334 }
1335 }
1336
1337 let s = &plan.sinks;
1339 pr.check(s.live_stride >= 1, "sinks.live_stride", || "must be >= 1 (1 = keep all)".into());
1340 if let Some(out) = &s.out {
1341 if let Err(e) = writable_target(out) {
1342 pr.add("sinks.out", e);
1343 }
1344 if s.clean && (inp.images.starts_with(out) || plan.gps.as_ref().is_some_and(|g| g.starts_with(out))) {
1345 pr.add("sinks.out", format!("시작 때 지울 출력 폴더 {} 안에 입력이 있음", out.display()));
1346 }
1347 }
1348
1349 let mut layout = None;
1351 if !inp.images.is_dir() {
1352 pr.add("input.images", format!("영상 폴더 없음: {}", inp.images.display()));
1353 } else {
1354 let missing: Vec<&String> = inp.cameras.iter().filter(|c| !inp.images.join(c).is_dir()).collect();
1355 for c in &missing {
1356 pr.add("input.cameras", format!("카메라 폴더 없음: {}", inp.images.join(c).display()));
1357 }
1358 if missing.is_empty() && !inp.cameras.is_empty() && inp.stride >= 1 {
1359 match Layout::discover_in(&inp.images, &inp.cameras, inp.stride) {
1360 Ok(mut l) => {
1361 if let Some(n) = inp.positions {
1362 l.truncate(n);
1363 }
1364 if l.frames.is_empty() {
1365 pr.add(
1366 "input",
1367 format!(
1368 "선택된 영상 없음: {}/{c}/{c}_NNNN.jpg 중 번호 % {} == 0 인 파일",
1369 inp.images.display(),
1370 inp.stride,
1371 c = inp.cameras[0]
1372 ),
1373 );
1374 } else {
1375 layout = Some(l);
1376 }
1377 }
1378 Err(e) => pr.add("input.images", e),
1379 }
1380 }
1381 }
1382
1383 let mut gps = None;
1385 match (plan.align.frame, &plan.gps) {
1386 (AlignFrame::None, _) => gps = Some(Vec::new()),
1387 (AlignFrame::Enu, None) => pr.add("gps", "ENU 정렬에는 GPS 파일이 필요함 — `.gps(경로)` 또는 정렬 끔([align] frame = \"none\")"),
1388 (AlignFrame::Enu, Some(p)) => match read_gps_file(p) {
1389 Err(e) => pr.add("gps", format!("{}: {e}", p.display())),
1390 Ok(all) => {
1391 if let Some(l) = &layout {
1392 let sel: Vec<GpsRecord> = all.into_iter().filter(|g| l.pos_of.contains_key(&g.name)).collect();
1393 if sel.is_empty() {
1394 pr.add("gps", format!("{}: 선택된 영상의 GPS 기록이 하나도 없음", p.display()));
1395 } else {
1396 gps = Some(sel);
1397 }
1398 } else {
1399 gps = Some(Vec::new());
1400 }
1401 }
1402 },
1403 }
1404 (layout, gps)
1405}
1406
1407pub struct Recon {
1413 plan: Plan,
1414 attach: Vec<Attach>,
1415 layout: Layout,
1416 gps: Vec<GpsRecord>,
1417 frames: bool,
1418}
1419
1420impl fmt::Debug for Recon {
1421 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1422 f.debug_struct("Recon")
1423 .field("plan", &self.plan)
1424 .field("hooks", &self.attach.len())
1425 .field("positions", &self.layout.frames.len())
1426 .finish()
1427 }
1428}
1429
1430impl Recon {
1431 pub fn declare() -> ReconBuilder {
1433 Self::from_plan(Plan::default())
1434 }
1435
1436 pub fn from_plan(plan: Plan) -> ReconBuilder {
1438 ReconBuilder { plan, attach: Vec::new(), pending: Vec::new(), frames: false }
1439 }
1440
1441 pub fn plan(&self) -> &Plan {
1443 &self.plan
1444 }
1445
1446 pub fn positions(&self) -> usize {
1448 self.layout.frames.len()
1449 }
1450
1451 pub fn layout(&self) -> &Layout {
1453 &self.layout
1454 }
1455
1456 pub fn run(self) -> Result<Summary, String> {
1460 let Recon { plan, attach, layout, gps, frames } = self;
1461 if plan.threads > 0 {
1462 let _ = rayon::ThreadPoolBuilder::new().num_threads(plan.threads).build_global();
1464 }
1465 let dense = if plan.dense.enabled { Some(plan.dense.config()?) } else { None };
1467 let prior = match &plan.sparse.intrinsics_prior {
1468 Some(p) => {
1469 Some(Arc::new(IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma)?))
1470 }
1471 None => None,
1472 };
1473 let s = &plan.sinks;
1474 let sinks = match &s.out {
1475 Some(out) => {
1476 if s.clean && out.exists() {
1477 std::fs::remove_dir_all(out).map_err(|e| format!("{}: {e}", out.display()))?;
1478 }
1479 let mut set = s.set();
1480 if plan.sparse.stable_display {
1483 let origin = gps.first().map(|g| [g.lat, g.lon, g.alt]);
1484 set.live_stream = Some(LiveStream { enu: origin.is_some(), origin_gps: origin });
1485 }
1486 Some(DefaultSinks::with_set(out, s.echo, set).map_err(|e| e.to_string())?)
1487 }
1488 None => None,
1489 };
1490 let npos = layout.frames.len();
1491 let mut sc = SessionConfig::new(plan.input.images.clone());
1492 sc.gps = gps;
1493 sc.span = plan.zones.span;
1494 sc.overlap = plan.zones.overlap;
1495 sc.total_positions = Some(npos);
1496 sc.incremental_triangulation = plan.sparse.incremental_triangulation;
1497 sc.stable_display = plan.sparse.stable_display;
1498 sc.settle_tol = plan.sparse.settle_tol;
1499 sc.settle_max_zone = plan.sparse.settle_max_zone;
1500 sc.intrinsics_prior = prior;
1501 sc.fixed_enu_origin = plan.align.fixed_origin;
1502 sc.seed = plan.seed;
1503 sc.extraction.sift.max_num_features = plan.features.max_num_features;
1504 sc.extraction.reader.max_image_size = plan.features.max_image_size;
1505 sc.sift = make_sift_backend(&plan.features.backend)?;
1506 sc.matcher = Arc::from(make_match_backend(&plan.matching.backend)?);
1507 sc.sparse = make_sparse_backend(&plan.sparse.backend)?;
1508 sc.ba_backend = make_ba_backend(&plan.sparse.ba_backend)?;
1509 sc.dense = dense;
1510 sc.history = 0;
1512 sc.decode_frames = frames;
1513 sc.lookahead = plan.live.lookahead;
1514 sc.preview = plan.live.preview;
1515
1516 let mut pl = Pipeline::new(Session::new(sc));
1517 if let Some(sinks) = sinks {
1518 pl = pl.on_any(sinks.into_hook());
1520 }
1521 for a in attach {
1522 pl = a(pl);
1523 }
1524
1525 let (span, ovl) = (plan.zones.span, plan.zones.overlap);
1526 let mut reg_end: BTreeMap<usize, Vec<ZoneRange>> = BTreeMap::new();
1527 for z in zones_upto(npos, span, ovl) {
1528 reg_end.entry(z.hi - 1).or_default().push(z);
1529 }
1530 pl.push(Input::Log(format!(
1531 "regions: {}",
1532 reg_end
1533 .iter()
1534 .map(|(e, v)| format!("{e} ->{}", v.iter().map(|z| format!(" {}:{}:{}", z.zone, z.lo, z.hi)).collect::<String>()))
1535 .collect::<Vec<_>>()
1536 .join(", ")
1537 )));
1538 let d = &plan.dense;
1539 pl.push(Input::Log(format!(
1540 "설정: span {span} overlap {ovl} stride {} 증분삼각측량 {} 깊이캐시 {} 고정원점 {} pm {}({})/sift {}/match {}/ba {} 직렬조밀화 {} 왜곡보정최대 {} 뷰 {} 스레드 {}",
1541 plan.input.stride,
1542 plan.sparse.incremental_triangulation,
1543 d.depth_cache,
1544 plan.align.fixed_origin,
1545 d.backend,
1546 d.profile,
1547 plan.features.backend,
1548 plan.matching.backend,
1549 plan.sparse.ba_backend,
1550 d.serialize,
1551 d.max_image_size,
1552 d.views,
1553 rayon::current_num_threads()
1554 )));
1555 let ahead = pl.reducer().lookahead();
1556 for p in 0..npos {
1557 for q in p..npos.min(p + 1 + plan.live.lookahead) {
1559 if !ahead.hint(&frame_set_of(&layout, &plan.input.cameras, q)) {
1560 break;
1561 }
1562 }
1563 pl.push(frame_set_of(&layout, &plan.input.cameras, p).into());
1564 if let Some(e) = pl.reducer().failed() {
1565 return Err(e.to_string());
1567 }
1568 }
1569 let (_session, summary) = pl.finish();
1570 Ok(summary)
1571 }
1572}
1573
1574#[cfg(test)]
1575mod tests {
1576 use super::*;
1577
1578 #[test]
1579 fn default_plan_toml_roundtrip() {
1580 let p = Plan::default();
1581 let s = p.to_toml();
1582 assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
1583 assert_eq!(Plan::from_toml("").unwrap(), p);
1585 }
1586
1587 #[test]
1588 fn full_plan_toml_roundtrip() {
1589 let b = Recon::declare()
1590 .input("some/where")
1591 .cameras(["a", "b"])
1592 .stride(2)
1593 .positions(5)
1594 .zones(6, 1)
1595 .fixed_enu_origin(true)
1596 .incremental_triangulation(true)
1597 .dense(
1598 Dense::profile("quality")
1599 .fusion_mode("traversal")
1600 .fusion_min_views(3)
1601 .fusion_depth_error(0.02)
1602 .fusion_normal_error(12.5)
1603 .fusion_reproj_error(1.5)
1604 .fusion_residual("second-pass")
1605 .fusion_smooth_normal(30.0)
1606 .max_levels(1)
1607 .filter_min_views(2)
1608 .filter_geom_error(1.25)
1609 .filter_min_ncc(0.1)
1610 .median_filter(true)
1611 .depth_cache(true)
1612 .serialize(true),
1613 )
1614 .sinks(Sinks::default_files("o").models(true).echo(false))
1615 .seed(42)
1616 .threads(3);
1617 let p = b.plan().clone();
1618 let s = p.to_toml();
1619 assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
1620 assert!(s.contains("[dense.fusion]") && s.contains("min_views = 3"), "{s}");
1621 assert!(s.contains("smooth_normal = 30.0") && s.contains("max_levels = 1"), "{s}");
1622 }
1623
1624 #[test]
1625 fn example_plan_parses() {
1626 let s = include_str!("../../../examples/plans/aerial-formation.toml");
1627 let p = Plan::from_toml(s).unwrap();
1628 assert_eq!(p.input.cameras, ["camF", "camR", "camL"]);
1629 assert_eq!(p.pairing, Pairing::Formation);
1630 assert_eq!(Plan::from_toml(&p.to_toml()).unwrap(), p);
1631 }
1632
1633 #[test]
1634 fn unknown_field_rejected() {
1635 assert!(Plan::from_toml("[zones]\nspam = 3\n").is_err());
1636 assert!(Plan::from_toml("pairing = \"grid\"\n").is_err());
1637 }
1638
1639 #[test]
1640 fn stream_plan_matches_builder() {
1641 let mut c = StreamConfig::new("src".into(), "dst".into());
1642 (c.stride, c.span, c.overlap, c.seed, c.save_models, c.echo) = (1, 4, 1, Some(7), true, false);
1643 let b = Recon::declare()
1644 .input("src")
1645 .preset(DEFAULT_PRESET)
1646 .stride(1)
1647 .zones(4, 1)
1648 .seed(7)
1649 .sinks(Sinks::default_files("dst").models(true).echo(false));
1650 assert_eq!(Plan::from_stream(&c), *b.plan());
1651 }
1652
1653 #[test]
1654 fn build_collects_all_problems() {
1655 let tmp = std::env::temp_dir().join(format!("cumulus3d_declare_missing_{}", std::process::id()));
1656 let e = Recon::declare()
1657 .input(tmp.join("in"))
1658 .preset("grid")
1659 .stride(0)
1660 .zones(4, 4)
1661 .dense(Dense::profile("slow").views(40).max_levels(0).fusion_smooth_normal(0.0).fusion_mode("magic").filter_min_ncc(2.0))
1662 .features(Features { backend: "fpga".into(), ..Features::default() })
1663 .sinks(Sinks::default_files(tmp.join("in")).clean(true))
1664 .build()
1665 .unwrap_err();
1666 for f in [
1667 "preset",
1668 "input.stride",
1669 "zones.overlap",
1670 "features.backend",
1671 "dense.profile",
1672 "dense.views",
1673 "dense.fusion.mode",
1674 "dense.max_levels",
1675 "dense.fusion.smooth_normal",
1676 "dense.filter.min_ncc",
1677 "sinks.out",
1678 "input.images",
1679 "gps",
1680 ] {
1681 assert!(e.has(f), "{f} 없음:\n{e}");
1682 }
1683 assert_eq!(e.has("dense.backend"), !cumulus3d_cuda::is_available());
1684 assert!(!tmp.exists());
1685 assert!(e.to_string().starts_with("계획 검사 실패("));
1686 }
1687
1688 #[test]
1689 fn live_stride_roundtrip_and_validation() {
1690 assert_eq!(Sinks::default().live_stride, 6);
1691 let p = Plan { sinks: Sinks::default_files("o").live_stride(3), ..Plan::default() };
1692 assert_eq!(Plan::from_toml(&p.to_toml()).unwrap().sinks.live_stride, 3);
1693 let e = Recon::declare().sinks(Sinks::default_files("o").live_stride(0)).no_dense().build().unwrap_err();
1694 assert!(e.has("sinks.live_stride"), "{e}");
1695 }
1696
1697 #[test]
1698 fn enu_without_gps_is_error() {
1699 let e = Recon::declare().no_gps().no_dense().build().unwrap_err();
1700 assert!(e.has("gps"), "{e}");
1701 let e = Recon::declare().no_gps().align(AlignFrame::None).no_dense().build().unwrap_err();
1702 assert!(!e.has("gps"), "{e}");
1703 }
1704}