Skip to main content

cumulus3d_cli/
fusion_variants.rs

1/*
2 * fusion_variants.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 * SPDX-License-Identifier: Apache-2.0
26 */
27
28//! `densify --fusion-variants <file>`: build the depth maps once and fuse several times, changing only the fusion settings.
29//!
30//! File format: one `name|fusion flags` per line (same format as densify, whitespace separated). Empty lines and lines starting with `#`
31//! are skipped.
32//! The flags are interpreted by the densify subcommand's argument parser and overlaid on the base settings, so new fusion flags can be
33//! used as they are.
34//! Flags that change the depth maps (filter, PatchMatch, image size, etc.) are rejected.
35
36use crate::interop::{apply_densify_args, score_options, DensifyArgs};
37use clap::Parser;
38use cumulus3d_dense::densify::fuse_output;
39use cumulus3d_dense::fusion_score::ScoreFusionOptions;
40use cumulus3d_dense::{DenseScene, DensifyOptions, DepthMapSet};
41use std::path::Path;
42use std::time::Instant;
43
44/// One fusion variant.
45#[derive(Clone, Debug)]
46pub struct FusionVariant {
47    /// Variant name (used in the output file name).
48    pub name: String,
49    /// Flag string of the original line (for reporting).
50    pub flags: String,
51    /// Densification options of this variant.
52    pub opts: DensifyOptions,
53    /// Score fusion settings (if present, score fusion).
54    pub score: Option<ScoreFusionOptions>,
55}
56
57#[derive(Parser, Debug)]
58#[command(no_binary_name = true)]
59struct VariantLine {
60    #[command(flatten)]
61    a: DensifyArgs,
62}
63
64fn parse_line(tokens: &[&str]) -> Result<DensifyArgs, String> {
65    let mut argv: Vec<&str> = vec!["--input_path", ".", "--output_path", "."];
66    argv.extend_from_slice(tokens);
67    VariantLine::try_parse_from(argv).map(|v| v.a).map_err(|e| e.to_string())
68}
69
70/// Read the variants file and build the settings list in which each line's flags are overlaid on the base settings (`base`, with the
71/// densify command arguments `base_args` applied).
72/// If a line has no `--fusion-mode`, it follows the command's fusion mode (including score fusion).
73pub fn read_variants(path: &Path, base_args: &DensifyArgs, base: &DensifyOptions) -> Result<Vec<FusionVariant>, String> {
74    let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
75    // Interpretation result of a line without flags: the reference for checking whether arguments outside the settings (image size etc.)
76    // changed.
77    let empty = parse_line(&[])?;
78    let mut out: Vec<FusionVariant> = Vec::new();
79    for (ln, raw) in text.lines().enumerate() {
80        let line = raw.trim();
81        if line.is_empty() || line.starts_with('#') {
82            continue;
83        }
84        let at = |m: String| format!("{}:{}: {m}", path.display(), ln + 1);
85        let (name, flags) = line.split_once('|').ok_or_else(|| at("`이름|플래그` 형식이 아님".into()))?;
86        let name = name.trim();
87        if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) || name.starts_with('.') {
88            return Err(at(format!("변형 이름 {name:?} (영숫자·_·-·. 만)")));
89        }
90        if out.iter().any(|v| v.name == name) {
91            return Err(at(format!("변형 이름 중복 {name}")));
92        }
93        let tokens: Vec<&str> = flags.split_whitespace().collect();
94        let a = parse_line(&tokens).map_err(at)?;
95        let outside = [
96            ("--max_image_size", a.max_image_size != empty.max_image_size),
97            ("--number-views", a.number_views != empty.number_views),
98            ("--pm-backend", a.pm_backend != empty.pm_backend),
99            ("--mvs-profile", a.mvs_profile != empty.mvs_profile),
100            ("--max-images", a.max_images != empty.max_images),
101            ("--image_path", a.image_path != empty.image_path),
102            ("--stats", a.stats != empty.stats),
103            ("--fusion-variants", a.fusion_variants != empty.fusion_variants),
104            ("--residual-out", a.residual_out != empty.residual_out),
105        ];
106        if let Some((f, _)) = outside.iter().find(|(_, changed)| *changed) {
107            return Err(at(format!("{f} 는 변형마다 바꿀 수 없음(융합 설정만)")));
108        }
109        let mut opts = base.clone();
110        apply_densify_args(&a, &mut opts).map_err(at)?;
111        let mut same = opts.clone();
112        same.fusion = base.fusion.clone();
113        if same != *base {
114            return Err(at("깊이맵을 바꾸는 플래그가 있음(융합 설정만 허용)".into()));
115        }
116        let score = score_options(&a, &opts, Some(base_args)).map_err(at)?;
117        out.push(FusionVariant { name: name.to_string(), flags: tokens.join(" "), opts, score });
118    }
119    if out.is_empty() {
120        return Err(format!("{}: 변형 없음", path.display()));
121    }
122    Ok(out)
123}
124
125/// Fuse per variant and write `out_dir/<name>.ply`. Print the fusion time per variant, and with `stats` also the point-cloud statistics.
126/// Collect name, point count, fusion time and flags in `out_dir/fusion_variants.tsv`.
127pub fn run_variants(scene: &DenseScene, maps: &DepthMapSet, variants: &[FusionVariant], out_dir: &Path, stats: bool) -> Result<(), String> {
128    std::fs::create_dir_all(out_dir).map_err(|e| format!("{}: {e}", out_dir.display()))?;
129    let mut tsv = String::from("name\tpoints\tresidual_points\tfusion_s\tpass1_s\tpass2_s\twrite_s\tflags\n");
130    for v in variants {
131        let out = fuse_output(scene, maps, &v.opts, v.score.as_ref());
132        let fusion_s = out.timings.fusion.as_secs_f64();
133        let path = out_dir.join(format!("{}.ply", v.name));
134        let tw = Instant::now();
135        out.write_ply(&path).map_err(|e| format!("{}: {e}", path.display()))?;
136        let rpath = out_dir.join(format!("{}.residual.ply", v.name));
137        let wrote_residual = out.write_residual_ply(&rpath).map_err(|e| format!("{}: {e}", rpath.display()))?;
138        let write_s = tw.elapsed().as_secs_f64();
139        let extra = if wrote_residual { format!(" (2차 점 {} → {})", out.num_residual(), rpath.display()) } else { String::new() };
140        let (p1, p2) = (out.timings.fusion_pass1.as_secs_f64(), out.timings.fusion_pass2.as_secs_f64());
141        let sm = out.timings.fusion_smooth.as_secs_f64();
142        let (vis, vis_n) = (out.timings.fusion_visibility.as_secs_f64(), out.timings.fusion_visibility_removed);
143        println!(
144            "변형 {}: 점 {} 융합 {fusion_s:.2}s (1차 {p1:.2}s 2차 {p2:.2}s 평활 {sm:.2}s, 가시성 {vis:.2}s, 제거 {vis_n}) 쓰기 {write_s:.2}s → {}{extra}",
145            v.name,
146            out.cloud.len(),
147            path.display()
148        );
149        if stats {
150            let ts = Instant::now();
151            let st = cumulus3d_dense::cloud_stats(scene, &out.depth_maps, &out.cloud, &out.visibility, 200_000, 2.0);
152            println!(
153                "통계[{}]: 점 {} 융합 {fusion_s:.2}s (1차 {p1:.2}s 2차 {p2:.2}s) 이웃 간격 중앙 {:.4} GSD {:.4} 이상점(2px) {:.2}% 평면 이탈(>GSD·고립) {:.2}% 평면 거리 중앙 {:.4} 중복(<GSD/2) {:.2}% ({:.1}s)",
154                v.name,
155                st.points,
156                st.nn_spacing_median,
157                st.gsd,
158                100.0 * st.outlier_ratio,
159                100.0 * st.plane_outlier_ratio,
160                st.plane_residual_median,
161                100.0 * st.duplicate_ratio,
162                ts.elapsed().as_secs_f64()
163            );
164        }
165        tsv.push_str(&format!(
166            "{}\t{}\t{}\t{fusion_s:.3}\t{p1:.3}\t{p2:.3}\t{write_s:.3}\t{}\n",
167            v.name,
168            out.cloud.len(),
169            out.num_residual(),
170            v.flags
171        ));
172    }
173    let p = out_dir.join("fusion_variants.tsv");
174    std::fs::write(&p, tsv).map_err(|e| format!("{}: {e}", p.display()))
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    fn write_tmp(name: &str, body: &str) -> std::path::PathBuf {
182        let p = std::env::temp_dir().join(format!("cumulus3d_fv_{}_{name}", std::process::id()));
183        std::fs::write(&p, body).unwrap();
184        p
185    }
186
187    fn args(t: &[&str]) -> DensifyArgs {
188        parse_line(t).unwrap()
189    }
190
191    #[test]
192    fn parses_fusion_flags_over_base() {
193        let base = DensifyOptions::default();
194        let p = write_tmp("ok", "# 주석\n\na|--fusion-min-views 3 --fusion-normal-error 30\nb|\nc|--fusion-mode score --score-tau 2.5 --fusion-depth-error 0.02\n");
195        let v = read_variants(&p, &args(&[]), &base).unwrap();
196        assert_eq!(v.len(), 3);
197        assert_eq!(v[0].opts.fusion.min_consistent_views, 3);
198        assert_eq!(v[0].opts.fusion.max_normal_error_deg, 30.0);
199        assert!(v[0].score.is_none());
200        assert_eq!(v[1].opts, base);
201        let s = v[2].score.as_ref().unwrap();
202        assert_eq!((s.tau, s.depth_error), (2.5, 0.02));
203        std::fs::remove_file(p).ok();
204    }
205
206    #[test]
207    fn inherits_score_mode_from_command() {
208        let base = DensifyOptions::default();
209        let cmd = args(&["--fusion-mode", "score", "--score-lambda", "0.5"]);
210        let p = write_tmp("inh", "a|--score-tau 3\nb|--fusion-mode consistency\n");
211        let v = read_variants(&p, &cmd, &base).unwrap();
212        let s = v[0].score.as_ref().unwrap();
213        assert_eq!((s.tau, s.lambda), (3.0, 0.5));
214        assert!(v[1].score.is_none());
215        std::fs::remove_file(p).ok();
216    }
217
218    #[test]
219    fn rejects_depth_flags_and_bad_names() {
220        let base = DensifyOptions::default();
221        for (n, body) in [
222            ("f", "a|--filter-min-views 1"),
223            ("s", "a|--max_image_size 100"),
224            ("n", "a b|--fusion-min-views 2"),
225            ("d", "a|\na|"),
226            ("u", "a|--nope 1"),
227            ("t", "a|--score-tau 2"),
228        ] {
229            let p = write_tmp(n, body);
230            assert!(read_variants(&p, &args(&[]), &base).is_err(), "{body}");
231            std::fs::remove_file(p).ok();
232        }
233    }
234}