Skip to main content

cumulus3d_cli/
interop.rs

1/*
2 * interop.rs
3 *
4 * Copyright (c) 2026 ParkSangWoo
5 *
6 * Author(s):
7 *
8 *      ParkSangWoo <dev.parksangwoo@gmail.com>
9 *
10 *
11 * This file is part of cumulus3d.
12 *
13 * Licensed under the Apache License, Version 2.0 (the "License");
14 * you may not use this file except in compliance with the License.
15 * You may obtain a copy of the License at
16 *
17 *      http://www.apache.org/licenses/LICENSE-2.0
18 *
19 * Unless required by applicable law or agreed to in writing, software
20 * distributed under the License is distributed on an "AS IS" BASIS,
21 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 * See the License for the specific language governing permissions and
23 * limitations under the License.
24 *
25 * Third-party notices: parts of the algorithms, default parameters and data
26 * formats in this file follow other open-source projects. Their copyright
27 * notices and licenses are reproduced in THIRD_PARTY_NOTICES.md.
28 *
29 * SPDX-License-Identifier: Apache-2.0
30 */
31
32//! Stage-by-stage subcommands compatible with an external SfM tool (COLMAP) (thin wrappers). Subcommand names and option strings follow
33//! that tool's command line.
34//! State between stages is carried by `--database_path` (a FeatureStore binary file) and model folders ([`cumulus3d_core::interop`]
35//! format).
36//! GPU-related options (`--FeatureExtraction.use_gpu` etc.) are accepted and ignored.
37
38use 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
62/// Accepts `1/0/true/false` (command-line convention).
63pub 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/// Stage subcommands (command names use the underscore form such as `feature_extractor`).
72#[derive(Subcommand, Debug)]
73pub enum InteropCmd {
74    /// Image folder -> feature store (SIFT extraction, camera initialization).
75    #[command(name = "feature_extractor")]
76    FeatureExtractor(FeatureExtractorArgs),
77    /// Pair list file -> descriptor matching + two-view geometry verification.
78    #[command(name = "matches_importer")]
79    MatchesImporter(MatchesImporterArgs),
80    /// Feature store -> global SfM model (`output_path/0`).
81    #[command(name = "global_mapper")]
82    GlobalMapper(GlobalMapperArgs),
83    /// Register images not yet registered to an existing model.
84    #[command(name = "image_registrator")]
85    ImageRegistrator(ImageRegistratorArgs),
86    /// Triangulate 3D points with the registered poses.
87    #[command(name = "point_triangulator")]
88    PointTriangulator(PointTriangulatorArgs),
89    /// Model bundle adjustment.
90    #[command(name = "bundle_adjuster")]
91    BundleAdjuster(BundleAdjusterArgs),
92    /// Align the model to the GPS ENU frame.
93    #[command(name = "model_aligner")]
94    ModelAligner(ModelAlignerArgs),
95    /// Print model statistics.
96    #[command(name = "model_analyzer")]
97    ModelAnalyzer(ModelAnalyzerArgs),
98    /// Model format conversion (BIN/TXT/PLY).
99    #[command(name = "model_converter")]
100    ModelConverter(ModelConverterArgs),
101    /// Delete images from a model.
102    #[command(name = "image_deleter")]
103    ImageDeleter(ImageDeleterArgs),
104    /// Create the undistortion work folder.
105    #[command(name = "image_undistorter")]
106    ImageUndistorter(ImageUndistorterArgs),
107    /// Densification: image_undistorter output folder -> dense.ply.
108    #[command(name = "densify")]
109    Densify(Box<DensifyArgs>),
110}
111
112/// Arguments of `feature_extractor`.
113#[derive(Args, Debug)]
114pub struct FeatureExtractorArgs {
115    /// Feature store file path (created if missing).
116    #[arg(long = "database_path")]
117    pub database_path: PathBuf,
118    /// Input image folder.
119    #[arg(long = "image_path")]
120    pub image_path: PathBuf,
121    /// File listing the image names to process (the whole folder if absent).
122    #[arg(long = "image_list_path")]
123    pub image_list_path: Option<PathBuf>,
124    /// Camera model name.
125    #[arg(long = "ImageReader.camera_model", default_value = "SIMPLE_RADIAL")]
126    pub camera_model: String,
127    /// All images share a single camera.
128    #[arg(long = "ImageReader.single_camera", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
129    pub single_camera: bool,
130    /// One camera per folder.
131    #[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    /// Use an existing camera id (when >= 0).
134    #[arg(long = "ImageReader.existing_camera_id")]
135    pub existing_camera_id: Option<i64>,
136    /// Specify camera parameters directly (comma separated).
137    #[arg(long = "ImageReader.camera_params")]
138    pub camera_params: Option<String>,
139    /// Focal length = factor x max(w, h) when there is no EXIF focal length.
140    #[arg(long = "ImageReader.default_focal_length_factor", default_value_t = 1.2)]
141    pub default_focal_length_factor: f64,
142    /// Maximum number of features per image.
143    #[arg(long = "SiftExtraction.max_num_features", default_value_t = 8192)]
144    pub max_num_features: usize,
145    /// Maximum SIFT input size (pixels).
146    #[arg(long = "SiftExtraction.max_image_size", default_value_t = 3200)]
147    pub max_image_size: usize,
148    /// Whether to use the GPU (accepted and ignored).
149    #[arg(long = "FeatureExtraction.use_gpu", value_parser = parse_flag)]
150    pub use_gpu: Option<bool>,
151    /// Whether to use the GPU (accepted and ignored).
152    #[arg(long = "SiftExtraction.use_gpu", value_parser = parse_flag)]
153    pub sift_use_gpu: Option<bool>,
154}
155
156/// Arguments of `matches_importer`.
157#[derive(Args, Debug)]
158pub struct MatchesImporterArgs {
159    /// Feature store file path.
160    #[arg(long = "database_path")]
161    pub database_path: PathBuf,
162    /// Pair list file (`name1 name2` per line).
163    #[arg(long = "match_list_path")]
164    pub match_list_path: PathBuf,
165    /// Matching type (only `pairs` is supported).
166    #[arg(long = "match_type", default_value = "pairs")]
167    pub match_type: String,
168    /// Whether to use the GPU (accepted and ignored).
169    #[arg(long = "FeatureMatching.use_gpu", value_parser = parse_flag)]
170    pub use_gpu: Option<bool>,
171    /// Whether to use the GPU (accepted and ignored).
172    #[arg(long = "SiftMatching.use_gpu", value_parser = parse_flag)]
173    pub sift_use_gpu: Option<bool>,
174    /// Maximum number of matches per pair.
175    #[arg(long = "SiftMatching.max_num_matches", default_value_t = 32768)]
176    pub max_num_matches: usize,
177}
178
179/// Arguments of `global_mapper`.
180#[derive(Args, Debug)]
181pub struct GlobalMapperArgs {
182    /// Feature store file path.
183    #[arg(long = "database_path")]
184    pub database_path: PathBuf,
185    /// Image folder (if given, point colors are extracted).
186    #[arg(long = "image_path")]
187    pub image_path: Option<PathBuf>,
188    /// Output folder (the model goes under `0/`).
189    #[arg(long = "output_path")]
190    pub output_path: PathBuf,
191    /// Number of global BA iterations.
192    #[arg(long = "GlobalMapper.ba_num_iterations")]
193    pub ba_num_iterations: Option<usize>,
194    /// Skip re-triangulation.
195    #[arg(long = "GlobalMapper.skip_retriangulation", value_parser = parse_flag)]
196    pub skip_retriangulation: Option<bool>,
197    /// Maximum number of tracks to keep.
198    #[arg(long = "GlobalMapper.keep_max_num_tracks")]
199    pub keep_max_num_tracks: Option<usize>,
200}
201
202/// Arguments of `image_registrator`.
203#[derive(Args, Debug)]
204pub struct ImageRegistratorArgs {
205    /// Feature store file path.
206    #[arg(long = "database_path")]
207    pub database_path: PathBuf,
208    /// Input model folder.
209    #[arg(long = "input_path")]
210    pub input_path: PathBuf,
211    /// Output model folder.
212    #[arg(long = "output_path")]
213    pub output_path: PathBuf,
214}
215
216/// Arguments of `point_triangulator`.
217#[derive(Args, Debug)]
218pub struct PointTriangulatorArgs {
219    /// Feature store file path.
220    #[arg(long = "database_path")]
221    pub database_path: PathBuf,
222    /// Image folder (if given, point colors are extracted).
223    #[arg(long = "image_path")]
224    pub image_path: Option<PathBuf>,
225    /// Input model folder.
226    #[arg(long = "input_path")]
227    pub input_path: PathBuf,
228    /// Output model folder.
229    #[arg(long = "output_path")]
230    pub output_path: PathBuf,
231    /// Delete existing 3D points and triangulate again.
232    #[arg(long = "clear_points", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
233    pub clear_points: bool,
234}
235
236/// Arguments of `bundle_adjuster`.
237#[derive(Args, Debug)]
238pub struct BundleAdjusterArgs {
239    /// Input model folder.
240    #[arg(long = "input_path")]
241    pub input_path: PathBuf,
242    /// Output model folder.
243    #[arg(long = "output_path")]
244    pub output_path: PathBuf,
245    /// Maximum number of iterations.
246    #[arg(long = "BundleAdjustment.max_num_iterations", default_value_t = 100)]
247    pub max_num_iterations: usize,
248    /// Refine the focal length.
249    #[arg(long = "BundleAdjustment.refine_focal_length", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
250    pub refine_focal_length: bool,
251    /// Refine the principal point.
252    #[arg(long = "BundleAdjustment.refine_principal_point", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
253    pub refine_principal_point: bool,
254    /// Refine extra parameters such as distortion.
255    #[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/// Arguments of `model_aligner`.
260#[derive(Args, Debug)]
261pub struct ModelAlignerArgs {
262    /// Input model folder.
263    #[arg(long = "input_path")]
264    pub input_path: PathBuf,
265    /// Output model folder.
266    #[arg(long = "output_path")]
267    pub output_path: PathBuf,
268    /// Reference GPS file (line: `name latitude longitude altitude`).
269    #[arg(long = "ref_images_path")]
270    pub ref_images_path: PathBuf,
271    /// Whether the reference is GPS (only 1 is supported).
272    #[arg(long = "ref_is_gps", default_value = "1", value_parser = parse_flag, action = clap::ArgAction::Set)]
273    pub ref_is_gps: bool,
274    /// Alignment frame (only `enu` is supported).
275    #[arg(long = "alignment_type", default_value = "enu")]
276    pub alignment_type: String,
277    /// Maximum error of the robust estimate (meters, 0 = no robust estimation).
278    #[arg(long = "alignment_max_error", default_value_t = 0.0)]
279    pub alignment_max_error: f64,
280    /// Minimum number of common images required for alignment.
281    #[arg(long = "min_common_images", default_value_t = 3)]
282    pub min_common_images: usize,
283}
284
285/// Arguments of `model_analyzer`.
286#[derive(Args, Debug)]
287pub struct ModelAnalyzerArgs {
288    /// Model folder.
289    #[arg(long = "path")]
290    pub path: PathBuf,
291    /// Print detailed statistics.
292    #[arg(long = "verbose", default_value = "0", value_parser = parse_flag, action = clap::ArgAction::Set)]
293    pub verbose: bool,
294}
295
296/// Arguments of `model_converter`.
297#[derive(Args, Debug)]
298pub struct ModelConverterArgs {
299    /// Input model folder.
300    #[arg(long = "input_path")]
301    pub input_path: PathBuf,
302    /// Output folder or file.
303    #[arg(long = "output_path")]
304    pub output_path: PathBuf,
305    /// Output format (BIN|TXT|PLY).
306    #[arg(long = "output_type")]
307    pub output_type: String,
308}
309
310/// Arguments of `image_deleter`.
311#[derive(Args, Debug)]
312pub struct ImageDeleterArgs {
313    /// Input model folder.
314    #[arg(long = "input_path")]
315    pub input_path: PathBuf,
316    /// Output model folder.
317    #[arg(long = "output_path")]
318    pub output_path: PathBuf,
319    /// File listing the image ids to delete.
320    #[arg(long = "image_ids_path")]
321    pub image_ids_path: Option<PathBuf>,
322    /// File listing the image names to delete.
323    #[arg(long = "image_names_path")]
324    pub image_names_path: Option<PathBuf>,
325}
326
327/// Arguments of `image_undistorter`.
328#[derive(Args, Debug)]
329pub struct ImageUndistorterArgs {
330    /// Input image folder.
331    #[arg(long = "image_path")]
332    pub image_path: PathBuf,
333    /// Input model folder.
334    #[arg(long = "input_path")]
335    pub input_path: PathBuf,
336    /// Output work folder.
337    #[arg(long = "output_path")]
338    pub output_path: PathBuf,
339    /// Output format (only this value is supported).
340    #[arg(long = "output_type", default_value = "COLMAP")]
341    pub output_type: String,
342    /// Maximum size of undistorted images (-1 = unlimited).
343    #[arg(long = "max_image_size", default_value_t = -1)]
344    pub max_image_size: i64,
345}
346
347/// Arguments of `densify`.
348#[derive(Args, Debug)]
349pub struct DensifyArgs {
350    /// image_undistorter output folder (images/, sparse/). If `--image_path` is given, the model with distortion (`--input_path`) is
351    /// undistorted in memory instead.
352    #[arg(long = "input_path", short = 'i')]
353    pub input_path: PathBuf,
354    /// Output PLY.
355    #[arg(long = "output_path", short = 'o')]
356    pub output_path: PathBuf,
357    /// If given, `input_path` is treated as an input model with distortion and the images in this folder are undistorted in memory.
358    #[arg(long = "image_path")]
359    pub image_path: Option<PathBuf>,
360    /// Maximum undistortion size (in-memory undistortion path).
361    #[arg(long = "max_image_size", default_value_t = 960)]
362    pub max_image_size: i64,
363    /// Number of source views per reference view (<= 32).
364    #[arg(long = "number-views", default_value_t = 10)]
365    pub number_views: usize,
366    /// PatchMatch backend (cuda).
367    #[arg(long = "pm-backend", default_value = "cuda")]
368    pub pm_backend: String,
369    /// Profile (fast|quality).
370    #[arg(long = "mvs-profile", default_value = "fast")]
371    pub mvs_profile: String,
372    /// Cost window radius (default: profile value 5).
373    #[arg(long = "window-radius")]
374    pub window_radius: Option<i32>,
375    /// Cost window sample stride 1|2 (default: fast 2, quality 1).
376    #[arg(long = "window-step")]
377    pub window_step: Option<i32>,
378    /// Upper bound on the number of scales.
379    #[arg(long = "max-levels")]
380    pub max_levels: Option<usize>,
381    /// Only the first N images in registration order (when using `--image_path`).
382    #[arg(long = "max-images")]
383    pub max_images: Option<usize>,
384    /// Fusion mode (consistency|traversal|score).
385    #[arg(long = "fusion-mode")]
386    pub fusion_mode: Option<String>,
387    /// Consistent fusion: minimum number of agreeing views besides the reference.
388    #[arg(long = "fusion-min-views")]
389    pub fusion_min_views: Option<usize>,
390    /// Fusion relative depth tolerance.
391    #[arg(long = "fusion-depth-error")]
392    pub fusion_depth_error: Option<f64>,
393    /// Fusion normal tolerance angle (degrees).
394    #[arg(long = "fusion-normal-error")]
395    pub fusion_normal_error: Option<f64>,
396    /// Fusion reprojection tolerance (pixels).
397    #[arg(long = "fusion-reproj-error")]
398    pub fusion_reproj_error: Option<f64>,
399    /// Handling of remaining pixels in consistent fusion (none|release|second-pass).
400    #[arg(long = "fusion-residual")]
401    pub fusion_residual: Option<String>,
402    /// Second-pass fusion: minimum number of agreeing views besides the reference (default 2).
403    #[arg(long = "residual-min-views")]
404    pub residual_min_views: Option<usize>,
405    /// Second-pass fusion: relative depth tolerance (default 0.5x the first pass).
406    #[arg(long = "residual-depth-error")]
407    pub residual_depth_error: Option<f64>,
408    /// Second-pass fusion: normal tolerance angle (degrees, default 0.67x the first pass).
409    #[arg(long = "residual-normal-error")]
410    pub residual_normal_error: Option<f64>,
411    /// Second-pass fusion: second-pass points within this distance of a first-pass point (GSD multiple, default 0.5) are discarded.
412    #[arg(long = "residual-min-dist")]
413    pub residual_min_dist: Option<f64>,
414    /// Consistent fusion: mark used pixels (1|0).
415    #[arg(long = "fusion-mark-used", value_parser = parse_flag)]
416    pub fusion_mark_used: Option<bool>,
417    /// Consistent fusion: marking radius (pixels).
418    #[arg(long = "fusion-mark-radius")]
419    pub fusion_mark_radius: Option<usize>,
420    /// Consistent fusion: number of overlapping views to check.
421    #[arg(long = "fusion-num-images")]
422    pub fusion_num_images: Option<usize>,
423    /// Override the number of geometric iterations at all scales.
424    #[arg(long = "geo-iters")]
425    pub geo_iters: Option<u32>,
426    /// Override the number of geometric run rounds.
427    #[arg(long = "geo-rounds")]
428    pub geo_rounds: Option<u32>,
429    /// Consistent fusion: inverse-variance weighting (1|0).
430    #[arg(long = "fusion-inverse-variance", value_parser = parse_flag)]
431    pub fusion_inverse_variance: Option<bool>,
432    /// Fused-cloud smoothing along normals: iterations (0 = off).
433    #[arg(long = "fusion-smooth-iters")]
434    pub fusion_smooth_iters: Option<usize>,
435    /// Fused-cloud smoothing: neighbour radius (GSD multiple).
436    #[arg(long = "fusion-smooth-radius")]
437    pub fusion_smooth_radius: Option<f64>,
438    /// Fused-cloud smoothing: range sigma along the normal (GSD multiple).
439    #[arg(long = "fusion-smooth-range")]
440    pub fusion_smooth_range: Option<f64>,
441    /// Fused-cloud smoothing: neighbour normal gate (degrees).
442    #[arg(long = "fusion-smooth-normal")]
443    pub fusion_smooth_normal: Option<f64>,
444    /// Free-space visibility filter: neighbouring views checked per fused point (0 = off).
445    #[arg(long = "visibility-views")]
446    pub visibility_views: Option<usize>,
447    /// Free-space visibility filter: relative depth tolerance.
448    #[arg(long = "visibility-tau")]
449    pub visibility_tau: Option<f64>,
450    /// Free-space visibility filter: minimum violating views to remove a point.
451    #[arg(long = "visibility-min-violations")]
452    pub visibility_min_violations: Option<usize>,
453    /// Depth map filter: minimum number of agreeing views.
454    #[arg(long = "filter-min-views")]
455    pub filter_min_views: Option<u32>,
456    /// Depth map filter: forward/backward reprojection tolerance (pixels).
457    #[arg(long = "filter-geom-error")]
458    pub filter_geom_error: Option<f32>,
459    /// Depth map filter: minimum NCC.
460    #[arg(long = "filter-min-ncc")]
461    pub filter_min_ncc: Option<f32>,
462    /// Remove small fragments (1|0).
463    #[arg(long = "remove-speckles", value_parser = parse_flag)]
464    pub remove_speckles: Option<bool>,
465    /// Boundary-aware gap filling (1|0).
466    #[arg(long = "fill-holes", value_parser = parse_flag)]
467    pub fill_holes: Option<bool>,
468    /// Direction-diversity damping of neighbor selection (1 = off).
469    #[arg(long = "diversity-decay")]
470    pub diversity_decay: Option<f64>,
471    /// Reference patch variance threshold for adding coarse-scale candidates (0 = off).
472    #[arg(long = "weak-texture-var")]
473    pub weak_texture_var: Option<f32>,
474    /// 5x5 median plane filter before readout (1|0).
475    #[arg(long = "median-filter", value_parser = parse_flag)]
476    pub median_filter: Option<bool>,
477    /// Print statistics of the resulting point cloud (spacing, outliers, duplicates).
478    #[arg(long = "stats")]
479    pub stats: bool,
480    /// Score fusion: acceptance threshold tau (default 2.0).
481    #[arg(long = "score-tau")]
482    pub score_tau: Option<f64>,
483    /// Score fusion: reprojection error weight sigma_e (pixels, default 1.0).
484    #[arg(long = "score-sigma-e")]
485    pub score_sigma_e: Option<f64>,
486    /// Score fusion: viewing-angle weight sigma_theta (degrees, default 2.0).
487    #[arg(long = "score-sigma-theta")]
488    pub score_sigma_theta: Option<f64>,
489    /// Score fusion: free-space violation penalty coefficient lambda (default 1.0).
490    #[arg(long = "score-lambda")]
491    pub score_lambda: Option<f64>,
492    /// PLY for the second-pass fusion points only (when `--fusion-residual second-pass`).
493    #[arg(long = "residual-out")]
494    pub residual_out: Option<PathBuf>,
495    /// Fusion variants file (`name|fusion flags` per line). The depth maps are built once and a `<output_path folder>/<name>.ply` is
496    /// written per variant.
497    #[arg(long = "fusion-variants")]
498    pub fusion_variants: Option<PathBuf>,
499}
500
501/// Apply the densify subcommand options to the settings.
502pub(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.set_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
632/// Score fusion settings. With `--fusion-mode score`, it inherits the tolerances of `o.fusion` (after the other fusion flags are applied)
633/// and overrides them with `--score-*`.
634/// `base` is the densify command's own arguments in a fusion variant: if the variant line has no `--fusion-mode`, that mode is followed,
635/// and its `--score-*` are applied first.
636pub(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
677/// Extract 3D point colors from the image folder (the common final step of the commands that use a model).
678pub 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
709/// Run one subcommand.
710pub 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            // Validate the variants file before depth estimation.
903            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}