1use crate::densewrap::{dense_model, make_pm_backend, parse_profile, DenseConfig};
6use clap::{Args, Subcommand};
7use cumulus3d_align::{align_to_gps, ModelAlignerOptions};
8use cumulus3d_ba::{bundle_adjust, BaConfig};
9use cumulus3d_core::analyzer::analyzer_lines;
10use cumulus3d_core::interop::{read_model, write_model_binary, write_model_text, ImageOrder};
11use cumulus3d_core::io::{read_gps_file, write_ply, PointCloud, PlyLayout};
12use cumulus3d_core::reconstruction::bilinear_rgb;
13use cumulus3d_core::{CameraModelKind, MatchGraph, MatchGraphOptions, FeatureStore, ImageId, Reconstruction};
14use cumulus3d_dense::densify::densify_with;
15use cumulus3d_dense::fusion_score::ScoreFusionOptions;
16use cumulus3d_dense::{write_undistorted_workspace, DenseScene, UndistortCache, UndistortOptions};
17use cumulus3d_features::{CameraMode, ExtractionOptions, FeatureExtractor, ImageStatus};
18use cumulus3d_matching::{match_pair_list_file, CpuMatcher, PairMatchingOptions};
19use cumulus3d_sfm::{global_mapper, register_images, triangulate_points, GlobalSfmOptions, PointTriangulatorOptions, RegistrationOptions};
20use std::path::{Path, PathBuf};
21use std::time::Instant;
22
23type R = Result<(), String>;
24
25fn e<E: std::fmt::Display>(x: E) -> String {
26 x.to_string()
27}
28
29pub fn parse_flag(s: &str) -> Result<bool, String> {
31 match s.to_ascii_lowercase().as_str() {
32 "1" | "true" | "yes" | "on" => Ok(true),
33 "0" | "false" | "no" | "off" => Ok(false),
34 _ => Err(format!("불린 값이 아님: {s}")),
35 }
36}
37
38#[derive(Subcommand, Debug)]
40pub enum InteropCmd {
41 #[command(name = "feature_extractor")]
43 FeatureExtractor(FeatureExtractorArgs),
44 #[command(name = "matches_importer")]
46 MatchesImporter(MatchesImporterArgs),
47 #[command(name = "global_mapper")]
49 GlobalMapper(GlobalMapperArgs),
50 #[command(name = "image_registrator")]
52 ImageRegistrator(ImageRegistratorArgs),
53 #[command(name = "point_triangulator")]
55 PointTriangulator(PointTriangulatorArgs),
56 #[command(name = "bundle_adjuster")]
58 BundleAdjuster(BundleAdjusterArgs),
59 #[command(name = "model_aligner")]
61 ModelAligner(ModelAlignerArgs),
62 #[command(name = "model_analyzer")]
64 ModelAnalyzer(ModelAnalyzerArgs),
65 #[command(name = "model_converter")]
67 ModelConverter(ModelConverterArgs),
68 #[command(name = "image_deleter")]
70 ImageDeleter(ImageDeleterArgs),
71 #[command(name = "image_undistorter")]
73 ImageUndistorter(ImageUndistorterArgs),
74 #[command(name = "densify")]
76 Densify(Box<DensifyArgs>),
77}
78
79#[derive(Args, Debug)]
81pub struct FeatureExtractorArgs {
82 #[arg(long = "database_path")]
84 pub database_path: PathBuf,
85 #[arg(long = "image_path")]
87 pub image_path: PathBuf,
88 #[arg(long = "image_list_path")]
90 pub image_list_path: Option<PathBuf>,
91 #[arg(long = "ImageReader.camera_model", default_value = "SIMPLE_RADIAL")]
93 pub camera_model: String,
94 #[arg(long = "ImageReader.single_camera", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
96 pub single_camera: bool,
97 #[arg(long = "ImageReader.single_camera_per_folder", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
99 pub single_camera_per_folder: bool,
100 #[arg(long = "ImageReader.existing_camera_id")]
102 pub existing_camera_id: Option<i64>,
103 #[arg(long = "ImageReader.camera_params")]
105 pub camera_params: Option<String>,
106 #[arg(long = "ImageReader.default_focal_length_factor", default_value_t = 1.2)]
108 pub default_focal_length_factor: f64,
109 #[arg(long = "SiftExtraction.max_num_features", default_value_t = 8192)]
111 pub max_num_features: usize,
112 #[arg(long = "SiftExtraction.max_image_size", default_value_t = 3200)]
114 pub max_image_size: usize,
115 #[arg(long = "FeatureExtraction.use_gpu", value_parser = parse_flag)]
117 pub use_gpu: Option<bool>,
118 #[arg(long = "SiftExtraction.use_gpu", value_parser = parse_flag)]
120 pub sift_use_gpu: Option<bool>,
121}
122
123#[derive(Args, Debug)]
125pub struct MatchesImporterArgs {
126 #[arg(long = "database_path")]
128 pub database_path: PathBuf,
129 #[arg(long = "match_list_path")]
131 pub match_list_path: PathBuf,
132 #[arg(long = "match_type", default_value = "pairs")]
134 pub match_type: String,
135 #[arg(long = "FeatureMatching.use_gpu", value_parser = parse_flag)]
137 pub use_gpu: Option<bool>,
138 #[arg(long = "SiftMatching.use_gpu", value_parser = parse_flag)]
140 pub sift_use_gpu: Option<bool>,
141 #[arg(long = "SiftMatching.max_num_matches", default_value_t = 32768)]
143 pub max_num_matches: usize,
144}
145
146#[derive(Args, Debug)]
148pub struct GlobalMapperArgs {
149 #[arg(long = "database_path")]
151 pub database_path: PathBuf,
152 #[arg(long = "image_path")]
154 pub image_path: Option<PathBuf>,
155 #[arg(long = "output_path")]
157 pub output_path: PathBuf,
158 #[arg(long = "GlobalMapper.ba_num_iterations")]
160 pub ba_num_iterations: Option<usize>,
161 #[arg(long = "GlobalMapper.skip_retriangulation", value_parser = parse_flag)]
163 pub skip_retriangulation: Option<bool>,
164 #[arg(long = "GlobalMapper.keep_max_num_tracks")]
166 pub keep_max_num_tracks: Option<usize>,
167}
168
169#[derive(Args, Debug)]
171pub struct ImageRegistratorArgs {
172 #[arg(long = "database_path")]
174 pub database_path: PathBuf,
175 #[arg(long = "input_path")]
177 pub input_path: PathBuf,
178 #[arg(long = "output_path")]
180 pub output_path: PathBuf,
181}
182
183#[derive(Args, Debug)]
185pub struct PointTriangulatorArgs {
186 #[arg(long = "database_path")]
188 pub database_path: PathBuf,
189 #[arg(long = "image_path")]
191 pub image_path: Option<PathBuf>,
192 #[arg(long = "input_path")]
194 pub input_path: PathBuf,
195 #[arg(long = "output_path")]
197 pub output_path: PathBuf,
198 #[arg(long = "clear_points", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
200 pub clear_points: bool,
201}
202
203#[derive(Args, Debug)]
205pub struct BundleAdjusterArgs {
206 #[arg(long = "input_path")]
208 pub input_path: PathBuf,
209 #[arg(long = "output_path")]
211 pub output_path: PathBuf,
212 #[arg(long = "BundleAdjustment.max_num_iterations", default_value_t = 100)]
214 pub max_num_iterations: usize,
215 #[arg(long = "BundleAdjustment.refine_focal_length", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
217 pub refine_focal_length: bool,
218 #[arg(long = "BundleAdjustment.refine_principal_point", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
220 pub refine_principal_point: bool,
221 #[arg(long = "BundleAdjustment.refine_extra_params", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
223 pub refine_extra_params: bool,
224}
225
226#[derive(Args, Debug)]
228pub struct ModelAlignerArgs {
229 #[arg(long = "input_path")]
231 pub input_path: PathBuf,
232 #[arg(long = "output_path")]
234 pub output_path: PathBuf,
235 #[arg(long = "ref_images_path")]
237 pub ref_images_path: PathBuf,
238 #[arg(long = "ref_is_gps", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
240 pub ref_is_gps: bool,
241 #[arg(long = "alignment_type", default_value = "enu")]
243 pub alignment_type: String,
244 #[arg(long = "alignment_max_error", default_value_t = 0.0)]
246 pub alignment_max_error: f64,
247 #[arg(long = "min_common_images", default_value_t = 3)]
249 pub min_common_images: usize,
250}
251
252#[derive(Args, Debug)]
254pub struct ModelAnalyzerArgs {
255 #[arg(long = "path")]
257 pub path: PathBuf,
258 #[arg(long = "verbose", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
260 pub verbose: bool,
261}
262
263#[derive(Args, Debug)]
265pub struct ModelConverterArgs {
266 #[arg(long = "input_path")]
268 pub input_path: PathBuf,
269 #[arg(long = "output_path")]
271 pub output_path: PathBuf,
272 #[arg(long = "output_type")]
274 pub output_type: String,
275}
276
277#[derive(Args, Debug)]
279pub struct ImageDeleterArgs {
280 #[arg(long = "input_path")]
282 pub input_path: PathBuf,
283 #[arg(long = "output_path")]
285 pub output_path: PathBuf,
286 #[arg(long = "image_ids_path")]
288 pub image_ids_path: Option<PathBuf>,
289 #[arg(long = "image_names_path")]
291 pub image_names_path: Option<PathBuf>,
292}
293
294#[derive(Args, Debug)]
296pub struct ImageUndistorterArgs {
297 #[arg(long = "image_path")]
299 pub image_path: PathBuf,
300 #[arg(long = "input_path")]
302 pub input_path: PathBuf,
303 #[arg(long = "output_path")]
305 pub output_path: PathBuf,
306 #[arg(long = "output_type", default_value = "COLMAP")]
308 pub output_type: String,
309 #[arg(long = "max_image_size", default_value_t = -1)]
311 pub max_image_size: i64,
312}
313
314#[derive(Args, Debug)]
316pub struct DensifyArgs {
317 #[arg(long = "input_path", short = 'i')]
319 pub input_path: PathBuf,
320 #[arg(long = "output_path", short = 'o')]
322 pub output_path: PathBuf,
323 #[arg(long = "image_path")]
325 pub image_path: Option<PathBuf>,
326 #[arg(long = "max_image_size", default_value_t = 960)]
328 pub max_image_size: i64,
329 #[arg(long = "number-views", default_value_t = 10)]
331 pub number_views: usize,
332 #[arg(long = "pm-backend", default_value = "cuda")]
334 pub pm_backend: String,
335 #[arg(long = "mvs-profile", default_value = "fast")]
337 pub mvs_profile: String,
338 #[arg(long = "window-radius")]
340 pub window_radius: Option<i32>,
341 #[arg(long = "window-step")]
343 pub window_step: Option<i32>,
344 #[arg(long = "max-levels")]
346 pub max_levels: Option<usize>,
347 #[arg(long = "max-images")]
349 pub max_images: Option<usize>,
350 #[arg(long = "fusion-mode")]
352 pub fusion_mode: Option<String>,
353 #[arg(long = "fusion-min-views")]
355 pub fusion_min_views: Option<usize>,
356 #[arg(long = "fusion-depth-error")]
358 pub fusion_depth_error: Option<f64>,
359 #[arg(long = "fusion-normal-error")]
361 pub fusion_normal_error: Option<f64>,
362 #[arg(long = "fusion-reproj-error")]
364 pub fusion_reproj_error: Option<f64>,
365 #[arg(long = "fusion-residual")]
367 pub fusion_residual: Option<String>,
368 #[arg(long = "residual-min-views")]
370 pub residual_min_views: Option<usize>,
371 #[arg(long = "residual-depth-error")]
373 pub residual_depth_error: Option<f64>,
374 #[arg(long = "residual-normal-error")]
376 pub residual_normal_error: Option<f64>,
377 #[arg(long = "residual-min-dist")]
379 pub residual_min_dist: Option<f64>,
380 #[arg(long = "fusion-mark-used", value_parser = parse_flag)]
382 pub fusion_mark_used: Option<bool>,
383 #[arg(long = "fusion-mark-radius")]
385 pub fusion_mark_radius: Option<usize>,
386 #[arg(long = "fusion-num-images")]
388 pub fusion_num_images: Option<usize>,
389 #[arg(long = "geo-iters")]
391 pub geo_iters: Option<u32>,
392 #[arg(long = "geo-rounds")]
394 pub geo_rounds: Option<u32>,
395 #[arg(long = "fusion-inverse-variance", value_parser = parse_flag)]
397 pub fusion_inverse_variance: Option<bool>,
398 #[arg(long = "filter-min-views")]
400 pub filter_min_views: Option<u32>,
401 #[arg(long = "filter-geom-error")]
403 pub filter_geom_error: Option<f32>,
404 #[arg(long = "filter-min-ncc")]
406 pub filter_min_ncc: Option<f32>,
407 #[arg(long = "remove-speckles", value_parser = parse_flag)]
409 pub remove_speckles: Option<bool>,
410 #[arg(long = "fill-holes", value_parser = parse_flag)]
412 pub fill_holes: Option<bool>,
413 #[arg(long = "diversity-decay")]
415 pub diversity_decay: Option<f64>,
416 #[arg(long = "weak-texture-var")]
418 pub weak_texture_var: Option<f32>,
419 #[arg(long = "median-filter", value_parser = parse_flag)]
421 pub median_filter: Option<bool>,
422 #[arg(long = "stats")]
424 pub stats: bool,
425 #[arg(long = "score-tau")]
427 pub score_tau: Option<f64>,
428 #[arg(long = "score-sigma-e")]
430 pub score_sigma_e: Option<f64>,
431 #[arg(long = "score-sigma-theta")]
433 pub score_sigma_theta: Option<f64>,
434 #[arg(long = "score-lambda")]
436 pub score_lambda: Option<f64>,
437 #[arg(long = "residual-out")]
439 pub residual_out: Option<PathBuf>,
440 #[arg(long = "fusion-variants")]
442 pub fusion_variants: Option<PathBuf>,
443}
444
445pub(crate) fn apply_densify_args(a: &DensifyArgs, o: &mut cumulus3d_dense::DensifyOptions) -> R {
447 if let Some(r) = a.window_radius {
448 o.pm.window_radius = r;
449 }
450 if let Some(st) = a.window_step {
451 o.pm.window_step = st;
452 }
453 if let Some(l) = a.max_levels {
454 o.max_levels = l;
455 }
456 if let Some(m) = a.fusion_mode.as_deref().filter(|m| !is_score_mode(m)) {
457 o.fusion.mode = cumulus3d_dense::FusionMode::parse(m).ok_or_else(|| format!("알 수 없는 --fusion-mode {m}"))?;
458 }
459 let f = &mut o.fusion;
460 if let Some(v) = a.fusion_min_views {
461 f.min_consistent_views = v;
462 }
463 if let Some(v) = a.fusion_depth_error {
464 f.max_depth_error = v;
465 }
466 if let Some(v) = a.fusion_normal_error {
467 f.max_normal_error_deg = v;
468 }
469 if let Some(v) = a.fusion_reproj_error {
470 f.max_reproj_error = v;
471 }
472 if let Some(m) = &a.fusion_residual {
473 f.residual = cumulus3d_dense::FusionResidual::parse(m).ok_or_else(|| format!("알 수 없는 --fusion-residual {m}"))?;
474 }
475 if let Some(v) = a.residual_min_views {
476 f.residual_params.min_views = v;
477 }
478 if let Some(v) = a.residual_depth_error {
479 f.residual_params.depth_error = Some(v);
480 }
481 if let Some(v) = a.residual_normal_error {
482 f.residual_params.normal_error_deg = Some(v);
483 }
484 if let Some(v) = a.residual_min_dist {
485 f.residual_params.min_dist_gsd = v;
486 }
487 if let Some(v) = a.fusion_mark_used {
488 f.mark_used = v;
489 }
490 if let Some(v) = a.fusion_mark_radius {
491 f.mark_radius = v;
492 }
493 if let Some(v) = a.fusion_num_images {
494 f.consistency_num_images = v;
495 }
496 if let Some(v) = a.geo_iters {
497 o.geometric_iters_override = Some(v);
498 }
499 if let Some(v) = a.geo_rounds {
500 o.geometric_rounds_override = Some(v);
501 }
502 if let Some(v) = a.fusion_inverse_variance {
503 f.inverse_variance = v;
504 }
505 if let Some(v) = a.filter_min_views {
506 o.filter.min_num_consistent = v;
507 }
508 if let Some(v) = a.filter_geom_error {
509 o.filter.geom_max_cost = v;
510 }
511 if let Some(v) = a.filter_min_ncc {
512 o.filter.min_ncc = v;
513 }
514 if let Some(v) = a.remove_speckles {
515 o.post.remove_speckles = v;
516 }
517 if let Some(v) = a.fill_holes {
518 o.post.fill_holes = v;
519 }
520 if let Some(v) = a.diversity_decay {
521 o.neighbors.diversity_decay = v;
522 }
523 if let Some(v) = a.weak_texture_var {
524 o.pm.weak_texture_var = v;
525 }
526 if let Some(v) = a.median_filter {
527 o.filter.median_filter = v;
528 }
529 Ok(())
530}
531
532fn is_score_mode(m: &str) -> bool {
533 m.eq_ignore_ascii_case("score")
534}
535
536fn apply_score_args(a: &DensifyArgs, s: &mut ScoreFusionOptions) {
537 if let Some(v) = a.score_tau {
538 s.tau = v;
539 }
540 if let Some(v) = a.score_sigma_e {
541 s.sigma_e_px = v;
542 }
543 if let Some(v) = a.score_sigma_theta {
544 s.sigma_theta_deg = v;
545 }
546 if let Some(v) = a.score_lambda {
547 s.lambda = v;
548 }
549}
550
551fn has_score_args(a: &DensifyArgs) -> bool {
552 a.score_tau.is_some() || a.score_sigma_e.is_some() || a.score_sigma_theta.is_some() || a.score_lambda.is_some()
553}
554
555pub(crate) fn score_options(a: &DensifyArgs, o: &cumulus3d_dense::DensifyOptions, base: Option<&DensifyArgs>) -> Result<Option<ScoreFusionOptions>, String> {
558 let mode = a.fusion_mode.as_deref().or_else(|| base.and_then(|b| b.fusion_mode.as_deref()));
559 if !mode.is_some_and(is_score_mode) {
560 if has_score_args(a) || base.is_some_and(|b| a.fusion_mode.is_none() && has_score_args(b)) {
561 return Err("--score-* 는 --fusion-mode score 와 함께".into());
562 }
563 return Ok(None);
564 }
565 let mut s = ScoreFusionOptions::from_fusion(&o.fusion);
566 if let Some(b) = base {
567 apply_score_args(b, &mut s);
568 }
569 apply_score_args(a, &mut s);
570 Ok(Some(s))
571}
572
573fn load_store(p: &Path) -> Result<FeatureStore, String> {
574 if p.exists() {
575 FeatureStore::load(p).map_err(|x| format!("{}: {x}", p.display()))
576 } else {
577 Ok(FeatureStore::new())
578 }
579}
580
581fn load_model(p: &Path) -> Result<Reconstruction, String> {
582 read_model(p).map_err(|x| format!("{}: {x}", p.display()))
583}
584
585fn save_model(rec: &Reconstruction, p: &Path) -> R {
586 std::fs::create_dir_all(p).map_err(e)?;
587 write_model_binary(rec, p, ImageOrder::Registration).map_err(e)
588}
589
590fn graph_of(store: &FeatureStore) -> MatchGraph {
591 MatchGraph::from_store(store, &MatchGraphOptions::default())
592}
593
594pub fn extract_colors(rec: &mut Reconstruction, image_path: &Path) {
596 rec.extract_colors(|im| {
597 let img = image::open(image_path.join(&im.name)).ok()?.to_rgb8();
598 let (w, h) = (img.width() as usize, img.height() as usize);
599 let data = img.into_raw();
600 Some(move |x: f64, y: f64| bilinear_rgb(&data, w, h, x, y))
601 });
602}
603
604fn list_images(root: &Path) -> Vec<String> {
605 fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
606 let Ok(rd) = std::fs::read_dir(dir) else { return };
607 for ent in rd.flatten() {
608 let p = ent.path();
609 if p.is_dir() {
610 walk(&p, root, out);
611 } else if let Some(ext) = p.extension().and_then(|x| x.to_str()) {
612 if matches!(ext.to_ascii_lowercase().as_str(), "jpg" | "jpeg" | "png") {
613 if let Ok(rel) = p.strip_prefix(root) {
614 out.push(rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"));
615 }
616 }
617 }
618 }
619 }
620 let mut v = Vec::new();
621 walk(root, root, &mut v);
622 v.sort();
623 v
624}
625
626pub fn run(cmd: InteropCmd) -> R {
628 let t = Instant::now();
629 match cmd {
630 InteropCmd::FeatureExtractor(a) => {
631 let store = load_store(&a.database_path)?;
632 let names = match &a.image_list_path {
633 Some(p) => cumulus3d_features::read_image_list(p).map_err(e)?,
634 None => list_images(&a.image_path),
635 };
636 let mut o = ExtractionOptions::default();
637 o.reader.camera_model = CameraModelKind::from_name(&a.camera_model).map_err(e)?;
638 o.reader.camera_mode = if let Some(id) = a.existing_camera_id.filter(|x| *x >= 0) {
639 CameraMode::Existing(id as u32)
640 } else if a.single_camera {
641 CameraMode::Single
642 } else if a.single_camera_per_folder {
643 CameraMode::PerFolder
644 } else {
645 CameraMode::PerImage
646 };
647 if let Some(p) = &a.camera_params {
648 o.reader.camera_params = Some(p.split(',').map(|x| x.trim().parse::<f64>().map_err(e)).collect::<Result<_, _>>()?);
649 }
650 o.reader.default_focal_length_factor = a.default_focal_length_factor;
651 o.reader.max_image_size = a.max_image_size;
652 o.sift.max_num_features = a.max_num_features;
653 let rep = FeatureExtractor::new().extract_files(&store, &a.image_path, &names, &o).map_err(e)?;
654 for r in &rep {
655 match &r.status {
656 ImageStatus::Extracted { image_id, camera_id, num_features } => {
657 println!("{} image_id={image_id} camera_id={camera_id} features={num_features}", r.name)
658 }
659 ImageStatus::AlreadyExists { image_id } => println!("{} image_id={image_id} (이미 있음)", r.name),
660 ImageStatus::Failed { error } => println!("{} 실패: {error}", r.name),
661 }
662 }
663 store.save(&a.database_path).map_err(e)?;
664 }
665 InteropCmd::MatchesImporter(a) => {
666 if a.match_type != "pairs" {
667 return Err(format!("--match_type {} 미지원(pairs 만)", a.match_type));
668 }
669 let store = load_store(&a.database_path)?;
670 let opts = PairMatchingOptions { max_num_matches: a.max_num_matches, ..Default::default() };
671 let (list, st) = match_pair_list_file(&store, &a.match_list_path, &opts, &CpuMatcher::default()).map_err(e)?;
672 for n in &list.missing_names {
673 println!("경고: 이름 없음 {n}");
674 }
675 println!(
676 "짝 {} 건너뜀 {} 매칭 {} 검증만 {} 유효 기하 {}",
677 list.pairs.len(),
678 st.num_skipped_existing,
679 st.num_matched,
680 st.num_verified_only,
681 st.num_valid_geometries
682 );
683 store.save(&a.database_path).map_err(e)?;
684 }
685 InteropCmd::GlobalMapper(a) => {
686 let store = load_store(&a.database_path)?;
687 let graph = graph_of(&store);
688 let mut o = GlobalSfmOptions::default();
689 if let Some(n) = a.ba_num_iterations {
690 o.ba_num_iterations = n;
691 }
692 if let Some(s) = a.skip_retriangulation {
693 o.skip_retriangulation = s;
694 }
695 if let Some(n) = a.keep_max_num_tracks {
696 o.tracks.max_num_tracks = n;
697 }
698 let out = global_mapper(&store, &graph, &o).map_err(e)?;
699 if let Some(f) = &out.failure {
700 println!("경고: {f}");
701 }
702 let mut rec = out.reconstruction;
703 if let Some(ip) = &a.image_path {
704 extract_colors(&mut rec, ip);
705 }
706 if rec.registered_image_count() > 0 {
707 save_model(&rec, &a.output_path.join("0"))?;
708 }
709 println!("등록 {} 점 {}", rec.registered_image_count(), rec.num_points3d());
710 }
711 InteropCmd::ImageRegistrator(a) => {
712 let store = load_store(&a.database_path)?;
713 let graph = graph_of(&store);
714 let mut rec = load_model(&a.input_path)?;
715 let rep = register_images(&mut rec, &store, &graph, &RegistrationOptions::default()).map_err(e)?;
716 println!("시도 {} 등록 {}", rep.attempts.len(), rep.registered().len());
717 save_model(&rec, &a.output_path)?;
718 }
719 InteropCmd::PointTriangulator(a) => {
720 let store = load_store(&a.database_path)?;
721 let graph = graph_of(&store);
722 let mut rec = load_model(&a.input_path)?;
723 let o = PointTriangulatorOptions { clear_points: a.clear_points, ..Default::default() };
724 let rep = triangulate_points(&mut rec, &graph, &o).map_err(e)?;
725 if let Some(ip) = &a.image_path {
726 extract_colors(&mut rec, ip);
727 }
728 println!("{rep:?}");
729 save_model(&rec, &a.output_path)?;
730 }
731 InteropCmd::BundleAdjuster(a) => {
732 let mut rec = load_model(&a.input_path)?;
733 let cfg = BaConfig {
734 max_num_iterations: a.max_num_iterations,
735 refine_focal_length: a.refine_focal_length,
736 refine_principal_point: a.refine_principal_point,
737 refine_extra_params: a.refine_extra_params,
738 ..Default::default()
739 };
740 let s = bundle_adjust(&mut rec, &cfg).map_err(e)?;
741 println!("반복 {} 비용 {:.6e} → {:.6e} {:?}", s.num_iterations, s.initial_cost, s.final_cost, s.termination);
742 save_model(&rec, &a.output_path)?;
743 }
744 InteropCmd::ModelAligner(a) => {
745 if !a.ref_is_gps || a.alignment_type != "enu" {
746 return Err("model_aligner: --ref_is_gps 1 --alignment_type enu 만 지원".into());
747 }
748 let mut rec = load_model(&a.input_path)?;
749 let gps = read_gps_file(&a.ref_images_path).map_err(e)?;
750 let o = ModelAlignerOptions { max_error: a.alignment_max_error, min_common_images: a.min_common_images, ..Default::default() };
751 let al = align_to_gps(&mut rec, &gps, &o).map_err(e)?;
752 for (n, er) in &al.errors {
753 println!("{n}: {er:.4}");
754 }
755 println!("평균 오차 {:.4} 중앙 {:.4} 인라이어 {}/{}", al.mean_error, al.median_error, al.num_inliers, al.common.len());
756 save_model(&rec, &a.output_path)?;
757 }
758 InteropCmd::ModelAnalyzer(a) => {
759 let rec = load_model(&a.path)?;
760 for l in analyzer_lines(&rec, a.verbose) {
761 println!("{l}");
762 }
763 return Ok(());
764 }
765 InteropCmd::ModelConverter(a) => {
766 let rec = load_model(&a.input_path)?;
767 match a.output_type.to_ascii_uppercase().as_str() {
768 "BIN" => save_model(&rec, &a.output_path)?,
769 "TXT" => {
770 std::fs::create_dir_all(&a.output_path).map_err(e)?;
771 write_model_text(&rec, &a.output_path, ImageOrder::Registration).map_err(e)?
772 }
773 "PLY" => {
774 let mut c = PointCloud::default();
775 for (_, p) in rec.points3d() {
776 c.positions.push([p.xyz.x as f32, p.xyz.y as f32, p.xyz.z as f32]);
777 c.normals.push([0.0; 3]);
778 c.colors.push(p.color);
779 }
780 write_ply(&a.output_path, &c, PlyLayout::XyzNormalRgb).map_err(e)?
781 }
782 o => return Err(format!("--output_type {o} 미지원(BIN|TXT|PLY)")),
783 }
784 }
785 InteropCmd::ImageDeleter(a) => {
786 let mut rec = load_model(&a.input_path)?;
787 let mut warn = Vec::new();
788 if let Some(p) = &a.image_ids_path {
789 let ids: Vec<ImageId> = std::fs::read_to_string(p).map_err(e)?.split_whitespace().filter_map(|x| x.parse().ok()).collect();
790 warn.extend(rec.deregister_images_by_id(&ids));
791 }
792 if let Some(p) = &a.image_names_path {
793 let text = std::fs::read_to_string(p).map_err(e)?;
794 let names: Vec<&str> = text.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
795 warn.extend(rec.deregister_images_by_name(&names));
796 }
797 for w in warn {
798 println!("{w}");
799 }
800 save_model(&rec, &a.output_path)?;
801 }
802 InteropCmd::ImageUndistorter(a) => {
803 if a.output_type != "COLMAP" {
804 return Err("image_undistorter: --output_type COLMAP 만 지원".into());
805 }
806 let rec = load_model(&a.input_path)?;
807 let o = UndistortOptions { max_image_size: a.max_image_size, ..Default::default() };
808 let r = cumulus3d_dense::undistort_from_dir(&rec, &a.image_path, &o, &UndistortCache::new()).map_err(e)?;
809 write_undistorted_workspace(&r, &a.output_path, &o).map_err(e)?;
810 println!("보정 {} 실패 {}", r.images.len(), r.failed.len());
811 }
812 InteropCmd::Densify(a) => {
813 let profile = parse_profile(&a.mvs_profile)?;
814 let mut cfg = DenseConfig::new(make_pm_backend(&a.pm_backend), profile);
815 cfg.undistort.max_image_size = a.max_image_size;
816 cfg.densify.neighbors.num_views = a.number_views;
817 apply_densify_args(&a, &mut cfg.densify)?;
818 cfg.score = score_options(&a, &cfg.densify, None)?;
819 let variants = a.fusion_variants.as_deref().map(|vp| crate::fusion_variants::read_variants(vp, &a, &cfg.densify)).transpose()?;
821 let backend = cfg.backend.clone()?;
822 let tl = Instant::now();
823 let (out, frames, scene) = match &a.image_path {
824 Some(ip) => {
825 let model = load_model(&a.input_path)?;
826 let keep: std::collections::HashSet<String> = model
827 .registered_images()
828 .into_iter()
829 .take(a.max_images.unwrap_or(usize::MAX))
830 .filter_map(|i| model.image(i).map(|im| im.name.clone()))
831 .collect();
832 let r = dense_model(&model, |n| keep.contains(n), ip, &cfg)?;
833 eprintln!("[time] 왜곡 보정+장면 {:.2}s", r.undistort_time.as_secs_f64());
834 (r.output, r.frames, r.scene)
835 }
836 None => {
837 let scene = DenseScene::from_workspace_dir(&a.input_path, &cfg.scene).map_err(e)?;
838 eprintln!("[time] 장면 읽기 {:.2}s", tl.elapsed().as_secs_f64());
839 let n = scene.views.len();
840 (densify_with(&scene, &cfg.densify, cfg.score.as_ref(), backend.as_ref(), None).map_err(e)?, n, scene)
841 }
842 };
843 let tm = &out.timings;
844 let lv: Vec<String> = tm.levels.iter().map(|d| format!("{:.2}", d.as_secs_f64())).collect();
845 eprintln!(
846 "[time] 뷰 {frames}, 깊이맵 {}: 이웃 {:.2}s 준비 {:.2}s 깊이 {:.2}s (스케일별 [{}], 그중 상향표본·판정 {:.2}s) 필터 {:.2}s 융합 {:.2}s 합 {:.2}s",
847 out.depth_views,
848 tm.neighbors.as_secs_f64(),
849 tm.prepare.as_secs_f64(),
850 tm.depth().as_secs_f64(),
851 lv.join(", "),
852 tm.upsample.as_secs_f64(),
853 tm.filter.as_secs_f64(),
854 tm.fusion.as_secs_f64(),
855 tm.total().as_secs_f64()
856 );
857 let tw = Instant::now();
858 out.write_ply(&a.output_path).map_err(e)?;
859 if let Some(rp) = &a.residual_out {
860 if !out.write_residual_ply(rp).map_err(e)? {
861 eprintln!("경고: 2차 융합 점 없음 → {} 쓰지 않음", rp.display());
862 }
863 }
864 eprintln!("[time] PLY 쓰기 {:.2}s", tw.elapsed().as_secs_f64());
865 println!("점 {} (2차 {}) 융합 {:.2}s (1차 {:.2}s 2차 {:.2}s)", out.cloud.len(), out.num_residual(), tm.fusion.as_secs_f64(), tm.fusion_pass1.as_secs_f64(), tm.fusion_pass2.as_secs_f64());
866 if a.stats {
867 let ts = Instant::now();
868 let st = cumulus3d_dense::cloud_stats(&scene, &out.depth_maps, &out.cloud, &out.visibility, 200_000, 2.0);
869 println!(
870 "통계: 점 {} 표본 {} 이웃 간격 중앙 {:.4} GSD {:.4} 이상점(2px) {:.2}% 평면 이탈(>GSD·고립) {:.2}% 평면 거리 중앙 {:.4} 중복(<GSD/2) {:.2}% ({:.1}s)",
871 st.points,
872 st.sampled,
873 st.nn_spacing_median,
874 st.gsd,
875 100.0 * st.outlier_ratio,
876 100.0 * st.plane_outlier_ratio,
877 st.plane_residual_median,
878 100.0 * st.duplicate_ratio,
879 ts.elapsed().as_secs_f64()
880 );
881 }
882 if let Some(variants) = &variants {
883 let dir = a.output_path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
884 crate::fusion_variants::run_variants(&scene, &out.depth_maps, variants, dir, a.stats)?;
885 }
886 }
887 }
888 eprintln!("Elapsed time: {:.3} [minutes]", t.elapsed().as_secs_f64() / 60.0);
889 Ok(())
890}