1use crate::densewrap::{dense_model, make_pm_backend, parse_profile, DenseConfig};
39use clap::{Args, Subcommand};
40use cumulus3d_align::{align_to_gps, ModelAlignerOptions};
41use cumulus3d_ba::{bundle_adjust, BaConfig};
42use cumulus3d_core::analyzer::analyzer_lines;
43use cumulus3d_core::interop::{read_model, write_model_binary, write_model_text, ImageOrder};
44use cumulus3d_core::io::{read_gps_file, write_ply, PlyLayout, PointCloud};
45use cumulus3d_core::reconstruction::bilinear_rgb;
46use cumulus3d_core::{CameraModelKind, FeatureStore, ImageId, MatchGraph, MatchGraphOptions, Reconstruction};
47use cumulus3d_dense::densify::densify_with;
48use cumulus3d_dense::fusion_score::ScoreFusionOptions;
49use cumulus3d_dense::{write_undistorted_workspace, DenseScene, UndistortCache, UndistortOptions};
50use cumulus3d_features::{CameraMode, ExtractionOptions, FeatureExtractor, ImageStatus};
51use cumulus3d_matching::{match_pair_list_file, CpuMatcher, PairMatchingOptions};
52use cumulus3d_sfm::{global_mapper, register_images, triangulate_points, GlobalSfmOptions, PointTriangulatorOptions, RegistrationOptions};
53use std::path::{Path, PathBuf};
54use std::time::Instant;
55
56type R = Result<(), String>;
57
58fn e<E: std::fmt::Display>(x: E) -> String {
59 x.to_string()
60}
61
62pub fn parse_flag(s: &str) -> Result<bool, String> {
64 match s.to_ascii_lowercase().as_str() {
65 "1" | "true" | "yes" | "on" => Ok(true),
66 "0" | "false" | "no" | "off" => Ok(false),
67 _ => Err(format!("불린 값이 아님: {s}")),
68 }
69}
70
71#[derive(Subcommand, Debug)]
73pub enum InteropCmd {
74 #[command(name = "feature_extractor")]
76 FeatureExtractor(FeatureExtractorArgs),
77 #[command(name = "matches_importer")]
79 MatchesImporter(MatchesImporterArgs),
80 #[command(name = "global_mapper")]
82 GlobalMapper(GlobalMapperArgs),
83 #[command(name = "image_registrator")]
85 ImageRegistrator(ImageRegistratorArgs),
86 #[command(name = "point_triangulator")]
88 PointTriangulator(PointTriangulatorArgs),
89 #[command(name = "bundle_adjuster")]
91 BundleAdjuster(BundleAdjusterArgs),
92 #[command(name = "model_aligner")]
94 ModelAligner(ModelAlignerArgs),
95 #[command(name = "model_analyzer")]
97 ModelAnalyzer(ModelAnalyzerArgs),
98 #[command(name = "model_converter")]
100 ModelConverter(ModelConverterArgs),
101 #[command(name = "image_deleter")]
103 ImageDeleter(ImageDeleterArgs),
104 #[command(name = "image_undistorter")]
106 ImageUndistorter(ImageUndistorterArgs),
107 #[command(name = "densify")]
109 Densify(Box<DensifyArgs>),
110}
111
112#[derive(Args, Debug)]
114pub struct FeatureExtractorArgs {
115 #[arg(long = "database_path")]
117 pub database_path: PathBuf,
118 #[arg(long = "image_path")]
120 pub image_path: PathBuf,
121 #[arg(long = "image_list_path")]
123 pub image_list_path: Option<PathBuf>,
124 #[arg(long = "ImageReader.camera_model", default_value = "SIMPLE_RADIAL")]
126 pub camera_model: String,
127 #[arg(long = "ImageReader.single_camera", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
129 pub single_camera: bool,
130 #[arg(long = "ImageReader.single_camera_per_folder", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
132 pub single_camera_per_folder: bool,
133 #[arg(long = "ImageReader.existing_camera_id")]
135 pub existing_camera_id: Option<i64>,
136 #[arg(long = "ImageReader.camera_params")]
138 pub camera_params: Option<String>,
139 #[arg(long = "ImageReader.default_focal_length_factor", default_value_t = 1.2)]
141 pub default_focal_length_factor: f64,
142 #[arg(long = "SiftExtraction.max_num_features", default_value_t = 8192)]
144 pub max_num_features: usize,
145 #[arg(long = "SiftExtraction.max_image_size", default_value_t = 3200)]
147 pub max_image_size: usize,
148 #[arg(long = "FeatureExtraction.use_gpu", value_parser = parse_flag)]
150 pub use_gpu: Option<bool>,
151 #[arg(long = "SiftExtraction.use_gpu", value_parser = parse_flag)]
153 pub sift_use_gpu: Option<bool>,
154}
155
156#[derive(Args, Debug)]
158pub struct MatchesImporterArgs {
159 #[arg(long = "database_path")]
161 pub database_path: PathBuf,
162 #[arg(long = "match_list_path")]
164 pub match_list_path: PathBuf,
165 #[arg(long = "match_type", default_value = "pairs")]
167 pub match_type: String,
168 #[arg(long = "FeatureMatching.use_gpu", value_parser = parse_flag)]
170 pub use_gpu: Option<bool>,
171 #[arg(long = "SiftMatching.use_gpu", value_parser = parse_flag)]
173 pub sift_use_gpu: Option<bool>,
174 #[arg(long = "SiftMatching.max_num_matches", default_value_t = 32768)]
176 pub max_num_matches: usize,
177}
178
179#[derive(Args, Debug)]
181pub struct GlobalMapperArgs {
182 #[arg(long = "database_path")]
184 pub database_path: PathBuf,
185 #[arg(long = "image_path")]
187 pub image_path: Option<PathBuf>,
188 #[arg(long = "output_path")]
190 pub output_path: PathBuf,
191 #[arg(long = "GlobalMapper.ba_num_iterations")]
193 pub ba_num_iterations: Option<usize>,
194 #[arg(long = "GlobalMapper.skip_retriangulation", value_parser = parse_flag)]
196 pub skip_retriangulation: Option<bool>,
197 #[arg(long = "GlobalMapper.keep_max_num_tracks")]
199 pub keep_max_num_tracks: Option<usize>,
200}
201
202#[derive(Args, Debug)]
204pub struct ImageRegistratorArgs {
205 #[arg(long = "database_path")]
207 pub database_path: PathBuf,
208 #[arg(long = "input_path")]
210 pub input_path: PathBuf,
211 #[arg(long = "output_path")]
213 pub output_path: PathBuf,
214}
215
216#[derive(Args, Debug)]
218pub struct PointTriangulatorArgs {
219 #[arg(long = "database_path")]
221 pub database_path: PathBuf,
222 #[arg(long = "image_path")]
224 pub image_path: Option<PathBuf>,
225 #[arg(long = "input_path")]
227 pub input_path: PathBuf,
228 #[arg(long = "output_path")]
230 pub output_path: PathBuf,
231 #[arg(long = "clear_points", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
233 pub clear_points: bool,
234}
235
236#[derive(Args, Debug)]
238pub struct BundleAdjusterArgs {
239 #[arg(long = "input_path")]
241 pub input_path: PathBuf,
242 #[arg(long = "output_path")]
244 pub output_path: PathBuf,
245 #[arg(long = "BundleAdjustment.max_num_iterations", default_value_t = 100)]
247 pub max_num_iterations: usize,
248 #[arg(long = "BundleAdjustment.refine_focal_length", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
250 pub refine_focal_length: bool,
251 #[arg(long = "BundleAdjustment.refine_principal_point", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
253 pub refine_principal_point: bool,
254 #[arg(long = "BundleAdjustment.refine_extra_params", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
256 pub refine_extra_params: bool,
257}
258
259#[derive(Args, Debug)]
261pub struct ModelAlignerArgs {
262 #[arg(long = "input_path")]
264 pub input_path: PathBuf,
265 #[arg(long = "output_path")]
267 pub output_path: PathBuf,
268 #[arg(long = "ref_images_path")]
270 pub ref_images_path: PathBuf,
271 #[arg(long = "ref_is_gps", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
273 pub ref_is_gps: bool,
274 #[arg(long = "alignment_type", default_value = "enu")]
276 pub alignment_type: String,
277 #[arg(long = "alignment_max_error", default_value_t = 0.0)]
279 pub alignment_max_error: f64,
280 #[arg(long = "min_common_images", default_value_t = 3)]
282 pub min_common_images: usize,
283}
284
285#[derive(Args, Debug)]
287pub struct ModelAnalyzerArgs {
288 #[arg(long = "path")]
290 pub path: PathBuf,
291 #[arg(long = "verbose", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
293 pub verbose: bool,
294}
295
296#[derive(Args, Debug)]
298pub struct ModelConverterArgs {
299 #[arg(long = "input_path")]
301 pub input_path: PathBuf,
302 #[arg(long = "output_path")]
304 pub output_path: PathBuf,
305 #[arg(long = "output_type")]
307 pub output_type: String,
308}
309
310#[derive(Args, Debug)]
312pub struct ImageDeleterArgs {
313 #[arg(long = "input_path")]
315 pub input_path: PathBuf,
316 #[arg(long = "output_path")]
318 pub output_path: PathBuf,
319 #[arg(long = "image_ids_path")]
321 pub image_ids_path: Option<PathBuf>,
322 #[arg(long = "image_names_path")]
324 pub image_names_path: Option<PathBuf>,
325}
326
327#[derive(Args, Debug)]
329pub struct ImageUndistorterArgs {
330 #[arg(long = "image_path")]
332 pub image_path: PathBuf,
333 #[arg(long = "input_path")]
335 pub input_path: PathBuf,
336 #[arg(long = "output_path")]
338 pub output_path: PathBuf,
339 #[arg(long = "output_type", default_value = "COLMAP")]
341 pub output_type: String,
342 #[arg(long = "max_image_size", default_value_t = -1)]
344 pub max_image_size: i64,
345}
346
347#[derive(Args, Debug)]
349pub struct DensifyArgs {
350 #[arg(long = "input_path", short = 'i')]
353 pub input_path: PathBuf,
354 #[arg(long = "output_path", short = 'o')]
356 pub output_path: PathBuf,
357 #[arg(long = "image_path")]
359 pub image_path: Option<PathBuf>,
360 #[arg(long = "max_image_size", default_value_t = 960)]
362 pub max_image_size: i64,
363 #[arg(long = "number-views", default_value_t = 10)]
365 pub number_views: usize,
366 #[arg(long = "pm-backend", default_value = "cuda")]
368 pub pm_backend: String,
369 #[arg(long = "mvs-profile", default_value = "fast")]
371 pub mvs_profile: String,
372 #[arg(long = "window-radius")]
374 pub window_radius: Option<i32>,
375 #[arg(long = "window-step")]
377 pub window_step: Option<i32>,
378 #[arg(long = "max-levels")]
380 pub max_levels: Option<usize>,
381 #[arg(long = "max-images")]
383 pub max_images: Option<usize>,
384 #[arg(long = "fusion-mode")]
386 pub fusion_mode: Option<String>,
387 #[arg(long = "fusion-min-views")]
389 pub fusion_min_views: Option<usize>,
390 #[arg(long = "fusion-depth-error")]
392 pub fusion_depth_error: Option<f64>,
393 #[arg(long = "fusion-normal-error")]
395 pub fusion_normal_error: Option<f64>,
396 #[arg(long = "fusion-reproj-error")]
398 pub fusion_reproj_error: Option<f64>,
399 #[arg(long = "fusion-residual")]
401 pub fusion_residual: Option<String>,
402 #[arg(long = "residual-min-views")]
404 pub residual_min_views: Option<usize>,
405 #[arg(long = "residual-depth-error")]
407 pub residual_depth_error: Option<f64>,
408 #[arg(long = "residual-normal-error")]
410 pub residual_normal_error: Option<f64>,
411 #[arg(long = "residual-min-dist")]
413 pub residual_min_dist: Option<f64>,
414 #[arg(long = "fusion-mark-used", value_parser = parse_flag)]
416 pub fusion_mark_used: Option<bool>,
417 #[arg(long = "fusion-mark-radius")]
419 pub fusion_mark_radius: Option<usize>,
420 #[arg(long = "fusion-num-images")]
422 pub fusion_num_images: Option<usize>,
423 #[arg(long = "geo-iters")]
425 pub geo_iters: Option<u32>,
426 #[arg(long = "geo-rounds")]
428 pub geo_rounds: Option<u32>,
429 #[arg(long = "fusion-inverse-variance", value_parser = parse_flag)]
431 pub fusion_inverse_variance: Option<bool>,
432 #[arg(long = "fusion-smooth-iters")]
434 pub fusion_smooth_iters: Option<usize>,
435 #[arg(long = "fusion-smooth-radius")]
437 pub fusion_smooth_radius: Option<f64>,
438 #[arg(long = "fusion-smooth-range")]
440 pub fusion_smooth_range: Option<f64>,
441 #[arg(long = "fusion-smooth-normal")]
443 pub fusion_smooth_normal: Option<f64>,
444 #[arg(long = "visibility-views")]
446 pub visibility_views: Option<usize>,
447 #[arg(long = "visibility-tau")]
449 pub visibility_tau: Option<f64>,
450 #[arg(long = "visibility-min-violations")]
452 pub visibility_min_violations: Option<usize>,
453 #[arg(long = "filter-min-views")]
455 pub filter_min_views: Option<u32>,
456 #[arg(long = "filter-geom-error")]
458 pub filter_geom_error: Option<f32>,
459 #[arg(long = "filter-min-ncc")]
461 pub filter_min_ncc: Option<f32>,
462 #[arg(long = "remove-speckles", value_parser = parse_flag)]
464 pub remove_speckles: Option<bool>,
465 #[arg(long = "fill-holes", value_parser = parse_flag)]
467 pub fill_holes: Option<bool>,
468 #[arg(long = "diversity-decay")]
470 pub diversity_decay: Option<f64>,
471 #[arg(long = "weak-texture-var")]
473 pub weak_texture_var: Option<f32>,
474 #[arg(long = "median-filter", value_parser = parse_flag)]
476 pub median_filter: Option<bool>,
477 #[arg(long = "stats")]
479 pub stats: bool,
480 #[arg(long = "score-tau")]
482 pub score_tau: Option<f64>,
483 #[arg(long = "score-sigma-e")]
485 pub score_sigma_e: Option<f64>,
486 #[arg(long = "score-sigma-theta")]
488 pub score_sigma_theta: Option<f64>,
489 #[arg(long = "score-lambda")]
491 pub score_lambda: Option<f64>,
492 #[arg(long = "residual-out")]
494 pub residual_out: Option<PathBuf>,
495 #[arg(long = "fusion-variants")]
498 pub fusion_variants: Option<PathBuf>,
499}
500
501pub(crate) fn apply_densify_args(a: &DensifyArgs, o: &mut cumulus3d_dense::DensifyOptions) -> R {
503 if let Some(r) = a.window_radius {
504 o.pm.window_radius = r;
505 }
506 if let Some(st) = a.window_step {
507 o.pm.window_step = st;
508 }
509 if let Some(l) = a.max_levels {
510 o.max_levels = l;
511 }
512 if let Some(m) = a.fusion_mode.as_deref().filter(|m| !is_score_mode(m)) {
513 o.fusion.mode = cumulus3d_dense::FusionMode::parse(m).ok_or_else(|| format!("알 수 없는 --fusion-mode {m}"))?;
514 }
515 let f = &mut o.fusion;
516 if let Some(v) = a.fusion_min_views {
517 f.min_consistent_views = v;
518 }
519 if let Some(v) = a.fusion_depth_error {
520 f.max_depth_error = v;
521 }
522 if let Some(v) = a.fusion_normal_error {
523 f.max_normal_error_deg = v;
524 }
525 if let Some(v) = a.fusion_reproj_error {
526 f.max_reproj_error = v;
527 }
528 if let Some(m) = &a.fusion_residual {
529 f.residual = cumulus3d_dense::FusionResidual::parse(m).ok_or_else(|| format!("알 수 없는 --fusion-residual {m}"))?;
530 }
531 if let Some(v) = a.residual_min_views {
532 f.residual_params.min_views = v;
533 }
534 if let Some(v) = a.residual_depth_error {
535 f.residual_params.depth_error = Some(v);
536 }
537 if let Some(v) = a.residual_normal_error {
538 f.residual_params.normal_error_deg = Some(v);
539 }
540 if let Some(v) = a.residual_min_dist {
541 f.residual_params.min_dist_gsd = v;
542 }
543 if let Some(v) = a.fusion_mark_used {
544 f.mark_used = v;
545 }
546 if let Some(v) = a.fusion_mark_radius {
547 f.mark_radius = v;
548 }
549 if let Some(v) = a.fusion_num_images {
550 f.consistency_num_images = v;
551 }
552 if let Some(v) = a.geo_iters {
553 o.geometric_iters_override = Some(v);
554 }
555 if let Some(v) = a.geo_rounds {
556 o.geometric_rounds_override = Some(v);
557 }
558 if let Some(v) = a.fusion_inverse_variance {
559 f.inverse_variance = v;
560 }
561 if let Some(v) = a.fusion_smooth_iters {
562 f.smooth.iterations = v;
563 }
564 if let Some(v) = a.fusion_smooth_radius {
565 f.smooth.radius_gsd = v;
566 }
567 if let Some(v) = a.fusion_smooth_range {
568 f.smooth.sigma_range_gsd = v;
569 }
570 if let Some(v) = a.fusion_smooth_normal {
571 f.smooth.normal_deg = v;
572 }
573 if let Some(v) = a.visibility_views {
574 f.visibility.check_views = v;
575 }
576 if let Some(v) = a.visibility_tau {
577 f.visibility.tau = v;
578 }
579 if let Some(v) = a.visibility_min_violations {
580 f.visibility.min_violations = v;
581 }
582 if let Some(v) = a.filter_min_views {
583 o.filter.min_num_consistent = v;
584 }
585 if let Some(v) = a.filter_geom_error {
586 o.filter.geom_max_cost = v;
587 }
588 if let Some(v) = a.filter_min_ncc {
589 o.filter.min_ncc = v;
590 }
591 if let Some(v) = a.remove_speckles {
592 o.post.remove_speckles = v;
593 }
594 if let Some(v) = a.fill_holes {
595 o.post.fill_holes = v;
596 }
597 if let Some(v) = a.diversity_decay {
598 o.neighbors.diversity_decay = v;
599 }
600 if let Some(v) = a.weak_texture_var {
601 o.pm.weak_texture_var = v;
602 }
603 if let Some(v) = a.median_filter {
604 o.filter.median_filter = v;
605 }
606 Ok(())
607}
608
609fn is_score_mode(m: &str) -> bool {
610 m.eq_ignore_ascii_case("score")
611}
612
613fn apply_score_args(a: &DensifyArgs, s: &mut ScoreFusionOptions) {
614 if let Some(v) = a.score_tau {
615 s.tau = v;
616 }
617 if let Some(v) = a.score_sigma_e {
618 s.sigma_e_px = v;
619 }
620 if let Some(v) = a.score_sigma_theta {
621 s.sigma_theta_deg = v;
622 }
623 if let Some(v) = a.score_lambda {
624 s.lambda = v;
625 }
626}
627
628fn has_score_args(a: &DensifyArgs) -> bool {
629 a.score_tau.is_some() || a.score_sigma_e.is_some() || a.score_sigma_theta.is_some() || a.score_lambda.is_some()
630}
631
632pub(crate) fn score_options(
637 a: &DensifyArgs,
638 o: &cumulus3d_dense::DensifyOptions,
639 base: Option<&DensifyArgs>,
640) -> Result<Option<ScoreFusionOptions>, String> {
641 let mode = a.fusion_mode.as_deref().or_else(|| base.and_then(|b| b.fusion_mode.as_deref()));
642 if !mode.is_some_and(is_score_mode) {
643 if has_score_args(a) || base.is_some_and(|b| a.fusion_mode.is_none() && has_score_args(b)) {
644 return Err("--score-* 는 --fusion-mode score 와 함께".into());
645 }
646 return Ok(None);
647 }
648 let mut s = ScoreFusionOptions::from_fusion(&o.fusion);
649 if let Some(b) = base {
650 apply_score_args(b, &mut s);
651 }
652 apply_score_args(a, &mut s);
653 Ok(Some(s))
654}
655
656fn load_store(p: &Path) -> Result<FeatureStore, String> {
657 if p.exists() {
658 FeatureStore::load(p).map_err(|x| format!("{}: {x}", p.display()))
659 } else {
660 Ok(FeatureStore::new())
661 }
662}
663
664fn load_model(p: &Path) -> Result<Reconstruction, String> {
665 read_model(p).map_err(|x| format!("{}: {x}", p.display()))
666}
667
668fn save_model(rec: &Reconstruction, p: &Path) -> R {
669 std::fs::create_dir_all(p).map_err(e)?;
670 write_model_binary(rec, p, ImageOrder::Registration).map_err(e)
671}
672
673fn graph_of(store: &FeatureStore) -> MatchGraph {
674 MatchGraph::from_store(store, &MatchGraphOptions::default())
675}
676
677pub fn extract_colors(rec: &mut Reconstruction, image_path: &Path) {
679 rec.extract_colors(|im| {
680 let img = image::open(image_path.join(&im.name)).ok()?.to_rgb8();
681 let (w, h) = (img.width() as usize, img.height() as usize);
682 let data = img.into_raw();
683 Some(move |x: f64, y: f64| bilinear_rgb(&data, w, h, x, y))
684 });
685}
686
687fn list_images(root: &Path) -> Vec<String> {
688 fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
689 let Ok(rd) = std::fs::read_dir(dir) else { return };
690 for ent in rd.flatten() {
691 let p = ent.path();
692 if p.is_dir() {
693 walk(&p, root, out);
694 } else if let Some(ext) = p.extension().and_then(|x| x.to_str()) {
695 if matches!(ext.to_ascii_lowercase().as_str(), "jpg" | "jpeg" | "png") {
696 if let Ok(rel) = p.strip_prefix(root) {
697 out.push(rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"));
698 }
699 }
700 }
701 }
702 }
703 let mut v = Vec::new();
704 walk(root, root, &mut v);
705 v.sort();
706 v
707}
708
709pub fn run(cmd: InteropCmd) -> R {
711 let t = Instant::now();
712 match cmd {
713 InteropCmd::FeatureExtractor(a) => {
714 let store = load_store(&a.database_path)?;
715 let names = match &a.image_list_path {
716 Some(p) => cumulus3d_features::read_image_list(p).map_err(e)?,
717 None => list_images(&a.image_path),
718 };
719 let mut o = ExtractionOptions::default();
720 o.reader.camera_model = CameraModelKind::from_name(&a.camera_model).map_err(e)?;
721 o.reader.camera_mode = if let Some(id) = a.existing_camera_id.filter(|x| *x >= 0) {
722 CameraMode::Existing(id as u32)
723 } else if a.single_camera {
724 CameraMode::Single
725 } else if a.single_camera_per_folder {
726 CameraMode::PerFolder
727 } else {
728 CameraMode::PerImage
729 };
730 if let Some(p) = &a.camera_params {
731 o.reader.camera_params = Some(p.split(',').map(|x| x.trim().parse::<f64>().map_err(e)).collect::<Result<_, _>>()?);
732 }
733 o.reader.default_focal_length_factor = a.default_focal_length_factor;
734 o.reader.max_image_size = a.max_image_size;
735 o.sift.max_num_features = a.max_num_features;
736 let rep = FeatureExtractor::new().extract_files(&store, &a.image_path, &names, &o).map_err(e)?;
737 for r in &rep {
738 match &r.status {
739 ImageStatus::Extracted { image_id, camera_id, num_features } => {
740 println!("{} image_id={image_id} camera_id={camera_id} features={num_features}", r.name)
741 }
742 ImageStatus::AlreadyExists { image_id } => println!("{} image_id={image_id} (이미 있음)", r.name),
743 ImageStatus::Failed { error } => println!("{} 실패: {error}", r.name),
744 }
745 }
746 store.save(&a.database_path).map_err(e)?;
747 }
748 InteropCmd::MatchesImporter(a) => {
749 if a.match_type != "pairs" {
750 return Err(format!("--match_type {} 미지원(pairs 만)", a.match_type));
751 }
752 let store = load_store(&a.database_path)?;
753 let opts = PairMatchingOptions { max_num_matches: a.max_num_matches, ..Default::default() };
754 let (list, st) = match_pair_list_file(&store, &a.match_list_path, &opts, &CpuMatcher::default()).map_err(e)?;
755 for n in &list.missing_names {
756 println!("경고: 이름 없음 {n}");
757 }
758 println!(
759 "짝 {} 건너뜀 {} 매칭 {} 검증만 {} 유효 기하 {}",
760 list.pairs.len(),
761 st.num_skipped_existing,
762 st.num_matched,
763 st.num_verified_only,
764 st.num_valid_geometries
765 );
766 store.save(&a.database_path).map_err(e)?;
767 }
768 InteropCmd::GlobalMapper(a) => {
769 let store = load_store(&a.database_path)?;
770 let graph = graph_of(&store);
771 let mut o = GlobalSfmOptions::default();
772 if let Some(n) = a.ba_num_iterations {
773 o.ba_num_iterations = n;
774 }
775 if let Some(s) = a.skip_retriangulation {
776 o.skip_retriangulation = s;
777 }
778 if let Some(n) = a.keep_max_num_tracks {
779 o.tracks.max_num_tracks = n;
780 }
781 let out = global_mapper(&store, &graph, &o).map_err(e)?;
782 if let Some(f) = &out.failure {
783 println!("경고: {f}");
784 }
785 let mut rec = out.reconstruction;
786 if let Some(ip) = &a.image_path {
787 extract_colors(&mut rec, ip);
788 }
789 if rec.registered_image_count() > 0 {
790 save_model(&rec, &a.output_path.join("0"))?;
791 }
792 println!("등록 {} 점 {}", rec.registered_image_count(), rec.num_points3d());
793 }
794 InteropCmd::ImageRegistrator(a) => {
795 let store = load_store(&a.database_path)?;
796 let graph = graph_of(&store);
797 let mut rec = load_model(&a.input_path)?;
798 let rep = register_images(&mut rec, &store, &graph, &RegistrationOptions::default()).map_err(e)?;
799 println!("시도 {} 등록 {}", rep.attempts.len(), rep.registered().len());
800 save_model(&rec, &a.output_path)?;
801 }
802 InteropCmd::PointTriangulator(a) => {
803 let store = load_store(&a.database_path)?;
804 let graph = graph_of(&store);
805 let mut rec = load_model(&a.input_path)?;
806 let o = PointTriangulatorOptions { clear_points: a.clear_points, ..Default::default() };
807 let rep = triangulate_points(&mut rec, &graph, &o).map_err(e)?;
808 if let Some(ip) = &a.image_path {
809 extract_colors(&mut rec, ip);
810 }
811 println!("{rep:?}");
812 save_model(&rec, &a.output_path)?;
813 }
814 InteropCmd::BundleAdjuster(a) => {
815 let mut rec = load_model(&a.input_path)?;
816 let cfg = BaConfig {
817 max_num_iterations: a.max_num_iterations,
818 refine_focal_length: a.refine_focal_length,
819 refine_principal_point: a.refine_principal_point,
820 refine_extra_params: a.refine_extra_params,
821 ..Default::default()
822 };
823 let s = bundle_adjust(&mut rec, &cfg).map_err(e)?;
824 println!("반복 {} 비용 {:.6e} → {:.6e} {:?}", s.num_iterations, s.initial_cost, s.final_cost, s.termination);
825 save_model(&rec, &a.output_path)?;
826 }
827 InteropCmd::ModelAligner(a) => {
828 if !a.ref_is_gps || a.alignment_type != "enu" {
829 return Err("model_aligner: --ref_is_gps 1 --alignment_type enu 만 지원".into());
830 }
831 let mut rec = load_model(&a.input_path)?;
832 let gps = read_gps_file(&a.ref_images_path).map_err(e)?;
833 let o = ModelAlignerOptions { max_error: a.alignment_max_error, min_common_images: a.min_common_images, ..Default::default() };
834 let al = align_to_gps(&mut rec, &gps, &o).map_err(e)?;
835 for (n, er) in &al.errors {
836 println!("{n}: {er:.4}");
837 }
838 println!("평균 오차 {:.4} 중앙 {:.4} 인라이어 {}/{}", al.mean_error, al.median_error, al.num_inliers, al.common.len());
839 save_model(&rec, &a.output_path)?;
840 }
841 InteropCmd::ModelAnalyzer(a) => {
842 let rec = load_model(&a.path)?;
843 for l in analyzer_lines(&rec, a.verbose) {
844 println!("{l}");
845 }
846 return Ok(());
847 }
848 InteropCmd::ModelConverter(a) => {
849 let rec = load_model(&a.input_path)?;
850 match a.output_type.to_ascii_uppercase().as_str() {
851 "BIN" => save_model(&rec, &a.output_path)?,
852 "TXT" => {
853 std::fs::create_dir_all(&a.output_path).map_err(e)?;
854 write_model_text(&rec, &a.output_path, ImageOrder::Registration).map_err(e)?
855 }
856 "PLY" => {
857 let mut c = PointCloud::default();
858 for (_, p) in rec.points3d() {
859 c.positions.push([p.xyz.x as f32, p.xyz.y as f32, p.xyz.z as f32]);
860 c.normals.push([0.0; 3]);
861 c.colors.push(p.color);
862 }
863 write_ply(&a.output_path, &c, PlyLayout::XyzNormalRgb).map_err(e)?
864 }
865 o => return Err(format!("--output_type {o} 미지원(BIN|TXT|PLY)")),
866 }
867 }
868 InteropCmd::ImageDeleter(a) => {
869 let mut rec = load_model(&a.input_path)?;
870 let mut warn = Vec::new();
871 if let Some(p) = &a.image_ids_path {
872 let ids: Vec<ImageId> = std::fs::read_to_string(p).map_err(e)?.split_whitespace().filter_map(|x| x.parse().ok()).collect();
873 warn.extend(rec.deregister_images_by_id(&ids));
874 }
875 if let Some(p) = &a.image_names_path {
876 let text = std::fs::read_to_string(p).map_err(e)?;
877 let names: Vec<&str> = text.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
878 warn.extend(rec.deregister_images_by_name(&names));
879 }
880 for w in warn {
881 println!("{w}");
882 }
883 save_model(&rec, &a.output_path)?;
884 }
885 InteropCmd::ImageUndistorter(a) => {
886 if a.output_type != "COLMAP" {
887 return Err("image_undistorter: --output_type COLMAP 만 지원".into());
888 }
889 let rec = load_model(&a.input_path)?;
890 let o = UndistortOptions { max_image_size: a.max_image_size, ..Default::default() };
891 let r = cumulus3d_dense::undistort_from_dir(&rec, &a.image_path, &o, &UndistortCache::new()).map_err(e)?;
892 write_undistorted_workspace(&r, &a.output_path, &o).map_err(e)?;
893 println!("보정 {} 실패 {}", r.images.len(), r.failed.len());
894 }
895 InteropCmd::Densify(a) => {
896 let profile = parse_profile(&a.mvs_profile)?;
897 let mut cfg = DenseConfig::new(make_pm_backend(&a.pm_backend), profile);
898 cfg.undistort.max_image_size = a.max_image_size;
899 cfg.densify.neighbors.num_views = a.number_views;
900 apply_densify_args(&a, &mut cfg.densify)?;
901 cfg.score = score_options(&a, &cfg.densify, None)?;
902 let variants =
904 a.fusion_variants.as_deref().map(|vp| crate::fusion_variants::read_variants(vp, &a, &cfg.densify)).transpose()?;
905 let backend = cfg.backend.clone()?;
906 let tl = Instant::now();
907 let (out, frames, scene) = match &a.image_path {
908 Some(ip) => {
909 let model = load_model(&a.input_path)?;
910 let keep: std::collections::HashSet<String> = model
911 .registered_images()
912 .into_iter()
913 .take(a.max_images.unwrap_or(usize::MAX))
914 .filter_map(|i| model.image(i).map(|im| im.name.clone()))
915 .collect();
916 let r = dense_model(&model, |n| keep.contains(n), ip, &cfg)?;
917 eprintln!("[time] 왜곡 보정+장면 {:.2}s", r.undistort_time.as_secs_f64());
918 (r.output, r.frames, r.scene)
919 }
920 None => {
921 let scene = DenseScene::from_workspace_dir(&a.input_path, &cfg.scene).map_err(e)?;
922 eprintln!("[time] 장면 읽기 {:.2}s", tl.elapsed().as_secs_f64());
923 let n = scene.views.len();
924 (densify_with(&scene, &cfg.densify, cfg.score.as_ref(), backend.as_ref(), None).map_err(e)?, n, scene)
925 }
926 };
927 let tm = &out.timings;
928 let lv: Vec<String> = tm.levels.iter().map(|d| format!("{:.2}", d.as_secs_f64())).collect();
929 eprintln!(
930 "[time] 뷰 {frames}, 깊이맵 {}: 이웃 {:.2}s 준비 {:.2}s 깊이 {:.2}s (스케일별 [{}], 그중 상향표본·판정 {:.2}s) 필터 {:.2}s 융합 {:.2}s 합 {:.2}s",
931 out.depth_views,
932 tm.neighbors.as_secs_f64(),
933 tm.prepare.as_secs_f64(),
934 tm.depth().as_secs_f64(),
935 lv.join(", "),
936 tm.upsample.as_secs_f64(),
937 tm.filter.as_secs_f64(),
938 tm.fusion.as_secs_f64(),
939 tm.total().as_secs_f64()
940 );
941 let tw = Instant::now();
942 out.write_ply(&a.output_path).map_err(e)?;
943 if let Some(rp) = &a.residual_out {
944 if !out.write_residual_ply(rp).map_err(e)? {
945 eprintln!("경고: 2차 융합 점 없음 → {} 쓰지 않음", rp.display());
946 }
947 }
948 eprintln!("[time] PLY 쓰기 {:.2}s", tw.elapsed().as_secs_f64());
949 println!(
950 "점 {} (2차 {}) 융합 {:.2}s (1차 {:.2}s 2차 {:.2}s 평활 {:.2}s, 가시성 {:.2}s, 제거 {})",
951 out.cloud.len(),
952 out.num_residual(),
953 tm.fusion.as_secs_f64(),
954 tm.fusion_pass1.as_secs_f64(),
955 tm.fusion_pass2.as_secs_f64(),
956 tm.fusion_smooth.as_secs_f64(),
957 tm.fusion_visibility.as_secs_f64(),
958 tm.fusion_visibility_removed
959 );
960 if a.stats {
961 let ts = Instant::now();
962 let st = cumulus3d_dense::cloud_stats(&scene, &out.depth_maps, &out.cloud, &out.visibility, 200_000, 2.0);
963 println!(
964 "통계: 점 {} 표본 {} 이웃 간격 중앙 {:.4} GSD {:.4} 이상점(2px) {:.2}% 평면 이탈(>GSD·고립) {:.2}% 평면 거리 중앙 {:.4} 중복(<GSD/2) {:.2}% ({:.1}s)",
965 st.points,
966 st.sampled,
967 st.nn_spacing_median,
968 st.gsd,
969 100.0 * st.outlier_ratio,
970 100.0 * st.plane_outlier_ratio,
971 st.plane_residual_median,
972 100.0 * st.duplicate_ratio,
973 ts.elapsed().as_secs_f64()
974 );
975 }
976 if let Some(variants) = &variants {
977 let dir = a.output_path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
978 crate::fusion_variants::run_variants(&scene, &out.depth_maps, variants, dir, a.stats)?;
979 }
980 }
981 }
982 eprintln!("Elapsed time: {:.3} [minutes]", t.elapsed().as_secs_f64() / 60.0);
983 Ok(())
984}