1use crate::blocks::LiveStream;
73use crate::densewrap::{make_match_backend, make_pm_backend, make_sift_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 profile: String,
297 pub max_image_size: i64,
299 pub views: usize,
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub max_levels: Option<usize>,
304 pub serialize: bool,
306 pub depth_cache: bool,
308 pub fusion: Fusion,
310 pub filter: Filter,
312}
313
314impl Default for Dense {
315 fn default() -> Self {
316 Self {
317 enabled: true,
318 backend: "cuda".into(),
319 profile: "fast".into(),
320 max_image_size: 960,
321 views: 10,
322 max_levels: None,
323 serialize: false,
324 depth_cache: false,
325 fusion: Fusion::default(),
326 filter: Filter::default(),
327 }
328 }
329}
330
331#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
333#[serde(default, deny_unknown_fields)]
334pub struct Fusion {
335 #[serde(skip_serializing_if = "Option::is_none")]
337 pub mode: Option<String>,
338 #[serde(skip_serializing_if = "Option::is_none")]
340 pub min_views: Option<usize>,
341 #[serde(skip_serializing_if = "Option::is_none")]
343 pub depth_error: Option<f64>,
344 #[serde(skip_serializing_if = "Option::is_none")]
346 pub normal_error: Option<f64>,
347 #[serde(skip_serializing_if = "Option::is_none")]
349 pub reproj_error: Option<f64>,
350 #[serde(skip_serializing_if = "Option::is_none")]
352 pub residual: Option<String>,
353 #[serde(skip_serializing_if = "Option::is_none")]
355 pub smooth_iters: Option<usize>,
356 #[serde(skip_serializing_if = "Option::is_none")]
358 pub smooth_radius: Option<f64>,
359 #[serde(skip_serializing_if = "Option::is_none")]
361 pub smooth_range: Option<f64>,
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub smooth_normal: Option<f64>,
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub visibility_views: Option<usize>,
368 #[serde(skip_serializing_if = "Option::is_none")]
370 pub visibility_tau: Option<f64>,
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub visibility_min_violations: Option<usize>,
374}
375
376#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
379#[serde(default, deny_unknown_fields)]
380pub struct Filter {
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub min_views: Option<u32>,
384 #[serde(skip_serializing_if = "Option::is_none")]
386 pub geom_error: Option<f32>,
387 #[serde(skip_serializing_if = "Option::is_none")]
389 pub min_ncc: Option<f32>,
390 #[serde(skip_serializing_if = "Option::is_none")]
392 pub median: Option<bool>,
393 #[serde(skip_serializing_if = "Option::is_none")]
395 pub fill_holes: Option<bool>,
396}
397
398impl Dense {
399 pub fn profile(name: &str) -> Self {
401 Self { profile: name.to_string(), ..Self::default() }
402 }
403 pub fn off() -> Self {
405 Self { enabled: false, ..Self::default() }
406 }
407 pub fn backend(mut self, name: &str) -> Self {
409 self.backend = name.to_string();
410 self
411 }
412 pub fn max_image_size(mut self, n: i64) -> Self {
414 self.max_image_size = n;
415 self
416 }
417 pub fn views(mut self, n: usize) -> Self {
419 self.views = n;
420 self
421 }
422 pub fn serialize(mut self, on: bool) -> Self {
424 self.serialize = on;
425 self
426 }
427 pub fn depth_cache(mut self, on: bool) -> Self {
429 self.depth_cache = on;
430 self
431 }
432 pub fn fusion_mode(mut self, mode: &str) -> Self {
434 self.fusion.mode = Some(mode.to_string());
435 self
436 }
437 pub fn fusion_min_views(mut self, n: usize) -> Self {
439 self.fusion.min_views = Some(n);
440 self
441 }
442 pub fn fusion_depth_error(mut self, v: f64) -> Self {
444 self.fusion.depth_error = Some(v);
445 self
446 }
447 pub fn fusion_normal_error(mut self, deg: f64) -> Self {
449 self.fusion.normal_error = Some(deg);
450 self
451 }
452 pub fn fusion_reproj_error(mut self, px: f64) -> Self {
454 self.fusion.reproj_error = Some(px);
455 self
456 }
457 pub fn fusion_residual(mut self, mode: &str) -> Self {
459 self.fusion.residual = Some(mode.to_string());
460 self
461 }
462 pub fn fusion_smooth_iters(mut self, n: usize) -> Self {
464 self.fusion.smooth_iters = Some(n);
465 self
466 }
467 pub fn fusion_smooth_radius(mut self, r: f64) -> Self {
469 self.fusion.smooth_radius = Some(r);
470 self
471 }
472 pub fn fusion_smooth_range(mut self, r: f64) -> Self {
474 self.fusion.smooth_range = Some(r);
475 self
476 }
477 pub fn fusion_smooth_normal(mut self, deg: f64) -> Self {
479 self.fusion.smooth_normal = Some(deg);
480 self
481 }
482 pub fn max_levels(mut self, n: usize) -> Self {
484 self.max_levels = Some(n);
485 self
486 }
487 pub fn fill_holes(mut self, on: bool) -> Self {
489 self.filter.fill_holes = Some(on);
490 self
491 }
492 pub fn filter_min_views(mut self, n: u32) -> Self {
494 self.filter.min_views = Some(n);
495 self
496 }
497 pub fn filter_geom_error(mut self, px: f32) -> Self {
499 self.filter.geom_error = Some(px);
500 self
501 }
502 pub fn filter_min_ncc(mut self, v: f32) -> Self {
504 self.filter.min_ncc = Some(v);
505 self
506 }
507 pub fn median_filter(mut self, on: bool) -> Self {
509 self.filter.median = Some(on);
510 self
511 }
512
513 pub fn apply(&self, o: &mut DensifyOptions) -> Result<(), String> {
515 let f = &self.fusion;
516 if let Some(v) = self.max_levels {
517 o.max_levels = v;
518 }
519 if let Some(m) = &f.mode {
520 o.fusion.mode = parse_fusion_mode(m)?;
521 }
522 if let Some(v) = f.min_views {
523 o.fusion.min_consistent_views = v;
524 }
525 if let Some(v) = f.depth_error {
526 o.fusion.max_depth_error = v;
527 }
528 if let Some(v) = f.normal_error {
529 o.fusion.max_normal_error_deg = v;
530 }
531 if let Some(v) = f.reproj_error {
532 o.fusion.max_reproj_error = v;
533 }
534 if let Some(v) = f.smooth_iters {
535 o.fusion.smooth.iterations = v;
536 }
537 if let Some(v) = f.smooth_radius {
538 o.fusion.smooth.radius_gsd = v;
539 }
540 if let Some(v) = f.smooth_range {
541 o.fusion.smooth.sigma_range_gsd = v;
542 }
543 if let Some(v) = f.smooth_normal {
544 o.fusion.smooth.normal_deg = v;
545 }
546 if let Some(v) = f.visibility_views {
547 o.fusion.visibility.check_views = v;
548 }
549 if let Some(v) = f.visibility_tau {
550 o.fusion.visibility.tau = v;
551 }
552 if let Some(v) = f.visibility_min_violations {
553 o.fusion.visibility.min_violations = v;
554 }
555 if let Some(m) = &f.residual {
556 o.fusion.residual =
557 FusionResidual::parse(m).ok_or_else(|| format!("알 수 없는 융합 남은 픽셀 처리 {m} (none|release|second-pass)"))?;
558 }
559 let g = &self.filter;
560 if let Some(v) = g.min_views {
561 o.filter.min_num_consistent = v;
562 }
563 if let Some(v) = g.geom_error {
564 o.filter.geom_max_cost = v;
565 }
566 if let Some(v) = g.min_ncc {
567 o.filter.min_ncc = v;
568 }
569 if let Some(v) = g.median {
570 o.filter.median_filter = v;
571 }
572 if let Some(v) = g.fill_holes {
573 o.post.fill_holes = v;
574 }
575 Ok(())
576 }
577
578 pub fn config(&self) -> Result<DenseConfig, String> {
581 let mut d = DenseConfig::new(make_pm_backend(&self.backend), parse_profile(&self.profile)?);
582 d.undistort.max_image_size = self.max_image_size;
583 d.densify.neighbors.num_views = self.views;
584 self.apply(&mut d.densify)?;
585 if self.serialize {
586 d.lock = Some(Arc::new(Mutex::new(())));
587 }
588 if self.depth_cache {
589 d.depth_cache = Some(Arc::new(DepthMapCache::new()));
590 }
591 Ok(d)
592 }
593}
594
595fn parse_fusion_mode(m: &str) -> Result<FusionMode, String> {
596 FusionMode::parse(m).ok_or_else(|| format!("알 수 없는 융합 방식 {m} (consistency|traversal)"))
597}
598
599#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
601#[serde(default, deny_unknown_fields)]
602pub struct Sinks {
603 #[serde(skip_serializing_if = "Option::is_none")]
605 pub out: Option<PathBuf>,
606 pub clean: bool,
608 pub echo: bool,
610 pub timeline: bool,
612 pub run_log: bool,
614 pub zone_ply: bool,
616 pub snapshots: bool,
618 pub models: bool,
620 pub live_stride: usize,
622}
623
624impl Default for Sinks {
625 fn default() -> Self {
627 Self { out: None, ..Self::default_files("") }
628 }
629}
630
631impl Sinks {
632 pub fn default_files(out: impl Into<PathBuf>) -> Self {
634 Self {
635 out: Some(out.into()),
636 clean: true,
637 echo: true,
638 timeline: true,
639 run_log: true,
640 zone_ply: true,
641 snapshots: true,
642 models: false,
643 live_stride: crate::post::LiveOptions::default().stride,
644 }
645 }
646 pub fn none() -> Self {
648 Self {
649 out: None,
650 clean: false,
651 echo: false,
652 timeline: false,
653 run_log: false,
654 zone_ply: false,
655 snapshots: false,
656 models: false,
657 live_stride: crate::post::LiveOptions::default().stride,
658 }
659 }
660 pub fn clean(mut self, on: bool) -> Self {
662 self.clean = on;
663 self
664 }
665 pub fn echo(mut self, on: bool) -> Self {
667 self.echo = on;
668 self
669 }
670 pub fn timeline(mut self, on: bool) -> Self {
672 self.timeline = on;
673 self
674 }
675 pub fn run_log(mut self, on: bool) -> Self {
677 self.run_log = on;
678 self
679 }
680 pub fn zone_ply(mut self, on: bool) -> Self {
682 self.zone_ply = on;
683 self
684 }
685 pub fn snapshots(mut self, on: bool) -> Self {
687 self.snapshots = on;
688 self
689 }
690 pub fn models(mut self, on: bool) -> Self {
692 self.models = on;
693 self
694 }
695 pub fn live_stride(mut self, stride: usize) -> Self {
697 self.live_stride = stride;
698 self
699 }
700 fn set(&self) -> SinkSet {
701 SinkSet {
702 timeline: self.timeline,
703 run_log: self.run_log,
704 zone_ply: self.zone_ply,
705 models: self.models,
706 snapshots: self.snapshots,
707 live_stream: None,
708 live_stride: self.live_stride,
709 }
710 }
711}
712
713impl Plan {
714 pub fn to_toml(&self) -> String {
716 toml::to_string(self).expect("계획은 항상 TOML 로 바꿀 수 있다")
717 }
718
719 pub fn to_toml_annotated(&self) -> String {
723 let s = self.to_toml();
724 let Ok(profile) = parse_profile(&self.dense.profile) else { return s };
725 let o = DensifyOptions::with_profile(profile);
726 let (f, g) = (&o.fusion, &o.filter);
727 let mode = match f.mode {
728 FusionMode::Consistency => "consistency",
729 FusionMode::Traversal => "traversal",
730 };
731 let fusion = format!(
732 "# {} 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",
733 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
734 );
735 let filter = format!(
736 "# {} profile defaults:\n# min_views = {}\n# geom_error = {}\n# min_ncc = {}\n# median = {}\n# fill_holes = {}\n",
737 self.dense.profile, g.min_num_consistent, g.geom_max_cost, g.min_ncc, g.median_filter, o.post.fill_holes
738 );
739 let mut out = String::with_capacity(s.len() + fusion.len() + filter.len());
740 let mut lines = s.lines().peekable();
741 while let Some(l) = lines.next() {
742 out.push_str(l);
743 out.push('\n');
744 let empty = lines.peek().is_none_or(|n| n.trim().is_empty() || n.starts_with('['));
745 if empty && l == "[dense.fusion]" {
746 out.push_str(&fusion);
747 } else if empty && l == "[dense.filter]" {
748 out.push_str(&filter);
749 }
750 }
751 out
752 }
753
754 pub fn from_toml(s: &str) -> Result<Self, String> {
756 toml::from_str(s).map_err(|e| format!("계획 TOML: {e}"))
757 }
758
759 pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
761 let p = path.as_ref();
762 let s = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
763 Self::from_toml(&s).map_err(|e| format!("{}: {e}", p.display()))
764 }
765
766 pub fn from_stream(c: &StreamConfig) -> Self {
768 Self {
769 seed: c.seed,
770 threads: c.threads,
771 gps: Some(c.src.join("gps_ref.txt")),
772 pairing: Pairing::Formation,
773 input: Source {
774 images: c.src.join("images"),
775 cameras: CAMS.iter().map(|s| s.to_string()).collect(),
776 stride: c.stride,
777 positions: c.max_positions,
778 },
779 zones: Zones { span: c.span, overlap: c.overlap },
780 align: Align { frame: AlignFrame::Enu, fixed_origin: c.fixed_enu_origin },
781 features: Features {
782 backend: c.sift_backend.clone(),
783 max_num_features: c.max_num_features,
784 max_image_size: c.sift_max_image_size,
785 },
786 matching: Matching { backend: c.match_backend.clone() },
787 sparse: Sparse {
788 incremental_triangulation: c.incremental_triangulation,
789 stable_display: c.stable_display,
790 settle_tol: c.settle_tol,
791 settle_max_zone: c.settle_max_zone,
792 intrinsics_prior: c.intrinsics_prior.clone(),
793 intrinsics_prior_sigma: c.intrinsics_prior_sigma,
794 intrinsics_prior_dist_sigma: c.intrinsics_prior_dist_sigma,
795 },
796 dense: Dense {
797 enabled: !c.no_dense,
798 backend: c.pm_backend.clone(),
799 profile: c.mvs_profile.clone(),
800 max_image_size: c.dense_max_image_size,
801 views: c.number_views,
802 max_levels: c.max_levels,
803 serialize: c.serialize_dense,
804 depth_cache: c.depth_cache,
805 fusion: c.fusion.clone(),
806 filter: Filter::default(),
807 },
808 sinks: Sinks { models: c.save_models, echo: c.echo, live_stride: c.live_stride, ..Sinks::default_files(&c.out) },
809 }
810 }
811}
812
813#[derive(Clone, Debug, PartialEq, Eq)]
819pub struct Problem {
820 pub field: String,
822 pub message: String,
824}
825
826#[derive(Clone, Debug, PartialEq, Eq)]
828pub struct PlanError {
829 pub problems: Vec<Problem>,
831}
832
833impl PlanError {
834 pub fn has(&self, field: &str) -> bool {
836 self.problems.iter().any(|p| p.field == field)
837 }
838}
839
840impl fmt::Display for PlanError {
841 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
842 write!(f, "계획 검사 실패({} 건)", self.problems.len())?;
843 for p in &self.problems {
844 write!(f, "\n - {}: {}", p.field, p.message)?;
845 }
846 Ok(())
847 }
848}
849
850impl std::error::Error for PlanError {}
851
852#[derive(Default)]
853struct Problems(Vec<Problem>);
854
855impl Problems {
856 fn add(&mut self, field: &str, message: impl Into<String>) {
857 self.0.push(Problem { field: field.to_string(), message: message.into() });
858 }
859 fn check(&mut self, ok: bool, field: &str, message: impl FnOnce() -> String) {
860 if !ok {
861 self.add(field, message());
862 }
863 }
864}
865
866type Attach = Box<dyn FnOnce(Pipeline<Session>) -> Pipeline<Session>>;
871
872pub struct ReconBuilder {
874 plan: Plan,
875 attach: Vec<Attach>,
876 pending: Vec<Problem>,
877 frames: bool,
878}
879
880impl fmt::Debug for ReconBuilder {
881 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
882 f.debug_struct("ReconBuilder").field("plan", &self.plan).field("hooks", &self.attach.len()).field("pending", &self.pending).finish()
883 }
884}
885
886impl ReconBuilder {
887 pub fn plan(&self) -> &Plan {
889 &self.plan
890 }
891
892 pub fn input(mut self, src: impl AsRef<Path>) -> Self {
894 let src = src.as_ref();
895 self.plan.input.images = src.join("images");
896 self.plan.gps = Some(src.join("gps_ref.txt"));
897 self
898 }
899
900 pub fn images(mut self, dir: impl Into<PathBuf>) -> Self {
902 self.plan.input.images = dir.into();
903 self
904 }
905
906 pub fn cameras<S: Into<String>>(mut self, cams: impl IntoIterator<Item = S>) -> Self {
908 self.plan.input.cameras = cams.into_iter().map(Into::into).collect();
909 self
910 }
911
912 pub fn preset(mut self, name: &str) -> Self {
915 match name {
916 DEFAULT_PRESET => {
917 self.plan.input.cameras = CAMS.iter().map(|c| c.to_string()).collect();
918 self.plan.input.stride = 3;
919 self.plan.pairing = Pairing::Formation;
920 }
921 other => self
922 .pending
923 .push(Problem { field: "preset".into(), message: format!("알 수 없는 프리셋 {other} ({})", PRESETS.join("|")) }),
924 }
925 self
926 }
927
928 pub fn stride(mut self, n: usize) -> Self {
930 self.plan.input.stride = n;
931 self
932 }
933
934 pub fn positions(mut self, n: usize) -> Self {
936 self.plan.input.positions = Some(n);
937 self
938 }
939
940 pub fn pairing(mut self, p: Pairing) -> Self {
942 self.plan.pairing = p;
943 self
944 }
945
946 pub fn zones(mut self, span: usize, overlap: usize) -> Self {
948 self.plan.zones = Zones { span, overlap };
949 self
950 }
951
952 pub fn gps(mut self, path: impl Into<PathBuf>) -> Self {
954 self.plan.gps = Some(path.into());
955 self
956 }
957
958 pub fn no_gps(mut self) -> Self {
960 self.plan.gps = None;
961 self
962 }
963
964 pub fn align(mut self, frame: AlignFrame) -> Self {
966 self.plan.align.frame = frame;
967 self
968 }
969
970 pub fn fixed_enu_origin(mut self, on: bool) -> Self {
972 self.plan.align.fixed_origin = on;
973 self
974 }
975
976 pub fn features(mut self, f: Features) -> Self {
978 self.plan.features = f;
979 self
980 }
981
982 pub fn match_backend(mut self, name: &str) -> Self {
984 self.plan.matching.backend = name.to_string();
985 self
986 }
987
988 pub fn gpu(mut self) -> Self {
990 self.plan.features.backend = "cuda".into();
991 self.plan.matching.backend = "cuda".into();
992 self.plan.dense.backend = "cuda".into();
993 self
994 }
995
996 pub fn incremental_triangulation(mut self, on: bool) -> Self {
998 self.plan.sparse.incremental_triangulation = on;
999 self
1000 }
1001
1002 pub fn stable_display(mut self, on: bool) -> Self {
1004 self.plan.sparse.stable_display = on;
1005 self
1006 }
1007
1008 pub fn settle(mut self, tol: f64, max_zone: usize) -> Self {
1011 self.plan.sparse.settle_tol = tol;
1012 self.plan.sparse.settle_max_zone = max_zone;
1013 self
1014 }
1015
1016 pub fn dense(mut self, d: Dense) -> Self {
1018 self.plan.dense = Dense { enabled: true, ..d };
1019 self
1020 }
1021
1022 pub fn no_dense(mut self) -> Self {
1024 self.plan.dense.enabled = false;
1025 self
1026 }
1027
1028 pub fn sinks(mut self, s: Sinks) -> Self {
1030 self.plan.sinks = s;
1031 self
1032 }
1033
1034 pub fn seed(mut self, seed: u64) -> Self {
1036 self.plan.seed = Some(seed);
1037 self
1038 }
1039
1040 pub fn threads(mut self, n: usize) -> Self {
1042 self.plan.threads = n;
1043 self
1044 }
1045
1046 fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + 'static) -> Self {
1047 self.attach.push(Box::new(f));
1048 self
1049 }
1050
1051 pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
1054 where
1055 F: FnMut(&crate::events::Event) + Send + 'static,
1056 {
1057 self.frames |= kind == EventKind::FrameDecoded;
1058 self.hook(move |p| p.on(kind, f))
1059 }
1060
1061 pub fn on_frame<F>(mut self, f: F) -> Self
1065 where
1066 F: FnMut(&Arc<Frame>) + Send + 'static,
1067 {
1068 self.frames = true;
1069 self.hook(move |p| p.on_frame(f))
1070 }
1071
1072 pub fn on_any<F>(self, f: F) -> Self
1074 where
1075 F: FnMut(&crate::events::Event) + Send + 'static,
1076 {
1077 self.hook(move |p| p.on_any(f))
1078 }
1079
1080 pub fn on_zone_preview<F>(self, f: F) -> Self
1082 where
1083 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
1084 {
1085 self.hook(move |p| p.on_zone_preview(f))
1086 }
1087
1088 pub fn on_zone_refined<F>(self, f: F) -> Self
1090 where
1091 F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
1092 {
1093 self.hook(move |p| p.on_zone_refined(f))
1094 }
1095
1096 pub fn on_snapshot<F>(self, f: F) -> Self
1098 where
1099 F: FnMut(usize, &Arc<PointCloud>) + Send + 'static,
1100 {
1101 self.hook(move |p| p.on_snapshot(f))
1102 }
1103
1104 pub fn on_position_done<F>(self, f: F) -> Self
1106 where
1107 F: FnMut(usize, usize, usize) + Send + 'static,
1108 {
1109 self.hook(move |p| p.on_position_done(f))
1110 }
1111
1112 pub fn on_message<F>(self, f: F) -> Self
1114 where
1115 F: FnMut(EventKind, &str) + Send + 'static,
1116 {
1117 self.hook(move |p| p.on_message(f))
1118 }
1119
1120 pub fn policy(self, kind: EventKind, p: QueuePolicy) -> Self {
1122 self.hook(move |pl| pl.policy(kind, p))
1123 }
1124
1125 pub fn build(self) -> Result<Recon, PlanError> {
1132 let ReconBuilder { plan, attach, pending, frames } = self;
1133 let mut pr = Problems(pending);
1134 let (layout, gps) = check(&plan, &mut pr);
1135 if !pr.0.is_empty() {
1136 return Err(PlanError { problems: pr.0 });
1137 }
1138 let (Some(layout), Some(gps)) = (layout, gps) else {
1139 unreachable!("문제가 없으면 입력과 GPS 를 읽었다");
1140 };
1141 Ok(Recon { plan, attach, layout, gps, frames })
1142 }
1143}
1144
1145fn backend_ok(pr: &mut Problems, field: &str, name: &str, allowed: &[&str]) {
1146 if !allowed.contains(&name) {
1147 pr.add(field, format!("알 수 없는 백엔드 {name} ({})", allowed.join("|")));
1148 } else if name == "cuda" && !cumulus3d_cuda::is_available() {
1149 pr.add(field, "CUDA 장치를 찾지 못함 — cuda 백엔드를 쓸 수 없다");
1150 }
1151}
1152
1153fn writable_target(p: &Path) -> Result<(), String> {
1155 if p.exists() {
1156 let m = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?;
1157 if !m.is_dir() {
1158 return Err(format!("{} 는 폴더가 아님", p.display()));
1159 }
1160 if m.permissions().readonly() {
1161 return Err(format!("{} 에 쓸 수 없음", p.display()));
1162 }
1163 return Ok(());
1164 }
1165 let mut a = p.parent();
1166 while let Some(d) = a {
1167 let d = if d.as_os_str().is_empty() { Path::new(".") } else { d };
1168 if d.exists() {
1169 let m = std::fs::metadata(d).map_err(|e| format!("{}: {e}", d.display()))?;
1170 if !m.is_dir() {
1171 return Err(format!("{} 는 폴더가 아님", d.display()));
1172 }
1173 if m.permissions().readonly() {
1174 return Err(format!("{} 에 쓸 수 없음", d.display()));
1175 }
1176 return Ok(());
1177 }
1178 a = d.parent();
1179 }
1180 Ok(())
1181}
1182
1183fn check(plan: &Plan, pr: &mut Problems) -> (Option<Layout>, Option<Vec<GpsRecord>>) {
1184 let inp = &plan.input;
1186 pr.check(inp.stride >= 1, "input.stride", || "1 이상이어야 함".into());
1187 pr.check(inp.positions != Some(0), "input.positions", || "1 이상이어야 함".into());
1188 pr.check(!inp.cameras.is_empty(), "input.cameras", || "카메라 폴더 목록이 비어 있음".into());
1189 let mut seen = std::collections::BTreeSet::new();
1190 for c in &inp.cameras {
1191 if c.is_empty() || c.contains(['/', '\\']) {
1192 pr.add("input.cameras", format!("잘못된 카메라 폴더 이름 {c:?}"));
1193 } else if !seen.insert(c.as_str()) {
1194 pr.add("input.cameras", format!("카메라 {c} 중복"));
1195 }
1196 }
1197 let z = plan.zones;
1198 pr.check(z.span >= 1, "zones.span", || "1 이상이어야 함".into());
1199 pr.check(z.overlap < z.span.max(1), "zones.overlap", || format!("구역 크기({})보다 작아야 함 (지금 {})", z.span, z.overlap));
1200 let f = &plan.features;
1201 backend_ok(pr, "features.backend", &f.backend, &["cpu", "cuda"]);
1202 pr.check(f.max_num_features >= 1, "features.max_num_features", || "1 이상이어야 함".into());
1203 pr.check(f.max_image_size >= 16, "features.max_image_size", || "16 이상이어야 함".into());
1204 backend_ok(pr, "matching.backend", &plan.matching.backend, &["cpu", "cuda"]);
1205
1206 let d = &plan.dense;
1207 if d.enabled {
1208 if d.backend != "cuda" {
1209 pr.add("dense.backend", format!("알 수 없는 백엔드 {} (cuda)", d.backend));
1210 } else if !cumulus3d_cuda::is_available() {
1211 pr.add("dense.backend", "조밀화에는 CUDA 장치가 필요한데 장치를 찾지 못함 — `.no_dense()` 또는 [dense] enabled = false");
1212 }
1213 if let Err(e) = parse_profile(&d.profile) {
1214 pr.add("dense.profile", e);
1215 }
1216 pr.check(d.max_image_size >= 16, "dense.max_image_size", || "16 이상이어야 함".into());
1217 pr.check((1..=32).contains(&d.views), "dense.views", || format!("1..=32 이어야 함 (지금 {})", d.views));
1218 if let Some(v) = d.max_levels {
1219 pr.check(v >= 1, "dense.max_levels", || "1 이상이어야 함".into());
1220 }
1221 let fu = &d.fusion;
1222 if let Some(m) = &fu.mode {
1223 if let Err(e) = parse_fusion_mode(m) {
1224 pr.add("dense.fusion.mode", e);
1225 }
1226 }
1227 if let Some(m) = &fu.residual {
1228 pr.check(FusionResidual::parse(m).is_some(), "dense.fusion.residual", || {
1229 format!("알 수 없는 처리 {m} (none|release|second-pass)")
1230 });
1231 }
1232 if let Some(v) = fu.min_views {
1233 pr.check(v >= 1, "dense.fusion.min_views", || "1 이상이어야 함".into());
1234 }
1235 if let Some(v) = fu.depth_error {
1236 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.depth_error", || format!("양수여야 함 (지금 {v})"));
1237 }
1238 if let Some(v) = fu.normal_error {
1239 pr.check(v > 0.0 && v <= 180.0, "dense.fusion.normal_error", || format!("(0, 180] 이어야 함 (지금 {v})"));
1240 }
1241 if let Some(v) = fu.reproj_error {
1242 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.reproj_error", || format!("양수여야 함 (지금 {v})"));
1243 }
1244 if let Some(v) = fu.visibility_tau {
1245 pr.check(v > 0.0 && v < 1.0, "dense.fusion.visibility_tau", || format!("(0, 1) 이어야 함 (지금 {v})"));
1246 }
1247 if let Some(v) = fu.smooth_radius {
1248 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_radius", || format!("양수여야 함 (지금 {v})"));
1249 }
1250 if let Some(v) = fu.smooth_range {
1251 pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_range", || format!("양수여야 함 (지금 {v})"));
1252 }
1253 if let Some(v) = fu.smooth_normal {
1254 pr.check(v > 0.0 && v <= 180.0, "dense.fusion.smooth_normal", || format!("(0, 180] 이어야 함 (지금 {v})"));
1255 }
1256 let fi = &d.filter;
1257 if let Some(v) = fi.geom_error {
1258 pr.check(v > 0.0 && v.is_finite(), "dense.filter.geom_error", || format!("양수여야 함 (지금 {v})"));
1259 }
1260 if let Some(v) = fi.min_ncc {
1261 pr.check((-1.0..=1.0).contains(&v), "dense.filter.min_ncc", || format!("[-1, 1] 이어야 함 (지금 {v})"));
1262 }
1263 }
1264
1265 if let Some(p) = &plan.sparse.intrinsics_prior {
1267 if let Err(e) = IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma) {
1268 pr.add("sparse.intrinsics_prior", e);
1269 }
1270 }
1271
1272 let s = &plan.sinks;
1274 pr.check(s.live_stride >= 1, "sinks.live_stride", || "must be >= 1 (1 = keep all)".into());
1275 if let Some(out) = &s.out {
1276 if let Err(e) = writable_target(out) {
1277 pr.add("sinks.out", e);
1278 }
1279 if s.clean && (inp.images.starts_with(out) || plan.gps.as_ref().is_some_and(|g| g.starts_with(out))) {
1280 pr.add("sinks.out", format!("시작 때 지울 출력 폴더 {} 안에 입력이 있음", out.display()));
1281 }
1282 }
1283
1284 let mut layout = None;
1286 if !inp.images.is_dir() {
1287 pr.add("input.images", format!("영상 폴더 없음: {}", inp.images.display()));
1288 } else {
1289 let missing: Vec<&String> = inp.cameras.iter().filter(|c| !inp.images.join(c).is_dir()).collect();
1290 for c in &missing {
1291 pr.add("input.cameras", format!("카메라 폴더 없음: {}", inp.images.join(c).display()));
1292 }
1293 if missing.is_empty() && !inp.cameras.is_empty() && inp.stride >= 1 {
1294 match Layout::discover_in(&inp.images, &inp.cameras, inp.stride) {
1295 Ok(mut l) => {
1296 if let Some(n) = inp.positions {
1297 l.truncate(n);
1298 }
1299 if l.frames.is_empty() {
1300 pr.add(
1301 "input",
1302 format!(
1303 "선택된 영상 없음: {}/{c}/{c}_NNNN.jpg 중 번호 % {} == 0 인 파일",
1304 inp.images.display(),
1305 inp.stride,
1306 c = inp.cameras[0]
1307 ),
1308 );
1309 } else {
1310 layout = Some(l);
1311 }
1312 }
1313 Err(e) => pr.add("input.images", e),
1314 }
1315 }
1316 }
1317
1318 let mut gps = None;
1320 match (plan.align.frame, &plan.gps) {
1321 (AlignFrame::None, _) => gps = Some(Vec::new()),
1322 (AlignFrame::Enu, None) => pr.add("gps", "ENU 정렬에는 GPS 파일이 필요함 — `.gps(경로)` 또는 정렬 끔([align] frame = \"none\")"),
1323 (AlignFrame::Enu, Some(p)) => match read_gps_file(p) {
1324 Err(e) => pr.add("gps", format!("{}: {e}", p.display())),
1325 Ok(all) => {
1326 if let Some(l) = &layout {
1327 let sel: Vec<GpsRecord> = all.into_iter().filter(|g| l.pos_of.contains_key(&g.name)).collect();
1328 if sel.is_empty() {
1329 pr.add("gps", format!("{}: 선택된 영상의 GPS 기록이 하나도 없음", p.display()));
1330 } else {
1331 gps = Some(sel);
1332 }
1333 } else {
1334 gps = Some(Vec::new());
1335 }
1336 }
1337 },
1338 }
1339 (layout, gps)
1340}
1341
1342pub struct Recon {
1348 plan: Plan,
1349 attach: Vec<Attach>,
1350 layout: Layout,
1351 gps: Vec<GpsRecord>,
1352 frames: bool,
1353}
1354
1355impl fmt::Debug for Recon {
1356 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1357 f.debug_struct("Recon")
1358 .field("plan", &self.plan)
1359 .field("hooks", &self.attach.len())
1360 .field("positions", &self.layout.frames.len())
1361 .finish()
1362 }
1363}
1364
1365impl Recon {
1366 pub fn declare() -> ReconBuilder {
1368 Self::from_plan(Plan::default())
1369 }
1370
1371 pub fn from_plan(plan: Plan) -> ReconBuilder {
1373 ReconBuilder { plan, attach: Vec::new(), pending: Vec::new(), frames: false }
1374 }
1375
1376 pub fn plan(&self) -> &Plan {
1378 &self.plan
1379 }
1380
1381 pub fn positions(&self) -> usize {
1383 self.layout.frames.len()
1384 }
1385
1386 pub fn layout(&self) -> &Layout {
1388 &self.layout
1389 }
1390
1391 pub fn run(self) -> Result<Summary, String> {
1395 let Recon { plan, attach, layout, gps, frames } = self;
1396 if plan.threads > 0 {
1397 let _ = rayon::ThreadPoolBuilder::new().num_threads(plan.threads).build_global();
1399 }
1400 let dense = if plan.dense.enabled { Some(plan.dense.config()?) } else { None };
1402 let prior = match &plan.sparse.intrinsics_prior {
1403 Some(p) => {
1404 Some(Arc::new(IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma)?))
1405 }
1406 None => None,
1407 };
1408 let s = &plan.sinks;
1409 let sinks = match &s.out {
1410 Some(out) => {
1411 if s.clean && out.exists() {
1412 std::fs::remove_dir_all(out).map_err(|e| format!("{}: {e}", out.display()))?;
1413 }
1414 let mut set = s.set();
1415 if plan.sparse.stable_display {
1418 let origin = gps.first().map(|g| [g.lat, g.lon, g.alt]);
1419 set.live_stream = Some(LiveStream { enu: origin.is_some(), origin_gps: origin });
1420 }
1421 Some(DefaultSinks::with_set(out, s.echo, set).map_err(|e| e.to_string())?)
1422 }
1423 None => None,
1424 };
1425 let npos = layout.frames.len();
1426 let mut sc = SessionConfig::new(plan.input.images.clone());
1427 sc.gps = gps;
1428 sc.span = plan.zones.span;
1429 sc.overlap = plan.zones.overlap;
1430 sc.total_positions = Some(npos);
1431 sc.incremental_triangulation = plan.sparse.incremental_triangulation;
1432 sc.stable_display = plan.sparse.stable_display;
1433 sc.settle_tol = plan.sparse.settle_tol;
1434 sc.settle_max_zone = plan.sparse.settle_max_zone;
1435 sc.intrinsics_prior = prior;
1436 sc.fixed_enu_origin = plan.align.fixed_origin;
1437 sc.seed = plan.seed;
1438 sc.extraction.sift.max_num_features = plan.features.max_num_features;
1439 sc.extraction.reader.max_image_size = plan.features.max_image_size;
1440 sc.sift = make_sift_backend(&plan.features.backend)?;
1441 sc.matcher = Arc::from(make_match_backend(&plan.matching.backend)?);
1442 sc.dense = dense;
1443 sc.history = 0;
1445 sc.decode_frames = frames;
1446
1447 let mut pl = Pipeline::new(Session::new(sc));
1448 if let Some(sinks) = sinks {
1449 pl = pl.on_any(sinks.into_hook());
1451 }
1452 for a in attach {
1453 pl = a(pl);
1454 }
1455
1456 let (span, ovl) = (plan.zones.span, plan.zones.overlap);
1457 let mut reg_end: BTreeMap<usize, Vec<ZoneRange>> = BTreeMap::new();
1458 for z in zones_upto(npos, span, ovl) {
1459 reg_end.entry(z.hi - 1).or_default().push(z);
1460 }
1461 pl.push(Input::Log(format!(
1462 "regions: {}",
1463 reg_end
1464 .iter()
1465 .map(|(e, v)| format!("{e} ->{}", v.iter().map(|z| format!(" {}:{}:{}", z.zone, z.lo, z.hi)).collect::<String>()))
1466 .collect::<Vec<_>>()
1467 .join(", ")
1468 )));
1469 let d = &plan.dense;
1470 pl.push(Input::Log(format!(
1471 "설정: span {span} overlap {ovl} stride {} 증분삼각측량 {} 깊이캐시 {} 고정원점 {} pm {}({})/sift {}/match {} 직렬조밀화 {} 왜곡보정최대 {} 뷰 {} 스레드 {}",
1472 plan.input.stride,
1473 plan.sparse.incremental_triangulation,
1474 d.depth_cache,
1475 plan.align.fixed_origin,
1476 d.backend,
1477 d.profile,
1478 plan.features.backend,
1479 plan.matching.backend,
1480 d.serialize,
1481 d.max_image_size,
1482 d.views,
1483 rayon::current_num_threads()
1484 )));
1485 for p in 0..npos {
1486 pl.push(frame_set_of(&layout, &plan.input.cameras, p).into());
1487 if let Some(e) = pl.reducer().failed() {
1488 return Err(e.to_string());
1490 }
1491 }
1492 let (_session, summary) = pl.finish();
1493 Ok(summary)
1494 }
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499 use super::*;
1500
1501 #[test]
1502 fn default_plan_toml_roundtrip() {
1503 let p = Plan::default();
1504 let s = p.to_toml();
1505 assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
1506 assert_eq!(Plan::from_toml("").unwrap(), p);
1508 }
1509
1510 #[test]
1511 fn full_plan_toml_roundtrip() {
1512 let b = Recon::declare()
1513 .input("some/where")
1514 .cameras(["a", "b"])
1515 .stride(2)
1516 .positions(5)
1517 .zones(6, 1)
1518 .fixed_enu_origin(true)
1519 .incremental_triangulation(true)
1520 .dense(
1521 Dense::profile("quality")
1522 .fusion_mode("traversal")
1523 .fusion_min_views(3)
1524 .fusion_depth_error(0.02)
1525 .fusion_normal_error(12.5)
1526 .fusion_reproj_error(1.5)
1527 .fusion_residual("second-pass")
1528 .fusion_smooth_normal(30.0)
1529 .max_levels(1)
1530 .filter_min_views(2)
1531 .filter_geom_error(1.25)
1532 .filter_min_ncc(0.1)
1533 .median_filter(true)
1534 .depth_cache(true)
1535 .serialize(true),
1536 )
1537 .sinks(Sinks::default_files("o").models(true).echo(false))
1538 .seed(42)
1539 .threads(3);
1540 let p = b.plan().clone();
1541 let s = p.to_toml();
1542 assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
1543 assert!(s.contains("[dense.fusion]") && s.contains("min_views = 3"), "{s}");
1544 assert!(s.contains("smooth_normal = 30.0") && s.contains("max_levels = 1"), "{s}");
1545 }
1546
1547 #[test]
1548 fn example_plan_parses() {
1549 let s = include_str!("../../../examples/plans/aerial-formation.toml");
1550 let p = Plan::from_toml(s).unwrap();
1551 assert_eq!(p.input.cameras, ["camF", "camR", "camL"]);
1552 assert_eq!(p.pairing, Pairing::Formation);
1553 assert_eq!(Plan::from_toml(&p.to_toml()).unwrap(), p);
1554 }
1555
1556 #[test]
1557 fn unknown_field_rejected() {
1558 assert!(Plan::from_toml("[zones]\nspam = 3\n").is_err());
1559 assert!(Plan::from_toml("pairing = \"grid\"\n").is_err());
1560 }
1561
1562 #[test]
1563 fn stream_plan_matches_builder() {
1564 let mut c = StreamConfig::new("src".into(), "dst".into());
1565 (c.stride, c.span, c.overlap, c.seed, c.save_models, c.echo) = (1, 4, 1, Some(7), true, false);
1566 let b = Recon::declare()
1567 .input("src")
1568 .preset(DEFAULT_PRESET)
1569 .stride(1)
1570 .zones(4, 1)
1571 .seed(7)
1572 .sinks(Sinks::default_files("dst").models(true).echo(false));
1573 assert_eq!(Plan::from_stream(&c), *b.plan());
1574 }
1575
1576 #[test]
1577 fn build_collects_all_problems() {
1578 let tmp = std::env::temp_dir().join(format!("cumulus3d_declare_missing_{}", std::process::id()));
1579 let e = Recon::declare()
1580 .input(tmp.join("in"))
1581 .preset("grid")
1582 .stride(0)
1583 .zones(4, 4)
1584 .dense(Dense::profile("slow").views(40).max_levels(0).fusion_smooth_normal(0.0).fusion_mode("magic").filter_min_ncc(2.0))
1585 .features(Features { backend: "fpga".into(), ..Features::default() })
1586 .sinks(Sinks::default_files(tmp.join("in")).clean(true))
1587 .build()
1588 .unwrap_err();
1589 for f in [
1590 "preset",
1591 "input.stride",
1592 "zones.overlap",
1593 "features.backend",
1594 "dense.profile",
1595 "dense.views",
1596 "dense.fusion.mode",
1597 "dense.max_levels",
1598 "dense.fusion.smooth_normal",
1599 "dense.filter.min_ncc",
1600 "sinks.out",
1601 "input.images",
1602 "gps",
1603 ] {
1604 assert!(e.has(f), "{f} 없음:\n{e}");
1605 }
1606 assert_eq!(e.has("dense.backend"), !cumulus3d_cuda::is_available());
1607 assert!(!tmp.exists());
1608 assert!(e.to_string().starts_with("계획 검사 실패("));
1609 }
1610
1611 #[test]
1612 fn live_stride_roundtrip_and_validation() {
1613 assert_eq!(Sinks::default().live_stride, 6);
1614 let p = Plan { sinks: Sinks::default_files("o").live_stride(3), ..Plan::default() };
1615 assert_eq!(Plan::from_toml(&p.to_toml()).unwrap().sinks.live_stride, 3);
1616 let e = Recon::declare().sinks(Sinks::default_files("o").live_stride(0)).no_dense().build().unwrap_err();
1617 assert!(e.has("sinks.live_stride"), "{e}");
1618 }
1619
1620 #[test]
1621 fn enu_without_gps_is_error() {
1622 let e = Recon::declare().no_gps().no_dense().build().unwrap_err();
1623 assert!(e.has("gps"), "{e}");
1624 let e = Recon::declare().no_gps().align(AlignFrame::None).no_dense().build().unwrap_err();
1625 assert!(!e.has("gps"), "{e}");
1626 }
1627}