1use crate::blocks::LiveStream;
73use crate::densewrap::{make_match_backend, make_pm_backend, make_sift_backend, make_undistort_backend, parse_profile, DenseConfig};
74use crate::events::{EventKind, Frame, ZoneRange};
75use crate::intrinsics::IntrinsicsPrior;
76use crate::pipeline::{Pipeline, QueuePolicy, Summary};
77use crate::session::{zones_upto, Input, Session, SessionConfig};
78use crate::sinks::{DefaultSinks, SinkSet};
79use crate::stream::{frame_set_of, Layout, StreamConfig, CAMS};
80use cumulus3d_core::io::ply::PointCloud;
81use cumulus3d_core::io::{read_gps_file, GpsRecord};
82use cumulus3d_dense::{DensifyOptions, DepthMapCache, FusionMode, FusionResidual};
83use serde::{Deserialize, Serialize};
84use std::collections::BTreeMap;
85use std::fmt;
86use std::path::{Path, PathBuf};
87use std::sync::{Arc, Mutex};
88
89pub const DEFAULT_PRESET: &str = "aerial-formation";
91
92pub const PRESETS: [&str; 1] = [DEFAULT_PRESET];
94
95#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
104#[serde(default, deny_unknown_fields)]
105pub struct Plan {
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub seed: Option<u64>,
109 pub threads: usize,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub gps: Option<PathBuf>,
114 pub pairing: Pairing,
116 pub input: Source,
118 pub zones: Zones,
120 pub align: Align,
122 pub features: Features,
124 pub matching: Matching,
126 pub sparse: Sparse,
128 pub dense: Dense,
130 pub sinks: Sinks,
132}
133
134impl Default for Plan {
135 fn default() -> Self {
136 Self {
137 seed: None,
138 threads: 0,
139 gps: Some(PathBuf::from("data/gps_ref.txt")),
140 pairing: Pairing::Formation,
141 input: Source::default(),
142 zones: Zones::default(),
143 align: Align::default(),
144 features: Features::default(),
145 matching: Matching::default(),
146 sparse: Sparse::default(),
147 dense: Dense::default(),
148 sinks: Sinks::default_files("out"),
149 }
150 }
151}
152
153#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
155#[serde(default, deny_unknown_fields)]
156pub struct Source {
157 pub images: PathBuf,
159 pub cameras: Vec<String>,
161 pub stride: usize,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub positions: Option<usize>,
166}
167
168impl Default for Source {
169 fn default() -> Self {
170 Self { images: PathBuf::from("data/images"), cameras: CAMS.iter().map(|c| c.to_string()).collect(), stride: 3, positions: None }
171 }
172}
173
174#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "kebab-case")]
177pub enum Pairing {
178 #[default]
180 Formation,
181}
182
183#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(default, deny_unknown_fields)]
186pub struct Zones {
187 pub span: usize,
189 pub overlap: usize,
191}
192
193impl Default for Zones {
194 fn default() -> Self {
195 Self { span: 12, overlap: 2 }
196 }
197}
198
199#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "kebab-case")]
202pub enum AlignFrame {
203 #[default]
205 Enu,
206 None,
208}
209
210#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(default, deny_unknown_fields)]
213pub struct Align {
214 pub frame: AlignFrame,
216 pub fixed_origin: bool,
218}
219
220#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(default, deny_unknown_fields)]
223pub struct Features {
224 pub backend: String,
226 pub max_num_features: usize,
228 pub max_image_size: usize,
230}
231
232impl Default for Features {
233 fn default() -> Self {
234 Self { backend: "cpu".into(), max_num_features: 8192, max_image_size: 3200 }
235 }
236}
237
238#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(default, deny_unknown_fields)]
241pub struct Matching {
242 pub backend: String,
244}
245
246impl Default for Matching {
247 fn default() -> Self {
248 Self { backend: "cpu".into() }
249 }
250}
251
252#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
254#[serde(default, deny_unknown_fields)]
255pub struct Sparse {
256 pub incremental_triangulation: bool,
258 pub stable_display: bool,
260 pub settle_tol: f64,
262 pub settle_max_zone: usize,
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub intrinsics_prior: Option<PathBuf>,
267 pub intrinsics_prior_sigma: f64,
269 pub intrinsics_prior_dist_sigma: f64,
271}
272
273impl Default for Sparse {
274 fn default() -> Self {
275 Self {
276 incremental_triangulation: false,
277 stable_display: false,
278 settle_tol: 0.01,
279 settle_max_zone: 3,
280 intrinsics_prior: None,
281 intrinsics_prior_sigma: crate::intrinsics::DEFAULT_FOCAL_SIGMA,
282 intrinsics_prior_dist_sigma: crate::intrinsics::DEFAULT_DIST_SIGMA,
283 }
284 }
285}
286
287#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
289#[serde(default, deny_unknown_fields)]
290pub struct Dense {
291 pub enabled: bool,
293 pub backend: String,
295 pub undistort_backend: String,
297 pub profile: String,
299 pub max_image_size: i64,
301 pub views: usize,
303 #[serde(skip_serializing_if = "Option::is_none")]
305 pub max_levels: Option<usize>,
306 pub serialize: bool,
308 pub depth_cache: bool,
310 pub fusion: Fusion,
312 pub filter: Filter,
314}
315
316impl Default for Dense {
317 fn default() -> Self {
318 Self {
319 enabled: true,
320 backend: "cuda".into(),
321 undistort_backend: "cpu".into(),
322 profile: "fast".into(),
323 max_image_size: 960,
324 views: 10,
325 max_levels: None,
326 serialize: false,
327 depth_cache: false,
328 fusion: Fusion::default(),
329 filter: Filter::default(),
330 }
331 }
332}
333
334#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
336#[serde(default, deny_unknown_fields)]
337pub struct Fusion {
338 #[serde(skip_serializing_if = "Option::is_none")]
340 pub mode: Option<String>,
341 #[serde(skip_serializing_if = "Option::is_none")]
343 pub min_views: Option<usize>,
344 #[serde(skip_serializing_if = "Option::is_none")]
346 pub depth_error: Option<f64>,
347 #[serde(skip_serializing_if = "Option::is_none")]
349 pub normal_error: Option<f64>,
350 #[serde(skip_serializing_if = "Option::is_none")]
352 pub reproj_error: Option<f64>,
353 #[serde(skip_serializing_if = "Option::is_none")]
355 pub residual: Option<String>,
356 #[serde(skip_serializing_if = "Option::is_none")]
358 pub smooth_iters: Option<usize>,
359 #[serde(skip_serializing_if = "Option::is_none")]
361 pub smooth_radius: Option<f64>,
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub smooth_range: Option<f64>,
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub smooth_normal: Option<f64>,
368 #[serde(skip_serializing_if = "Option::is_none")]
370 pub visibility_views: Option<usize>,
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub visibility_tau: Option<f64>,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub visibility_min_violations: Option<usize>,
377}
378
379#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
382#[serde(default, deny_unknown_fields)]
383pub struct Filter {
384 #[serde(skip_serializing_if = "Option::is_none")]
386 pub min_views: Option<u32>,
387 #[serde(skip_serializing_if = "Option::is_none")]
389 pub geom_error: Option<f32>,
390 #[serde(skip_serializing_if = "Option::is_none")]
392 pub min_ncc: Option<f32>,
393 #[serde(skip_serializing_if = "Option::is_none")]
395 pub median: Option<bool>,
396 #[serde(skip_serializing_if = "Option::is_none")]
398 pub fill_holes: Option<bool>,
399}
400
401impl Dense {
402 pub fn profile(name: &str) -> Self {
404 Self { profile: name.to_string(), ..Self::default() }
405 }
406 pub fn off() -> Self {
408 Self { enabled: false, ..Self::default() }
409 }
410 pub fn backend(mut self, name: &str) -> Self {
412 self.backend = name.to_string();
413 self
414 }
415 pub fn undistort_backend(mut self, name: &str) -> Self {
417 self.undistort_backend = name.to_string();
418 self
419 }
420 pub fn max_image_size(mut self, n: i64) -> Self {
422 self.max_image_size = n;
423 self
424 }
425 pub fn views(mut self, n: usize) -> Self {
427 self.views = n;
428 self
429 }
430 pub fn serialize(mut self, on: bool) -> Self {
432 self.serialize = on;
433 self
434 }
435 pub fn depth_cache(mut self, on: bool) -> Self {
437 self.depth_cache = on;
438 self
439 }
440 pub fn fusion_mode(mut self, mode: &str) -> Self {
442 self.fusion.mode = Some(mode.to_string());
443 self
444 }
445 pub fn fusion_min_views(mut self, n: usize) -> Self {
447 self.fusion.min_views = Some(n);
448 self
449 }
450 pub fn fusion_depth_error(mut self, v: f64) -> Self {
452 self.fusion.depth_error = Some(v);
453 self
454 }
455 pub fn fusion_normal_error(mut self, deg: f64) -> Self {
457 self.fusion.normal_error = Some(deg);
458 self
459 }
460 pub fn fusion_reproj_error(mut self, px: f64) -> Self {
462 self.fusion.reproj_error = Some(px);
463 self
464 }
465 pub fn fusion_residual(mut self, mode: &str) -> Self {
467 self.fusion.residual = Some(mode.to_string());
468 self
469 }
470 pub fn fusion_smooth_iters(mut self, n: usize) -> Self {
472 self.fusion.smooth_iters = Some(n);
473 self
474 }
475 pub fn fusion_smooth_radius(mut self, r: f64) -> Self {
477 self.fusion.smooth_radius = Some(r);
478 self
479 }
480 pub fn fusion_smooth_range(mut self, r: f64) -> Self {
482 self.fusion.smooth_range = Some(r);
483 self
484 }
485 pub fn fusion_smooth_normal(mut self, deg: f64) -> Self {
487 self.fusion.smooth_normal = Some(deg);
488 self
489 }
490 pub fn max_levels(mut self, n: usize) -> Self {
492 self.max_levels = Some(n);
493 self
494 }
495 pub fn fill_holes(mut self, on: bool) -> Self {
497 self.filter.fill_holes = Some(on);
498 self
499 }
500 pub fn filter_min_views(mut self, n: u32) -> Self {
502 self.filter.min_views = Some(n);
503 self
504 }
505 pub fn filter_geom_error(mut self, px: f32) -> Self {
507 self.filter.geom_error = Some(px);
508 self
509 }
510 pub fn filter_min_ncc(mut self, v: f32) -> Self {
512 self.filter.min_ncc = Some(v);
513 self
514 }
515 pub fn median_filter(mut self, on: bool) -> Self {
517 self.filter.median = Some(on);
518 self
519 }
520
521 pub fn apply(&self, o: &mut DensifyOptions) -> Result<(), String> {
523 let f = &self.fusion;
524 if let Some(v) = self.max_levels {
525 o.max_levels = v;
526 }
527 if let Some(m) = &f.mode {
528 o.set_fusion_mode(parse_fusion_mode(m)?);
529 }
530 if let Some(v) = f.min_views {
531 o.fusion.min_consistent_views = v;
532 }
533 if let Some(v) = f.depth_error {
534 o.fusion.max_depth_error = v;
535 }
536 if let Some(v) = f.normal_error {
537 o.fusion.max_normal_error_deg = v;
538 }
539 if let Some(v) = f.reproj_error {
540 o.fusion.max_reproj_error = v;
541 }
542 if let Some(v) = f.smooth_iters {
543 o.fusion.smooth.iterations = v;
544 }
545 if let Some(v) = f.smooth_radius {
546 o.fusion.smooth.radius_gsd = v;
547 }
548 if let Some(v) = f.smooth_range {
549 o.fusion.smooth.sigma_range_gsd = v;
550 }
551 if let Some(v) = f.smooth_normal {
552 o.fusion.smooth.normal_deg = v;
553 }
554 if let Some(v) = f.visibility_views {
555 o.fusion.visibility.check_views = v;
556 }
557 if let Some(v) = f.visibility_tau {
558 o.fusion.visibility.tau = v;
559 }
560 if let Some(v) = f.visibility_min_violations {
561 o.fusion.visibility.min_violations = v;
562 }
563 if let Some(m) = &f.residual {
564 o.fusion.residual =
565 FusionResidual::parse(m).ok_or_else(|| format!("알 수 없는 융합 남은 픽셀 처리 {m} (none|release|second-pass)"))?;
566 }
567 let g = &self.filter;
568 if let Some(v) = g.min_views {
569 o.filter.min_num_consistent = v;
570 }
571 if let Some(v) = g.geom_error {
572 o.filter.geom_max_cost = v;
573 }
574 if let Some(v) = g.min_ncc {
575 o.filter.min_ncc = v;
576 }
577 if let Some(v) = g.median {
578 o.filter.median_filter = v;
579 }
580 if let Some(v) = g.fill_holes {
581 o.post.fill_holes = v;
582 }
583 Ok(())
584 }
585
586 pub fn config(&self) -> Result<DenseConfig, String> {
589 let mut d = DenseConfig::new(make_pm_backend(&self.backend), parse_profile(&self.profile)?);
590 d.undistort_backend = make_undistort_backend(&self.undistort_backend)?;
591 d.undistort.max_image_size = self.max_image_size;
592 d.densify.neighbors.num_views = self.views;
593 self.apply(&mut d.densify)?;
594 if self.serialize {
595 d.lock = Some(Arc::new(Mutex::new(())));
596 }
597 if self.depth_cache {
598 d.depth_cache = Some(Arc::new(DepthMapCache::new()));
599 }
600 Ok(d)
601 }
602}
603
604fn parse_fusion_mode(m: &str) -> Result<FusionMode, String> {
605 FusionMode::parse(m).ok_or_else(|| format!("알 수 없는 융합 방식 {m} (consistency|traversal)"))
606}
607
608#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
610#[serde(default, deny_unknown_fields)]
611pub struct Sinks {
612 #[serde(skip_serializing_if = "Option::is_none")]
614 pub out: Option<PathBuf>,
615 pub clean: bool,
617 pub echo: bool,
619 pub timeline: bool,
621 pub run_log: bool,
623 pub zone_ply: bool,
625 pub snapshots: bool,
627 pub models: bool,
629 pub live_stride: usize,
631}
632
633impl Default for Sinks {
634 fn default() -> Self {
636 Self { out: None, ..Self::default_files("") }
637 }
638}
639
640impl Sinks {
641 pub fn default_files(out: impl Into<PathBuf>) -> Self {
643 Self {
644 out: Some(out.into()),
645 clean: true,
646 echo: true,
647 timeline: true,
648 run_log: true,
649 zone_ply: true,
650 snapshots: true,
651 models: false,
652 live_stride: crate::post::LiveOptions::default().stride,
653 }
654 }
655 pub fn none() -> Self {
657 Self {
658 out: None,
659 clean: false,
660 echo: false,
661 timeline: false,
662 run_log: false,
663 zone_ply: false,
664 snapshots: false,
665 models: false,
666 live_stride: crate::post::LiveOptions::default().stride,
667 }
668 }
669 pub fn clean(mut self, on: bool) -> Self {
671 self.clean = on;
672 self
673 }
674 pub fn echo(mut self, on: bool) -> Self {
676 self.echo = on;
677 self
678 }
679 pub fn timeline(mut self, on: bool) -> Self {
681 self.timeline = on;
682 self
683 }
684 pub fn run_log(mut self, on: bool) -> Self {
686 self.run_log = on;
687 self
688 }
689 pub fn zone_ply(mut self, on: bool) -> Self {
691 self.zone_ply = on;
692 self
693 }
694 pub fn snapshots(mut self, on: bool) -> Self {
696 self.snapshots = on;
697 self
698 }
699 pub fn models(mut self, on: bool) -> Self {
701 self.models = on;
702 self
703 }
704 pub fn live_stride(mut self, stride: usize) -> Self {
706 self.live_stride = stride;
707 self
708 }
709 fn set(&self) -> SinkSet {
710 SinkSet {
711 timeline: self.timeline,
712 run_log: self.run_log,
713 zone_ply: self.zone_ply,
714 models: self.models,
715 snapshots: self.snapshots,
716 live_stream: None,
717 live_stride: self.live_stride,
718 }
719 }
720}
721
722impl Plan {
723 pub fn to_toml(&self) -> String {
725 toml::to_string(self).expect("계획은 항상 TOML 로 바꿀 수 있다")
726 }
727
728 pub fn to_toml_annotated(&self) -> String {
732 let s = self.to_toml();
733 let Ok(profile) = parse_profile(&self.dense.profile) else { return s };
734 let o = DensifyOptions::with_profile(profile);
735 let (f, g) = (&o.fusion, &o.filter);
736 let mode = match f.mode {
737 FusionMode::Consistency => "consistency",
738 FusionMode::Traversal => "traversal",
739 };
740 let fusion = format!(
741 "# {} profile defaults:\n# mode = \"{mode}\"\n# min_views = {}\n# depth_error = {}\n# normal_error = {}\n# reproj_error = {}\n# residual = \"{}\"\n# smooth_iters = {}\n# smooth_radius = {}\n# smooth_range = {}\n# smooth_normal = {}\n",
742 self.dense.profile, f.min_consistent_views, f.max_depth_error, f.max_normal_error_deg, f.max_reproj_error, f.residual.name(), f.smooth.iterations, f.smooth.radius_gsd, f.smooth.sigma_range_gsd, f.smooth.normal_deg
743 );
744 let filter = format!(
745 "# {} profile defaults:\n# min_views = {}\n# geom_error = {}\n# min_ncc = {}\n# median = {}\n# fill_holes = {}\n",
746 self.dense.profile, g.min_num_consistent, g.geom_max_cost, g.min_ncc, g.median_filter, o.post.fill_holes
747 );
748 let mut out = String::with_capacity(s.len() + fusion.len() + filter.len());
749 let mut lines = s.lines().peekable();
750 while let Some(l) = lines.next() {
751 out.push_str(l);
752 out.push('\n');
753 let empty = lines.peek().is_none_or(|n| n.trim().is_empty() || n.starts_with('['));
754 if empty && l == "[dense.fusion]" {
755 out.push_str(&fusion);
756 } else if empty && l == "[dense.filter]" {
757 out.push_str(&filter);
758 }
759 }
760 out
761 }
762
763 pub fn from_toml(s: &str) -> Result<Self, String> {
765 toml::from_str(s).map_err(|e| format!("계획 TOML: {e}"))
766 }
767
768 pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
770 let p = path.as_ref();
771 let s = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
772 Self::from_toml(&s).map_err(|e| format!("{}: {e}", p.display()))
773 }
774
775 pub fn from_stream(c: &StreamConfig) -> Self {
777 Self {
778 seed: c.seed,
779 threads: c.threads,
780 gps: Some(c.src.join("gps_ref.txt")),
781 pairing: Pairing::Formation,
782 input: Source {
783 images: c.src.join("images"),
784 cameras: CAMS.iter().map(|s| s.to_string()).collect(),
785 stride: c.stride,
786 positions: c.max_positions,
787 },
788 zones: Zones { span: c.span, overlap: c.overlap },
789 align: Align { frame: AlignFrame::Enu, fixed_origin: c.fixed_enu_origin },
790 features: Features {
791 backend: c.sift_backend.clone(),
792 max_num_features: c.max_num_features,
793 max_image_size: c.sift_max_image_size,
794 },
795 matching: Matching { backend: c.match_backend.clone() },
796 sparse: Sparse {
797 incremental_triangulation: c.incremental_triangulation,
798 stable_display: c.stable_display,
799 settle_tol: c.settle_tol,
800 settle_max_zone: c.settle_max_zone,
801 intrinsics_prior: c.intrinsics_prior.clone(),
802 intrinsics_prior_sigma: c.intrinsics_prior_sigma,
803 intrinsics_prior_dist_sigma: c.intrinsics_prior_dist_sigma,
804 },
805 dense: Dense {
806 enabled: !c.no_dense,
807 backend: c.pm_backend.clone(),
808 undistort_backend: c.undistort_backend.clone(),
809 profile: c.mvs_profile.clone(),
810 max_image_size: c.dense_max_image_size,
811 views: c.number_views,
812 max_levels: c.max_levels,
813 serialize: c.serialize_dense,
814 depth_cache: c.depth_cache,
815 fusion: c.fusion.clone(),
816 filter: Filter::default(),
817 },
818 sinks: Sinks { models: c.save_models, echo: c.echo, live_stride: c.live_stride, ..Sinks::default_files(&c.out) },
819 }
820 }
821}
822
823#[derive(Clone, Debug, PartialEq, Eq)]
829pub struct Problem {
830 pub field: String,
832 pub message: String,
834}
835
836#[derive(Clone, Debug, PartialEq, Eq)]
838pub struct PlanError {
839 pub problems: Vec<Problem>,
841}
842
843impl PlanError {
844 pub fn has(&self, field: &str) -> bool {
846 self.problems.iter().any(|p| p.field == field)
847 }
848}
849
850impl fmt::Display for PlanError {
851 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
852 write!(f, "계획 검사 실패({} 건)", self.problems.len())?;
853 for p in &self.problems {
854 write!(f, "\n - {}: {}", p.field, p.message)?;
855 }
856 Ok(())
857 }
858}
859
860impl std::error::Error for PlanError {}
861
862#[derive(Default)]
863struct Problems(Vec<Problem>);
864
865impl Problems {
866 fn add(&mut self, field: &str, message: impl Into<String>) {
867 self.0.push(Problem { field: field.to_string(), message: message.into() });
868 }
869 fn check(&mut self, ok: bool, field: &str, message: impl FnOnce() -> String) {
870 if !ok {
871 self.add(field, message());
872 }
873 }
874}
875
876type Attach = Box<dyn FnOnce(Pipeline<Session>) -> Pipeline<Session>>;
881
882pub struct ReconBuilder {
884 plan: Plan,
885 attach: Vec<Attach>,
886 pending: Vec<Problem>,
887 frames: bool,
888}
889
890impl fmt::Debug for ReconBuilder {
891 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
892 f.debug_struct("ReconBuilder").field("plan", &self.plan).field("hooks", &self.attach.len()).field("pending", &self.pending).finish()
893 }
894}
895
896impl ReconBuilder {
897 pub fn plan(&self) -> &Plan {
899 &self.plan
900 }
901
902 pub fn input(mut self, src: impl AsRef<Path>) -> Self {
904 let src = src.as_ref();
905 self.plan.input.images = src.join("images");
906 self.plan.gps = Some(src.join("gps_ref.txt"));
907 self
908 }
909
910 pub fn images(mut self, dir: impl Into<PathBuf>) -> Self {
912 self.plan.input.images = dir.into();
913 self
914 }
915
916 pub fn cameras<S: Into<String>>(mut self, cams: impl IntoIterator<Item = S>) -> Self {
918 self.plan.input.cameras = cams.into_iter().map(Into::into).collect();
919 self
920 }
921
922 pub fn preset(mut self, name: &str) -> Self {
925 match name {
926 DEFAULT_PRESET => {
927 self.plan.input.cameras = CAMS.iter().map(|c| c.to_string()).collect();
928 self.plan.input.stride = 3;
929 self.plan.pairing = Pairing::Formation;
930 }
931 other => self
932 .pending
933 .push(Problem { field: "preset".into(), message: format!("알 수 없는 프리셋 {other} ({})", PRESETS.join("|")) }),
934 }
935 self
936 }
937
938 pub fn stride(mut self, n: usize) -> Self {
940 self.plan.input.stride = n;
941 self
942 }
943
944 pub fn positions(mut self, n: usize) -> Self {
946 self.plan.input.positions = Some(n);
947 self
948 }
949
950 pub fn pairing(mut self, p: Pairing) -> Self {
952 self.plan.pairing = p;
953 self
954 }
955
956 pub fn zones(mut self, span: usize, overlap: usize) -> Self {
958 self.plan.zones = Zones { span, overlap };
959 self
960 }
961
962 pub fn gps(mut self, path: impl Into<PathBuf>) -> Self {
964 self.plan.gps = Some(path.into());
965 self
966 }
967
968 pub fn no_gps(mut self) -> Self {
970 self.plan.gps = None;
971 self
972 }
973
974 pub fn align(mut self, frame: AlignFrame) -> Self {
976 self.plan.align.frame = frame;
977 self
978 }
979
980 pub fn fixed_enu_origin(mut self, on: bool) -> Self {
982 self.plan.align.fixed_origin = on;
983 self
984 }
985
986 pub fn features(mut self, f: Features) -> Self {
988 self.plan.features = f;
989 self
990 }
991
992 pub fn match_backend(mut self, name: &str) -> Self {
994 self.plan.matching.backend = name.to_string();
995 self
996 }
997
998 pub fn gpu(mut self) -> Self {
1000 self.plan.features.backend = "cuda".into();
1001 self.plan.matching.backend = "cuda".into();
1002 self.plan.dense.backend = "cuda".into();
1003 self.plan.dense.undistort_backend = "cuda".into();
1004 self
1005 }
1006
1007 pub fn incremental_triangulation(mut self, on: bool) -> Self {
1009 self.plan.sparse.incremental_triangulation = on;
1010 self
1011 }
1012
1013 pub fn stable_display(mut self, on: bool) -> Self {
1015 self.plan.sparse.stable_display = on;
1016 self
1017 }
1018
1019 pub fn settle(mut self, tol: f64, max_zone: usize) -> Self {
1022 self.plan.sparse.settle_tol = tol;
1023 self.plan.sparse.settle_max_zone = max_zone;
1024 self
1025 }
1026
1027 pub fn dense(mut self, d: Dense) -> Self {
1029 self.plan.dense = Dense { enabled: true, ..d };
1030 self
1031 }
1032
1033 pub fn no_dense(mut self) -> Self {
1035 self.plan.dense.enabled = false;
1036 self
1037 }
1038
1039 pub fn sinks(mut self, s: Sinks) -> Self {
1041 self.plan.sinks = s;
1042 self
1043 }
1044
1045 pub fn seed(mut self, seed: u64) -> Self {
1047 self.plan.seed = Some(seed);
1048 self
1049 }
1050
1051 pub fn threads(mut self, n: usize) -> Self {
1053 self.plan.threads = n;
1054 self
1055 }
1056
1057 fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + 'static) -> Self {
1058 self.attach.push(Box::new(f));
1059 self
1060 }
1061
1062 pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
1065 where
1066 F: FnMut(&crate::events::Event) + Send + 'static,
1067 {
1068 self.frames |= kind == EventKind::FrameDecoded;
1069 self.hook(move |p| p.on(kind, f))
1070 }
1071
1072 pub fn on_frame<F>(mut self, f: F) -> Self
1076 where
1077 F: FnMut(&Arc<Frame>) + Send + 'static,
1078 {
1079 self.frames = true;
1080 self.hook(move |p| p.on_frame(f))
1081 }
1082
1083 pub fn on_any<F>(self, f: F) -> Self
1085 where
1086 F: FnMut(&crate::events::Event) + Send + 'static,
1087 {
1088 self.hook(move |p| p.on_any(f))
1089 }
1090
1091 pub fn on_zone_preview<F>(self, f: F) -> Self
1093 where
1094 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
1095 {
1096 self.hook(move |p| p.on_zone_preview(f))
1097 }
1098
1099 pub fn on_zone_refined<F>(self, f: F) -> Self
1101 where
1102 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
1103 {
1104 self.hook(move |p| p.on_zone_refined(f))
1105 }
1106
1107 pub fn on_snapshot<F>(self, f: F) -> Self
1109 where
1110 F: FnMut(usize, &Arc<PointCloud>) + Send + 'static,
1111 {
1112 self.hook(move |p| p.on_snapshot(f))
1113 }
1114
1115 pub fn on_position_done<F>(self, f: F) -> Self
1117 where
1118 F: FnMut(usize, usize, usize) + Send + 'static,
1119 {
1120 self.hook(move |p| p.on_position_done(f))
1121 }
1122
1123 pub fn on_message<F>(self, f: F) -> Self
1125 where
1126 F: FnMut(EventKind, &str) + Send + 'static,
1127 {
1128 self.hook(move |p| p.on_message(f))
1129 }
1130
1131 pub fn policy(self, kind: EventKind, p: QueuePolicy) -> Self {
1133 self.hook(move |pl| pl.policy(kind, p))
1134 }
1135
1136 pub fn build(self) -> Result<Recon, PlanError> {
1143 let ReconBuilder { plan, attach, pending, frames } = self;
1144 let mut pr = Problems(pending);
1145 let (layout, gps) = check(&plan, &mut pr);
1146 if !pr.0.is_empty() {
1147 return Err(PlanError { problems: pr.0 });
1148 }
1149 let (Some(layout), Some(gps)) = (layout, gps) else {
1150 unreachable!("문제가 없으면 입력과 GPS 를 읽었다");
1151 };
1152 Ok(Recon { plan, attach, layout, gps, frames })
1153 }
1154}
1155
1156fn backend_ok(pr: &mut Problems, field: &str, name: &str, allowed: &[&str]) {
1157 if !allowed.contains(&name) {
1158 pr.add(field, format!("알 수 없는 백엔드 {name} ({})", allowed.join("|")));
1159 } else if name == "cuda" && !cumulus3d_cuda::is_available() {
1160 pr.add(field, "CUDA 장치를 찾지 못함 — cuda 백엔드를 쓸 수 없다");
1161 }
1162}
1163
1164fn writable_target(p: &Path) -> Result<(), String> {
1166 if p.exists() {
1167 let m = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?;
1168 if !m.is_dir() {
1169 return Err(format!("{} 는 폴더가 아님", p.display()));
1170 }
1171 if m.permissions().readonly() {
1172 return Err(format!("{} 에 쓸 수 없음", p.display()));
1173 }
1174 return Ok(());
1175 }
1176 let mut a = p.parent();
1177 while let Some(d) = a {
1178 let d = if d.as_os_str().is_empty() { Path::new(".") } else { d };
1179 if d.exists() {
1180 let m = std::fs::metadata(d).map_err(|e| format!("{}: {e}", d.display()))?;
1181 if !m.is_dir() {
1182 return Err(format!("{} 는 폴더가 아님", d.display()));
1183 }
1184 if m.permissions().readonly() {
1185 return Err(format!("{} 에 쓸 수 없음", d.display()));
1186 }
1187 return Ok(());
1188 }
1189 a = d.parent();
1190 }
1191 Ok(())
1192}
1193
1194fn check(plan: &Plan, pr: &mut Problems) -> (Option<Layout>, Option<Vec<GpsRecord>>) {
1195 let inp = &plan.input;
1197 pr.check(inp.stride >= 1, "input.stride", || "1 이상이어야 함".into());
1198 pr.check(inp.positions != Some(0), "input.positions", || "1 이상이어야 함".into());
1199 pr.check(!inp.cameras.is_empty(), "input.cameras", || "카메라 폴더 목록이 비어 있음".into());
1200 let mut seen = std::collections::BTreeSet::new();
1201 for c in &inp.cameras {
1202 if c.is_empty() || c.contains(['/', '\\']) {
1203 pr.add("input.cameras", format!("잘못된 카메라 폴더 이름 {c:?}"));
1204 } else if !seen.insert(c.as_str()) {
1205 pr.add("input.cameras", format!("카메라 {c} 중복"));
1206 }
1207 }
1208 let z = plan.zones;
1209 pr.check(z.span >= 1, "zones.span", || "1 이상이어야 함".into());
1210 pr.check(z.overlap < z.span.max(1), "zones.overlap", || format!("구역 크기({})보다 작아야 함 (지금 {})", z.span, z.overlap));
1211 let f = &plan.features;
1212 backend_ok(pr, "features.backend", &f.backend, &["cpu", "cuda"]);
1213 pr.check(f.max_num_features >= 1, "features.max_num_features", || "1 이상이어야 함".into());
1214 pr.check(f.max_image_size >= 16, "features.max_image_size", || "16 이상이어야 함".into());
1215 backend_ok(pr, "matching.backend", &plan.matching.backend, &["cpu", "cuda"]);
1216 backend_ok(pr, "dense.undistort_backend", &plan.dense.undistort_backend, &["cpu", "cuda"]);
1217
1218 let d = &plan.dense;
1219 if d.enabled {
1220 if d.backend != "cuda" {
1221 pr.add("dense.backend", format!("알 수 없는 백엔드 {} (cuda)", d.backend));
1222 } else if !cumulus3d_cuda::is_available() {
1223 pr.add("dense.backend", "조밀화에는 CUDA 장치가 필요한데 장치를 찾지 못함 — `.no_dense()` 또는 [dense] enabled = false");
1224 }
1225 if let Err(e) = parse_profile(&d.profile) {
1226 pr.add("dense.profile", e);
1227 }
1228 pr.check(d.max_image_size >= 16, "dense.max_image_size", || "16 이상이어야 함".into());
1229 pr.check((1..=32).contains(&d.views), "dense.views", || format!("1..=32 이어야 함 (지금 {})", d.views));
1230 if let Some(v) = d.max_levels {
1231 pr.check(v >= 1, "dense.max_levels", || "1 이상이어야 함".into());
1232 }
1233 let fu = &d.fusion;
1234 if let Some(m) = &fu.mode {
1235 if let Err(e) = parse_fusion_mode(m) {
1236 pr.add("dense.fusion.mode", e);
1237 }
1238 }
1239 if let Some(m) = &fu.residual {
1240 pr.check(FusionResidual::parse(m).is_some(), "dense.fusion.residual", || {
1241 format!("알 수 없는 처리 {m} (none|release|second-pass)")
1242 });
1243 }
1244 if let Some(v) = fu.min_views {
1245 pr.check(v >= 1, "dense.fusion.min_views", || "1 이상이어야 함".into());
1246 }
1247 if let Some(v) = fu.depth_error {
1248 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.depth_error", || format!("양수여야 함 (지금 {v})"));
1249 }
1250 if let Some(v) = fu.normal_error {
1251 pr.check(v > 0.0 && v <= 180.0, "dense.fusion.normal_error", || format!("(0, 180] 이어야 함 (지금 {v})"));
1252 }
1253 if let Some(v) = fu.reproj_error {
1254 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.reproj_error", || format!("양수여야 함 (지금 {v})"));
1255 }
1256 if let Some(v) = fu.visibility_tau {
1257 pr.check(v > 0.0 && v < 1.0, "dense.fusion.visibility_tau", || format!("(0, 1) 이어야 함 (지금 {v})"));
1258 }
1259 if let Some(v) = fu.smooth_radius {
1260 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_radius", || format!("양수여야 함 (지금 {v})"));
1261 }
1262 if let Some(v) = fu.smooth_range {
1263 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_range", || format!("양수여야 함 (지금 {v})"));
1264 }
1265 if let Some(v) = fu.smooth_normal {
1266 pr.check(v > 0.0 && v <= 180.0, "dense.fusion.smooth_normal", || format!("(0, 180] 이어야 함 (지금 {v})"));
1267 }
1268 let fi = &d.filter;
1269 if let Some(v) = fi.geom_error {
1270 pr.check(v > 0.0 && v.is_finite(), "dense.filter.geom_error", || format!("양수여야 함 (지금 {v})"));
1271 }
1272 if let Some(v) = fi.min_ncc {
1273 pr.check((-1.0..=1.0).contains(&v), "dense.filter.min_ncc", || format!("[-1, 1] 이어야 함 (지금 {v})"));
1274 }
1275 }
1276
1277 if let Some(p) = &plan.sparse.intrinsics_prior {
1279 if let Err(e) = IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma) {
1280 pr.add("sparse.intrinsics_prior", e);
1281 }
1282 }
1283
1284 let s = &plan.sinks;
1286 pr.check(s.live_stride >= 1, "sinks.live_stride", || "must be >= 1 (1 = keep all)".into());
1287 if let Some(out) = &s.out {
1288 if let Err(e) = writable_target(out) {
1289 pr.add("sinks.out", e);
1290 }
1291 if s.clean && (inp.images.starts_with(out) || plan.gps.as_ref().is_some_and(|g| g.starts_with(out))) {
1292 pr.add("sinks.out", format!("시작 때 지울 출력 폴더 {} 안에 입력이 있음", out.display()));
1293 }
1294 }
1295
1296 let mut layout = None;
1298 if !inp.images.is_dir() {
1299 pr.add("input.images", format!("영상 폴더 없음: {}", inp.images.display()));
1300 } else {
1301 let missing: Vec<&String> = inp.cameras.iter().filter(|c| !inp.images.join(c).is_dir()).collect();
1302 for c in &missing {
1303 pr.add("input.cameras", format!("카메라 폴더 없음: {}", inp.images.join(c).display()));
1304 }
1305 if missing.is_empty() && !inp.cameras.is_empty() && inp.stride >= 1 {
1306 match Layout::discover_in(&inp.images, &inp.cameras, inp.stride) {
1307 Ok(mut l) => {
1308 if let Some(n) = inp.positions {
1309 l.truncate(n);
1310 }
1311 if l.frames.is_empty() {
1312 pr.add(
1313 "input",
1314 format!(
1315 "선택된 영상 없음: {}/{c}/{c}_NNNN.jpg 중 번호 % {} == 0 인 파일",
1316 inp.images.display(),
1317 inp.stride,
1318 c = inp.cameras[0]
1319 ),
1320 );
1321 } else {
1322 layout = Some(l);
1323 }
1324 }
1325 Err(e) => pr.add("input.images", e),
1326 }
1327 }
1328 }
1329
1330 let mut gps = None;
1332 match (plan.align.frame, &plan.gps) {
1333 (AlignFrame::None, _) => gps = Some(Vec::new()),
1334 (AlignFrame::Enu, None) => pr.add("gps", "ENU 정렬에는 GPS 파일이 필요함 — `.gps(경로)` 또는 정렬 끔([align] frame = \"none\")"),
1335 (AlignFrame::Enu, Some(p)) => match read_gps_file(p) {
1336 Err(e) => pr.add("gps", format!("{}: {e}", p.display())),
1337 Ok(all) => {
1338 if let Some(l) = &layout {
1339 let sel: Vec<GpsRecord> = all.into_iter().filter(|g| l.pos_of.contains_key(&g.name)).collect();
1340 if sel.is_empty() {
1341 pr.add("gps", format!("{}: 선택된 영상의 GPS 기록이 하나도 없음", p.display()));
1342 } else {
1343 gps = Some(sel);
1344 }
1345 } else {
1346 gps = Some(Vec::new());
1347 }
1348 }
1349 },
1350 }
1351 (layout, gps)
1352}
1353
1354pub struct Recon {
1360 plan: Plan,
1361 attach: Vec<Attach>,
1362 layout: Layout,
1363 gps: Vec<GpsRecord>,
1364 frames: bool,
1365}
1366
1367impl fmt::Debug for Recon {
1368 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1369 f.debug_struct("Recon")
1370 .field("plan", &self.plan)
1371 .field("hooks", &self.attach.len())
1372 .field("positions", &self.layout.frames.len())
1373 .finish()
1374 }
1375}
1376
1377impl Recon {
1378 pub fn declare() -> ReconBuilder {
1380 Self::from_plan(Plan::default())
1381 }
1382
1383 pub fn from_plan(plan: Plan) -> ReconBuilder {
1385 ReconBuilder { plan, attach: Vec::new(), pending: Vec::new(), frames: false }
1386 }
1387
1388 pub fn plan(&self) -> &Plan {
1390 &self.plan
1391 }
1392
1393 pub fn positions(&self) -> usize {
1395 self.layout.frames.len()
1396 }
1397
1398 pub fn layout(&self) -> &Layout {
1400 &self.layout
1401 }
1402
1403 pub fn run(self) -> Result<Summary, String> {
1407 let Recon { plan, attach, layout, gps, frames } = self;
1408 if plan.threads > 0 {
1409 let _ = rayon::ThreadPoolBuilder::new().num_threads(plan.threads).build_global();
1411 }
1412 let dense = if plan.dense.enabled { Some(plan.dense.config()?) } else { None };
1414 let prior = match &plan.sparse.intrinsics_prior {
1415 Some(p) => {
1416 Some(Arc::new(IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma)?))
1417 }
1418 None => None,
1419 };
1420 let s = &plan.sinks;
1421 let sinks = match &s.out {
1422 Some(out) => {
1423 if s.clean && out.exists() {
1424 std::fs::remove_dir_all(out).map_err(|e| format!("{}: {e}", out.display()))?;
1425 }
1426 let mut set = s.set();
1427 if plan.sparse.stable_display {
1430 let origin = gps.first().map(|g| [g.lat, g.lon, g.alt]);
1431 set.live_stream = Some(LiveStream { enu: origin.is_some(), origin_gps: origin });
1432 }
1433 Some(DefaultSinks::with_set(out, s.echo, set).map_err(|e| e.to_string())?)
1434 }
1435 None => None,
1436 };
1437 let npos = layout.frames.len();
1438 let mut sc = SessionConfig::new(plan.input.images.clone());
1439 sc.gps = gps;
1440 sc.span = plan.zones.span;
1441 sc.overlap = plan.zones.overlap;
1442 sc.total_positions = Some(npos);
1443 sc.incremental_triangulation = plan.sparse.incremental_triangulation;
1444 sc.stable_display = plan.sparse.stable_display;
1445 sc.settle_tol = plan.sparse.settle_tol;
1446 sc.settle_max_zone = plan.sparse.settle_max_zone;
1447 sc.intrinsics_prior = prior;
1448 sc.fixed_enu_origin = plan.align.fixed_origin;
1449 sc.seed = plan.seed;
1450 sc.extraction.sift.max_num_features = plan.features.max_num_features;
1451 sc.extraction.reader.max_image_size = plan.features.max_image_size;
1452 sc.sift = make_sift_backend(&plan.features.backend)?;
1453 sc.matcher = Arc::from(make_match_backend(&plan.matching.backend)?);
1454 sc.dense = dense;
1455 sc.history = 0;
1457 sc.decode_frames = frames;
1458
1459 let mut pl = Pipeline::new(Session::new(sc));
1460 if let Some(sinks) = sinks {
1461 pl = pl.on_any(sinks.into_hook());
1463 }
1464 for a in attach {
1465 pl = a(pl);
1466 }
1467
1468 let (span, ovl) = (plan.zones.span, plan.zones.overlap);
1469 let mut reg_end: BTreeMap<usize, Vec<ZoneRange>> = BTreeMap::new();
1470 for z in zones_upto(npos, span, ovl) {
1471 reg_end.entry(z.hi - 1).or_default().push(z);
1472 }
1473 pl.push(Input::Log(format!(
1474 "regions: {}",
1475 reg_end
1476 .iter()
1477 .map(|(e, v)| format!("{e} ->{}", v.iter().map(|z| format!(" {}:{}:{}", z.zone, z.lo, z.hi)).collect::<String>()))
1478 .collect::<Vec<_>>()
1479 .join(", ")
1480 )));
1481 let d = &plan.dense;
1482 pl.push(Input::Log(format!(
1483 "설정: span {span} overlap {ovl} stride {} 증분삼각측량 {} 깊이캐시 {} 고정원점 {} pm {}({})/sift {}/match {} 직렬조밀화 {} 왜곡보정최대 {} 뷰 {} 스레드 {}",
1484 plan.input.stride,
1485 plan.sparse.incremental_triangulation,
1486 d.depth_cache,
1487 plan.align.fixed_origin,
1488 d.backend,
1489 d.profile,
1490 plan.features.backend,
1491 plan.matching.backend,
1492 d.serialize,
1493 d.max_image_size,
1494 d.views,
1495 rayon::current_num_threads()
1496 )));
1497 for p in 0..npos {
1498 pl.push(frame_set_of(&layout, &plan.input.cameras, p).into());
1499 if let Some(e) = pl.reducer().failed() {
1500 return Err(e.to_string());
1502 }
1503 }
1504 let (_session, summary) = pl.finish();
1505 Ok(summary)
1506 }
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511 use super::*;
1512
1513 #[test]
1514 fn default_plan_toml_roundtrip() {
1515 let p = Plan::default();
1516 let s = p.to_toml();
1517 assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
1518 assert_eq!(Plan::from_toml("").unwrap(), p);
1520 }
1521
1522 #[test]
1523 fn full_plan_toml_roundtrip() {
1524 let b = Recon::declare()
1525 .input("some/where")
1526 .cameras(["a", "b"])
1527 .stride(2)
1528 .positions(5)
1529 .zones(6, 1)
1530 .fixed_enu_origin(true)
1531 .incremental_triangulation(true)
1532 .dense(
1533 Dense::profile("quality")
1534 .fusion_mode("traversal")
1535 .fusion_min_views(3)
1536 .fusion_depth_error(0.02)
1537 .fusion_normal_error(12.5)
1538 .fusion_reproj_error(1.5)
1539 .fusion_residual("second-pass")
1540 .fusion_smooth_normal(30.0)
1541 .max_levels(1)
1542 .filter_min_views(2)
1543 .filter_geom_error(1.25)
1544 .filter_min_ncc(0.1)
1545 .median_filter(true)
1546 .depth_cache(true)
1547 .serialize(true),
1548 )
1549 .sinks(Sinks::default_files("o").models(true).echo(false))
1550 .seed(42)
1551 .threads(3);
1552 let p = b.plan().clone();
1553 let s = p.to_toml();
1554 assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
1555 assert!(s.contains("[dense.fusion]") && s.contains("min_views = 3"), "{s}");
1556 assert!(s.contains("smooth_normal = 30.0") && s.contains("max_levels = 1"), "{s}");
1557 }
1558
1559 #[test]
1560 fn example_plan_parses() {
1561 let s = include_str!("../../../examples/plans/aerial-formation.toml");
1562 let p = Plan::from_toml(s).unwrap();
1563 assert_eq!(p.input.cameras, ["camF", "camR", "camL"]);
1564 assert_eq!(p.pairing, Pairing::Formation);
1565 assert_eq!(Plan::from_toml(&p.to_toml()).unwrap(), p);
1566 }
1567
1568 #[test]
1569 fn unknown_field_rejected() {
1570 assert!(Plan::from_toml("[zones]\nspam = 3\n").is_err());
1571 assert!(Plan::from_toml("pairing = \"grid\"\n").is_err());
1572 }
1573
1574 #[test]
1575 fn stream_plan_matches_builder() {
1576 let mut c = StreamConfig::new("src".into(), "dst".into());
1577 (c.stride, c.span, c.overlap, c.seed, c.save_models, c.echo) = (1, 4, 1, Some(7), true, false);
1578 let b = Recon::declare()
1579 .input("src")
1580 .preset(DEFAULT_PRESET)
1581 .stride(1)
1582 .zones(4, 1)
1583 .seed(7)
1584 .sinks(Sinks::default_files("dst").models(true).echo(false));
1585 assert_eq!(Plan::from_stream(&c), *b.plan());
1586 }
1587
1588 #[test]
1589 fn build_collects_all_problems() {
1590 let tmp = std::env::temp_dir().join(format!("cumulus3d_declare_missing_{}", std::process::id()));
1591 let e = Recon::declare()
1592 .input(tmp.join("in"))
1593 .preset("grid")
1594 .stride(0)
1595 .zones(4, 4)
1596 .dense(Dense::profile("slow").views(40).max_levels(0).fusion_smooth_normal(0.0).fusion_mode("magic").filter_min_ncc(2.0))
1597 .features(Features { backend: "fpga".into(), ..Features::default() })
1598 .sinks(Sinks::default_files(tmp.join("in")).clean(true))
1599 .build()
1600 .unwrap_err();
1601 for f in [
1602 "preset",
1603 "input.stride",
1604 "zones.overlap",
1605 "features.backend",
1606 "dense.profile",
1607 "dense.views",
1608 "dense.fusion.mode",
1609 "dense.max_levels",
1610 "dense.fusion.smooth_normal",
1611 "dense.filter.min_ncc",
1612 "sinks.out",
1613 "input.images",
1614 "gps",
1615 ] {
1616 assert!(e.has(f), "{f} 없음:\n{e}");
1617 }
1618 assert_eq!(e.has("dense.backend"), !cumulus3d_cuda::is_available());
1619 assert!(!tmp.exists());
1620 assert!(e.to_string().starts_with("계획 검사 실패("));
1621 }
1622
1623 #[test]
1624 fn live_stride_roundtrip_and_validation() {
1625 assert_eq!(Sinks::default().live_stride, 6);
1626 let p = Plan { sinks: Sinks::default_files("o").live_stride(3), ..Plan::default() };
1627 assert_eq!(Plan::from_toml(&p.to_toml()).unwrap().sinks.live_stride, 3);
1628 let e = Recon::declare().sinks(Sinks::default_files("o").live_stride(0)).no_dense().build().unwrap_err();
1629 assert!(e.has("sinks.live_stride"), "{e}");
1630 }
1631
1632 #[test]
1633 fn enu_without_gps_is_error() {
1634 let e = Recon::declare().no_gps().no_dense().build().unwrap_err();
1635 assert!(e.has("gps"), "{e}");
1636 let e = Recon::declare().no_gps().align(AlignFrame::None).no_dense().build().unwrap_err();
1637 assert!(!e.has("gps"), "{e}");
1638 }
1639}