Skip to main content

cumulus3d_cli/
interop.rs

1//! 외부 SfM 도구(COLMAP) 단계별 명령 호환 하위 명령(얇은 래퍼). 하위 명령 이름과 옵션 문자열은 그 도구의 명령줄을 따른다.
2//! 단계 사이 상태는 `--database_path` (FeatureStore 이진 파일)와 모델 폴더([`cumulus3d_core::interop`] 형식)로 잇는다.
3//! GPU 관련 옵션(`--FeatureExtraction.use_gpu` 등)은 받기만 하고 무시한다.
4
5use 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
29/// `1/0/true/false` 를 받는다(명령줄 관례).
30pub 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/// 단계별 하위 명령(명령 이름은 `feature_extractor` 등 밑줄 형식).
39#[derive(Subcommand, Debug)]
40pub enum InteropCmd {
41    /// 영상 폴더 → 특징 저장소(SIFT 추출, 카메라 초기화).
42    #[command(name = "feature_extractor")]
43    FeatureExtractor(FeatureExtractorArgs),
44    /// 짝 목록 파일 → 기술자 매칭 + 두 뷰 기하 검증.
45    #[command(name = "matches_importer")]
46    MatchesImporter(MatchesImporterArgs),
47    /// 특징 저장소 → 전역 SfM 모델(`output_path/0`).
48    #[command(name = "global_mapper")]
49    GlobalMapper(GlobalMapperArgs),
50    /// 기존 모델에 아직 등록되지 않은 영상을 등록.
51    #[command(name = "image_registrator")]
52    ImageRegistrator(ImageRegistratorArgs),
53    /// 등록된 자세로 3D 점 삼각측량.
54    #[command(name = "point_triangulator")]
55    PointTriangulator(PointTriangulatorArgs),
56    /// 모델 번들 조정.
57    #[command(name = "bundle_adjuster")]
58    BundleAdjuster(BundleAdjusterArgs),
59    /// 모델을 GPS ENU 좌표계로 정렬.
60    #[command(name = "model_aligner")]
61    ModelAligner(ModelAlignerArgs),
62    /// 모델 통계 출력.
63    #[command(name = "model_analyzer")]
64    ModelAnalyzer(ModelAnalyzerArgs),
65    /// 모델 형식 변환(BIN/TXT/PLY).
66    #[command(name = "model_converter")]
67    ModelConverter(ModelConverterArgs),
68    /// 모델에서 영상 삭제.
69    #[command(name = "image_deleter")]
70    ImageDeleter(ImageDeleterArgs),
71    /// 왜곡 보정 작업 폴더 생성.
72    #[command(name = "image_undistorter")]
73    ImageUndistorter(ImageUndistorterArgs),
74    /// 조밀화: image_undistorter 출력 폴더 → dense.ply.
75    #[command(name = "densify")]
76    Densify(Box<DensifyArgs>),
77}
78
79/// `feature_extractor` 인자.
80#[derive(Args, Debug)]
81pub struct FeatureExtractorArgs {
82    /// 특징 저장소 파일 경로(없으면 새로 만든다).
83    #[arg(long = "database_path")]
84    pub database_path: PathBuf,
85    /// 입력 영상 폴더.
86    #[arg(long = "image_path")]
87    pub image_path: PathBuf,
88    /// 처리할 영상 이름 목록 파일(없으면 폴더 전체).
89    #[arg(long = "image_list_path")]
90    pub image_list_path: Option<PathBuf>,
91    /// 카메라 모델 이름.
92    #[arg(long = "ImageReader.camera_model", default_value = "SIMPLE_RADIAL")]
93    pub camera_model: String,
94    /// 모든 영상이 카메라 하나를 공유.
95    #[arg(long = "ImageReader.single_camera", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
96    pub single_camera: bool,
97    /// 폴더마다 카메라 하나.
98    #[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    /// 기존 카메라 id 사용(≥ 0 일 때).
101    #[arg(long = "ImageReader.existing_camera_id")]
102    pub existing_camera_id: Option<i64>,
103    /// 카메라 파라미터 직접 지정(쉼표 구분).
104    #[arg(long = "ImageReader.camera_params")]
105    pub camera_params: Option<String>,
106    /// EXIF 초점이 없을 때 초점 = 계수 × max(w, h).
107    #[arg(long = "ImageReader.default_focal_length_factor", default_value_t = 1.2)]
108    pub default_focal_length_factor: f64,
109    /// 영상당 최대 특징 수.
110    #[arg(long = "SiftExtraction.max_num_features", default_value_t = 8192)]
111    pub max_num_features: usize,
112    /// SIFT 입력 최대 크기(픽셀).
113    #[arg(long = "SiftExtraction.max_image_size", default_value_t = 3200)]
114    pub max_image_size: usize,
115    /// GPU 사용 여부(받기만 하고 무시).
116    #[arg(long = "FeatureExtraction.use_gpu", value_parser = parse_flag)]
117    pub use_gpu: Option<bool>,
118    /// GPU 사용 여부(받기만 하고 무시).
119    #[arg(long = "SiftExtraction.use_gpu", value_parser = parse_flag)]
120    pub sift_use_gpu: Option<bool>,
121}
122
123/// `matches_importer` 인자.
124#[derive(Args, Debug)]
125pub struct MatchesImporterArgs {
126    /// 특징 저장소 파일 경로.
127    #[arg(long = "database_path")]
128    pub database_path: PathBuf,
129    /// 짝 목록 파일(줄마다 `이름1 이름2`).
130    #[arg(long = "match_list_path")]
131    pub match_list_path: PathBuf,
132    /// 매칭 종류(`pairs` 만 지원).
133    #[arg(long = "match_type", default_value = "pairs")]
134    pub match_type: String,
135    /// GPU 사용 여부(받기만 하고 무시).
136    #[arg(long = "FeatureMatching.use_gpu", value_parser = parse_flag)]
137    pub use_gpu: Option<bool>,
138    /// GPU 사용 여부(받기만 하고 무시).
139    #[arg(long = "SiftMatching.use_gpu", value_parser = parse_flag)]
140    pub sift_use_gpu: Option<bool>,
141    /// 짝당 최대 매칭 수.
142    #[arg(long = "SiftMatching.max_num_matches", default_value_t = 32768)]
143    pub max_num_matches: usize,
144}
145
146/// `global_mapper` 인자.
147#[derive(Args, Debug)]
148pub struct GlobalMapperArgs {
149    /// 특징 저장소 파일 경로.
150    #[arg(long = "database_path")]
151    pub database_path: PathBuf,
152    /// 영상 폴더(주면 점 색 추출).
153    #[arg(long = "image_path")]
154    pub image_path: Option<PathBuf>,
155    /// 출력 폴더(모델은 `0/` 아래).
156    #[arg(long = "output_path")]
157    pub output_path: PathBuf,
158    /// 전역 BA 반복 횟수.
159    #[arg(long = "GlobalMapper.ba_num_iterations")]
160    pub ba_num_iterations: Option<usize>,
161    /// 재삼각측량 생략.
162    #[arg(long = "GlobalMapper.skip_retriangulation", value_parser = parse_flag)]
163    pub skip_retriangulation: Option<bool>,
164    /// 유지할 최대 트랙 수.
165    #[arg(long = "GlobalMapper.keep_max_num_tracks")]
166    pub keep_max_num_tracks: Option<usize>,
167}
168
169/// `image_registrator` 인자.
170#[derive(Args, Debug)]
171pub struct ImageRegistratorArgs {
172    /// 특징 저장소 파일 경로.
173    #[arg(long = "database_path")]
174    pub database_path: PathBuf,
175    /// 입력 모델 폴더.
176    #[arg(long = "input_path")]
177    pub input_path: PathBuf,
178    /// 출력 모델 폴더.
179    #[arg(long = "output_path")]
180    pub output_path: PathBuf,
181}
182
183/// `point_triangulator` 인자.
184#[derive(Args, Debug)]
185pub struct PointTriangulatorArgs {
186    /// 특징 저장소 파일 경로.
187    #[arg(long = "database_path")]
188    pub database_path: PathBuf,
189    /// 영상 폴더(주면 점 색 추출).
190    #[arg(long = "image_path")]
191    pub image_path: Option<PathBuf>,
192    /// 입력 모델 폴더.
193    #[arg(long = "input_path")]
194    pub input_path: PathBuf,
195    /// 출력 모델 폴더.
196    #[arg(long = "output_path")]
197    pub output_path: PathBuf,
198    /// 기존 3D 점을 지우고 다시 삼각측량.
199    #[arg(long = "clear_points", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
200    pub clear_points: bool,
201}
202
203/// `bundle_adjuster` 인자.
204#[derive(Args, Debug)]
205pub struct BundleAdjusterArgs {
206    /// 입력 모델 폴더.
207    #[arg(long = "input_path")]
208    pub input_path: PathBuf,
209    /// 출력 모델 폴더.
210    #[arg(long = "output_path")]
211    pub output_path: PathBuf,
212    /// 최대 반복 수.
213    #[arg(long = "BundleAdjustment.max_num_iterations", default_value_t = 100)]
214    pub max_num_iterations: usize,
215    /// 초점 거리 정제.
216    #[arg(long = "BundleAdjustment.refine_focal_length", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
217    pub refine_focal_length: bool,
218    /// 주점 정제.
219    #[arg(long = "BundleAdjustment.refine_principal_point", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
220    pub refine_principal_point: bool,
221    /// 왜곡 등 추가 파라미터 정제.
222    #[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/// `model_aligner` 인자.
227#[derive(Args, Debug)]
228pub struct ModelAlignerArgs {
229    /// 입력 모델 폴더.
230    #[arg(long = "input_path")]
231    pub input_path: PathBuf,
232    /// 출력 모델 폴더.
233    #[arg(long = "output_path")]
234    pub output_path: PathBuf,
235    /// 기준 GPS 파일(줄: `이름 위도 경도 고도`).
236    #[arg(long = "ref_images_path")]
237    pub ref_images_path: PathBuf,
238    /// 기준이 GPS 인지(1 만 지원).
239    #[arg(long = "ref_is_gps", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
240    pub ref_is_gps: bool,
241    /// 정렬 좌표계(`enu` 만 지원).
242    #[arg(long = "alignment_type", default_value = "enu")]
243    pub alignment_type: String,
244    /// 견고 추정 최대 오차(미터, 0 = 견고 추정 없음).
245    #[arg(long = "alignment_max_error", default_value_t = 0.0)]
246    pub alignment_max_error: f64,
247    /// 정렬에 필요한 최소 공통 영상 수.
248    #[arg(long = "min_common_images", default_value_t = 3)]
249    pub min_common_images: usize,
250}
251
252/// `model_analyzer` 인자.
253#[derive(Args, Debug)]
254pub struct ModelAnalyzerArgs {
255    /// 모델 폴더.
256    #[arg(long = "path")]
257    pub path: PathBuf,
258    /// 자세한 통계 출력.
259    #[arg(long = "verbose", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
260    pub verbose: bool,
261}
262
263/// `model_converter` 인자.
264#[derive(Args, Debug)]
265pub struct ModelConverterArgs {
266    /// 입력 모델 폴더.
267    #[arg(long = "input_path")]
268    pub input_path: PathBuf,
269    /// 출력 폴더 또는 파일.
270    #[arg(long = "output_path")]
271    pub output_path: PathBuf,
272    /// 출력 형식(BIN|TXT|PLY).
273    #[arg(long = "output_type")]
274    pub output_type: String,
275}
276
277/// `image_deleter` 인자.
278#[derive(Args, Debug)]
279pub struct ImageDeleterArgs {
280    /// 입력 모델 폴더.
281    #[arg(long = "input_path")]
282    pub input_path: PathBuf,
283    /// 출력 모델 폴더.
284    #[arg(long = "output_path")]
285    pub output_path: PathBuf,
286    /// 지울 영상 id 목록 파일.
287    #[arg(long = "image_ids_path")]
288    pub image_ids_path: Option<PathBuf>,
289    /// 지울 영상 이름 목록 파일.
290    #[arg(long = "image_names_path")]
291    pub image_names_path: Option<PathBuf>,
292}
293
294/// `image_undistorter` 인자.
295#[derive(Args, Debug)]
296pub struct ImageUndistorterArgs {
297    /// 입력 영상 폴더.
298    #[arg(long = "image_path")]
299    pub image_path: PathBuf,
300    /// 입력 모델 폴더.
301    #[arg(long = "input_path")]
302    pub input_path: PathBuf,
303    /// 출력 작업 폴더.
304    #[arg(long = "output_path")]
305    pub output_path: PathBuf,
306    /// 출력 형식(이 값만 지원).
307    #[arg(long = "output_type", default_value = "COLMAP")]
308    pub output_type: String,
309    /// 왜곡 보정 영상 최대 크기(-1 = 제한 없음).
310    #[arg(long = "max_image_size", default_value_t = -1)]
311    pub max_image_size: i64,
312}
313
314/// `densify` 인자.
315#[derive(Args, Debug)]
316pub struct DensifyArgs {
317    /// image_undistorter 출력 폴더(images/, sparse/). `--image_path` 를 주면 대신 왜곡 있는 모델(`--input_path`)을 메모리에서 보정.
318    #[arg(long = "input_path", short = 'i')]
319    pub input_path: PathBuf,
320    /// 출력 PLY.
321    #[arg(long = "output_path", short = 'o')]
322    pub output_path: PathBuf,
323    /// 주면 `input_path` 를 왜곡 있는 입력 모델로 보고 이 폴더의 영상을 메모리에서 보정한다.
324    #[arg(long = "image_path")]
325    pub image_path: Option<PathBuf>,
326    /// 왜곡 보정 최대 크기(메모리 보정 경로).
327    #[arg(long = "max_image_size", default_value_t = 960)]
328    pub max_image_size: i64,
329    /// 기준 뷰당 원천 뷰 수(≤ 32).
330    #[arg(long = "number-views", default_value_t = 10)]
331    pub number_views: usize,
332    /// PatchMatch 백엔드(cuda).
333    #[arg(long = "pm-backend", default_value = "cuda")]
334    pub pm_backend: String,
335    /// 프로파일(fast|quality).
336    #[arg(long = "mvs-profile", default_value = "fast")]
337    pub mvs_profile: String,
338    /// 비용 창 반경(기본: 프로파일 값 5).
339    #[arg(long = "window-radius")]
340    pub window_radius: Option<i32>,
341    /// 비용 창 표본 간격 1|2(기본: fast 2, quality 1).
342    #[arg(long = "window-step")]
343    pub window_step: Option<i32>,
344    /// 스케일 수 상한.
345    #[arg(long = "max-levels")]
346    pub max_levels: Option<usize>,
347    /// 등록 순서로 앞 N 장만(`--image_path` 사용 시).
348    #[arg(long = "max-images")]
349    pub max_images: Option<usize>,
350    /// 융합 방식(consistency|traversal|score).
351    #[arg(long = "fusion-mode")]
352    pub fusion_mode: Option<String>,
353    /// 일치 융합: 기준 외 일치 뷰 최소 수.
354    #[arg(long = "fusion-min-views")]
355    pub fusion_min_views: Option<usize>,
356    /// 융합 상대 깊이 허용치.
357    #[arg(long = "fusion-depth-error")]
358    pub fusion_depth_error: Option<f64>,
359    /// 융합 법선 허용 각(도).
360    #[arg(long = "fusion-normal-error")]
361    pub fusion_normal_error: Option<f64>,
362    /// 융합 재투영 허용치(픽셀).
363    #[arg(long = "fusion-reproj-error")]
364    pub fusion_reproj_error: Option<f64>,
365    /// 일치 융합 남은 픽셀 처리(none|release|second-pass).
366    #[arg(long = "fusion-residual")]
367    pub fusion_residual: Option<String>,
368    /// 2차 융합: 기준 외 일치 뷰 최소 수(기본 2).
369    #[arg(long = "residual-min-views")]
370    pub residual_min_views: Option<usize>,
371    /// 2차 융합: 상대 깊이 허용치(기본 1차의 0.5배).
372    #[arg(long = "residual-depth-error")]
373    pub residual_depth_error: Option<f64>,
374    /// 2차 융합: 법선 허용 각(도, 기본 1차의 0.67배).
375    #[arg(long = "residual-normal-error")]
376    pub residual_normal_error: Option<f64>,
377    /// 2차 융합: 1차 점과 이 거리(GSD 배수, 기본 0.5) 이내인 2차 점은 버림.
378    #[arg(long = "residual-min-dist")]
379    pub residual_min_dist: Option<f64>,
380    /// 일치 융합: 쓰인 픽셀 표시(1|0).
381    #[arg(long = "fusion-mark-used", value_parser = parse_flag)]
382    pub fusion_mark_used: Option<bool>,
383    /// 일치 융합: 표시 반경(픽셀).
384    #[arg(long = "fusion-mark-radius")]
385    pub fusion_mark_radius: Option<usize>,
386    /// 일치 융합: 검사할 겹침 뷰 수.
387    #[arg(long = "fusion-num-images")]
388    pub fusion_num_images: Option<usize>,
389    /// 모든 스케일의 기하 실행 반복 수 덮어쓰기.
390    #[arg(long = "geo-iters")]
391    pub geo_iters: Option<u32>,
392    /// 기하 실행 회차 수 덮어쓰기.
393    #[arg(long = "geo-rounds")]
394    pub geo_rounds: Option<u32>,
395    /// 일치 융합: 역분산 가중(1|0).
396    #[arg(long = "fusion-inverse-variance", value_parser = parse_flag)]
397    pub fusion_inverse_variance: Option<bool>,
398    /// 깊이맵 필터: 일치 뷰 최소 수.
399    #[arg(long = "filter-min-views")]
400    pub filter_min_views: Option<u32>,
401    /// 깊이맵 필터: 순·역 재투영 허용치(픽셀).
402    #[arg(long = "filter-geom-error")]
403    pub filter_geom_error: Option<f32>,
404    /// 깊이맵 필터: 최소 NCC.
405    #[arg(long = "filter-min-ncc")]
406    pub filter_min_ncc: Option<f32>,
407    /// 작은 조각 제거(1|0).
408    #[arg(long = "remove-speckles", value_parser = parse_flag)]
409    pub remove_speckles: Option<bool>,
410    /// 경계 인식 틈 메우기(1|0).
411    #[arg(long = "fill-holes", value_parser = parse_flag)]
412    pub fill_holes: Option<bool>,
413    /// 이웃 선택 방향 다양성 감쇠(1 = 끔).
414    #[arg(long = "diversity-decay")]
415    pub diversity_decay: Option<f64>,
416    /// 거친 스케일 후보를 더할 기준 패치 분산 문턱(0 = 끔).
417    #[arg(long = "weak-texture-var")]
418    pub weak_texture_var: Option<f32>,
419    /// 판독 전 5×5 중앙값 평면 필터(1|0).
420    #[arg(long = "median-filter", value_parser = parse_flag)]
421    pub median_filter: Option<bool>,
422    /// 결과 점군 통계(간격·이상점·중복) 출력.
423    #[arg(long = "stats")]
424    pub stats: bool,
425    /// 점수 융합: 채택 문턱 τ(기본 2.0).
426    #[arg(long = "score-tau")]
427    pub score_tau: Option<f64>,
428    /// 점수 융합: 재투영 오차 가중 σ_e(픽셀, 기본 1.0).
429    #[arg(long = "score-sigma-e")]
430    pub score_sigma_e: Option<f64>,
431    /// 점수 융합: 시선 사잇각 가중 σ_θ(도, 기본 2.0).
432    #[arg(long = "score-sigma-theta")]
433    pub score_sigma_theta: Option<f64>,
434    /// 점수 융합: 자유공간 위반 벌점 계수 λ(기본 1.0).
435    #[arg(long = "score-lambda")]
436    pub score_lambda: Option<f64>,
437    /// 2차 융합 점만 따로 쓸 PLY(`--fusion-residual second-pass` 일 때).
438    #[arg(long = "residual-out")]
439    pub residual_out: Option<PathBuf>,
440    /// 융합 변형 파일(줄마다 `이름|융합 플래그들`). 깊이맵은 한 번만 만들고 변형마다 `<output_path 폴더>/<이름>.ply` 를 쓴다.
441    #[arg(long = "fusion-variants")]
442    pub fusion_variants: Option<PathBuf>,
443}
444
445/// 조밀화 하위 명령 옵션을 설정에 반영.
446pub(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
555/// 점수 융합 설정. `--fusion-mode score` 면 `o.fusion`(다른 융합 플래그 반영 후)의 허용치를 이어받고 `--score-*` 를 덮어쓴다.
556/// `base` 는 융합 변형에서 densify 명령 자체의 인자: 변형 줄에 `--fusion-mode` 가 없으면 그 방식을 따르고, 그 `--score-*` 를 먼저 반영한다.
557pub(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
594/// 영상 폴더에서 3D 점 색 추출(모델을 쓰는 명령들의 공통 마무리 단계).
595pub 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
626/// 하위 명령 하나를 실행한다.
627pub 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            // 변형 파일은 깊이 추정 전에 검사한다.
820            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}