Skip to main content

cumulus3d_cli/
fusion_variants.rs

1//! `densify --fusion-variants <파일>`: 깊이맵은 한 번만 만들고 융합 설정만 바꿔 여러 번 융합한다.
2//!
3//! 파일 형식: 줄마다 `이름|융합 플래그들`(densify 와 같은 형식, 공백 구분). 빈 줄과 `#` 로 시작하는 줄은 건너뛴다.
4//! 플래그는 densify 하위 명령의 인자 파서로 해석해 기본 설정 위에 덮어쓰므로, 새 융합 플래그도 그대로 쓸 수 있다.
5//! 깊이맵을 바꾸는 플래그(필터·PatchMatch·영상 크기 등)는 거부한다.
6
7use crate::interop::{apply_densify_args, score_options, DensifyArgs};
8use clap::Parser;
9use cumulus3d_dense::densify::fuse_output;
10use cumulus3d_dense::fusion_score::ScoreFusionOptions;
11use cumulus3d_dense::{DenseScene, DensifyOptions, DepthMapSet};
12use std::path::Path;
13use std::time::Instant;
14
15/// 융합 변형 하나.
16#[derive(Clone, Debug)]
17pub struct FusionVariant {
18    /// 변형 이름(출력 파일 이름에 쓴다).
19    pub name: String,
20    /// 원래 줄의 플래그 문자열(보고용).
21    pub flags: String,
22    /// 이 변형의 조밀화 옵션.
23    pub opts: DensifyOptions,
24    /// 점수 융합 설정(있으면 점수 융합).
25    pub score: Option<ScoreFusionOptions>,
26}
27
28#[derive(Parser, Debug)]
29#[command(no_binary_name = true)]
30struct VariantLine {
31    #[command(flatten)]
32    a: DensifyArgs,
33}
34
35fn parse_line(tokens: &[&str]) -> Result<DensifyArgs, String> {
36    let mut argv: Vec<&str> = vec!["--input_path", ".", "--output_path", "."];
37    argv.extend_from_slice(tokens);
38    VariantLine::try_parse_from(argv).map(|v| v.a).map_err(|e| e.to_string())
39}
40
41/// 변형 파일을 읽어 기본 설정(`base`, densify 명령 인자 `base_args` 를 반영한 것) 위에 각 줄의 플래그를 덮어쓴 설정 목록을 만든다.
42/// 줄에 `--fusion-mode` 가 없으면 명령의 융합 방식(점수 융합 포함)을 따른다.
43pub fn read_variants(path: &Path, base_args: &DensifyArgs, base: &DensifyOptions) -> Result<Vec<FusionVariant>, String> {
44    let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
45    // 플래그 없는 줄의 해석 결과: 설정 밖 인자(영상 크기 등)가 바뀌었는지 비교하는 기준.
46    let empty = parse_line(&[])?;
47    let mut out: Vec<FusionVariant> = Vec::new();
48    for (ln, raw) in text.lines().enumerate() {
49        let line = raw.trim();
50        if line.is_empty() || line.starts_with('#') {
51            continue;
52        }
53        let at = |m: String| format!("{}:{}: {m}", path.display(), ln + 1);
54        let (name, flags) = line.split_once('|').ok_or_else(|| at("`이름|플래그` 형식이 아님".into()))?;
55        let name = name.trim();
56        if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) || name.starts_with('.') {
57            return Err(at(format!("변형 이름 {name:?} (영숫자·_·-·. 만)")));
58        }
59        if out.iter().any(|v| v.name == name) {
60            return Err(at(format!("변형 이름 중복 {name}")));
61        }
62        let tokens: Vec<&str> = flags.split_whitespace().collect();
63        let a = parse_line(&tokens).map_err(at)?;
64        let outside = [
65            ("--max_image_size", a.max_image_size != empty.max_image_size),
66            ("--number-views", a.number_views != empty.number_views),
67            ("--pm-backend", a.pm_backend != empty.pm_backend),
68            ("--mvs-profile", a.mvs_profile != empty.mvs_profile),
69            ("--max-images", a.max_images != empty.max_images),
70            ("--image_path", a.image_path != empty.image_path),
71            ("--stats", a.stats != empty.stats),
72            ("--fusion-variants", a.fusion_variants != empty.fusion_variants),
73            ("--residual-out", a.residual_out != empty.residual_out),
74        ];
75        if let Some((f, _)) = outside.iter().find(|(_, changed)| *changed) {
76            return Err(at(format!("{f} 는 변형마다 바꿀 수 없음(융합 설정만)")));
77        }
78        let mut opts = base.clone();
79        apply_densify_args(&a, &mut opts).map_err(at)?;
80        let mut same = opts.clone();
81        same.fusion = base.fusion.clone();
82        if same != *base {
83            return Err(at("깊이맵을 바꾸는 플래그가 있음(융합 설정만 허용)".into()));
84        }
85        let score = score_options(&a, &opts, Some(base_args)).map_err(at)?;
86        out.push(FusionVariant { name: name.to_string(), flags: tokens.join(" "), opts, score });
87    }
88    if out.is_empty() {
89        return Err(format!("{}: 변형 없음", path.display()));
90    }
91    Ok(out)
92}
93
94/// 변형마다 융합해 `out_dir/<이름>.ply` 를 쓴다. 변형별 융합 시간을 출력하고, `stats` 면 점군 통계도 낸다.
95/// `out_dir/fusion_variants.tsv` 에 이름·점 수·융합 시간·플래그를 모은다.
96pub fn run_variants(scene: &DenseScene, maps: &DepthMapSet, variants: &[FusionVariant], out_dir: &Path, stats: bool) -> Result<(), String> {
97    std::fs::create_dir_all(out_dir).map_err(|e| format!("{}: {e}", out_dir.display()))?;
98    let mut tsv = String::from("name\tpoints\tresidual_points\tfusion_s\tpass1_s\tpass2_s\twrite_s\tflags\n");
99    for v in variants {
100        let out = fuse_output(scene, maps, &v.opts, v.score.as_ref());
101        let fusion_s = out.timings.fusion.as_secs_f64();
102        let path = out_dir.join(format!("{}.ply", v.name));
103        let tw = Instant::now();
104        out.write_ply(&path).map_err(|e| format!("{}: {e}", path.display()))?;
105        let rpath = out_dir.join(format!("{}.residual.ply", v.name));
106        let wrote_residual = out.write_residual_ply(&rpath).map_err(|e| format!("{}: {e}", rpath.display()))?;
107        let write_s = tw.elapsed().as_secs_f64();
108        let extra = if wrote_residual { format!(" (2차 점 {} → {})", out.num_residual(), rpath.display()) } else { String::new() };
109        let (p1, p2) = (out.timings.fusion_pass1.as_secs_f64(), out.timings.fusion_pass2.as_secs_f64());
110        println!("변형 {}: 점 {} 융합 {fusion_s:.2}s (1차 {p1:.2}s 2차 {p2:.2}s) 쓰기 {write_s:.2}s → {}{extra}", v.name, out.cloud.len(), path.display());
111        if stats {
112            let ts = Instant::now();
113            let st = cumulus3d_dense::cloud_stats(scene, &out.depth_maps, &out.cloud, &out.visibility, 200_000, 2.0);
114            println!(
115                "통계[{}]: 점 {} 융합 {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)",
116                v.name,
117                st.points,
118                st.nn_spacing_median,
119                st.gsd,
120                100.0 * st.outlier_ratio,
121                100.0 * st.plane_outlier_ratio,
122                st.plane_residual_median,
123                100.0 * st.duplicate_ratio,
124                ts.elapsed().as_secs_f64()
125            );
126        }
127        tsv.push_str(&format!("{}\t{}\t{}\t{fusion_s:.3}\t{p1:.3}\t{p2:.3}\t{write_s:.3}\t{}\n", v.name, out.cloud.len(), out.num_residual(), v.flags));
128    }
129    let p = out_dir.join("fusion_variants.tsv");
130    std::fs::write(&p, tsv).map_err(|e| format!("{}: {e}", p.display()))
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn write_tmp(name: &str, body: &str) -> std::path::PathBuf {
138        let p = std::env::temp_dir().join(format!("cumulus3d_fv_{}_{name}", std::process::id()));
139        std::fs::write(&p, body).unwrap();
140        p
141    }
142
143    fn args(t: &[&str]) -> DensifyArgs {
144        parse_line(t).unwrap()
145    }
146
147    #[test]
148    fn parses_fusion_flags_over_base() {
149        let base = DensifyOptions::default();
150        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");
151        let v = read_variants(&p, &args(&[]), &base).unwrap();
152        assert_eq!(v.len(), 3);
153        assert_eq!(v[0].opts.fusion.min_consistent_views, 3);
154        assert_eq!(v[0].opts.fusion.max_normal_error_deg, 30.0);
155        assert!(v[0].score.is_none());
156        assert_eq!(v[1].opts, base);
157        let s = v[2].score.as_ref().unwrap();
158        assert_eq!((s.tau, s.depth_error), (2.5, 0.02));
159        std::fs::remove_file(p).ok();
160    }
161
162    #[test]
163    fn inherits_score_mode_from_command() {
164        let base = DensifyOptions::default();
165        let cmd = args(&["--fusion-mode", "score", "--score-lambda", "0.5"]);
166        let p = write_tmp("inh", "a|--score-tau 3\nb|--fusion-mode consistency\n");
167        let v = read_variants(&p, &cmd, &base).unwrap();
168        let s = v[0].score.as_ref().unwrap();
169        assert_eq!((s.tau, s.lambda), (3.0, 0.5));
170        assert!(v[1].score.is_none());
171        std::fs::remove_file(p).ok();
172    }
173
174    #[test]
175    fn rejects_depth_flags_and_bad_names() {
176        let base = DensifyOptions::default();
177        for (n, body) in [("f", "a|--filter-min-views 1"), ("s", "a|--max_image_size 100"), ("n", "a b|--fusion-min-views 2"), ("d", "a|\na|"), ("u", "a|--nope 1"), ("t", "a|--score-tau 2")] {
178            let p = write_tmp(n, body);
179            assert!(read_variants(&p, &args(&[]), &base).is_err(), "{body}");
180            std::fs::remove_file(p).ok();
181        }
182    }
183}