use crate::blocks::LiveStream;
use crate::densewrap::{
make_ba_backend, make_match_backend, make_pm_backend, make_sift_backend, make_sparse_backend, make_undistort_backend, parse_profile,
DenseConfig,
};
use crate::events::{EventKind, Frame, ZoneRange};
use crate::intrinsics::IntrinsicsPrior;
use crate::pipeline::{Pipeline, QueuePolicy, Summary};
use crate::session::{zones_upto, Input, Session, SessionConfig};
use crate::sinks::{DefaultSinks, SinkSet};
use crate::stream::{frame_set_of, Layout, StreamConfig, CAMS};
use cumulus3d_core::io::ply::PointCloud;
use cumulus3d_core::io::{read_gps_file, GpsRecord};
use cumulus3d_dense::{DensifyOptions, DepthMapCache, FusionMode, FusionResidual};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
pub const DEFAULT_PRESET: &str = "aerial-formation";
pub const PRESETS: [&str; 1] = [DEFAULT_PRESET];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Plan {
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
pub threads: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub gps: Option<PathBuf>,
pub pairing: Pairing,
pub input: Source,
pub zones: Zones,
pub align: Align,
pub features: Features,
pub matching: Matching,
pub sparse: Sparse,
pub live: Live,
pub dense: Dense,
pub sinks: Sinks,
}
impl Default for Plan {
fn default() -> Self {
Self {
seed: None,
threads: 0,
gps: Some(PathBuf::from("data/gps_ref.txt")),
pairing: Pairing::Formation,
input: Source::default(),
zones: Zones::default(),
align: Align::default(),
features: Features::default(),
matching: Matching::default(),
sparse: Sparse::default(),
live: Live::default(),
dense: Dense::default(),
sinks: Sinks::default_files("out"),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Source {
pub images: PathBuf,
pub cameras: Vec<String>,
pub stride: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub positions: Option<usize>,
}
impl Default for Source {
fn default() -> Self {
Self { images: PathBuf::from("data/images"), cameras: CAMS.iter().map(|c| c.to_string()).collect(), stride: 3, positions: None }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Pairing {
#[default]
Formation,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Zones {
pub span: usize,
pub overlap: usize,
}
impl Default for Zones {
fn default() -> Self {
Self { span: 12, overlap: 2 }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AlignFrame {
#[default]
Enu,
None,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Align {
pub frame: AlignFrame,
pub fixed_origin: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Features {
pub backend: String,
pub max_num_features: usize,
pub max_image_size: usize,
}
impl Default for Features {
fn default() -> Self {
Self { backend: "cpu".into(), max_num_features: 8192, max_image_size: 3200 }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Matching {
pub backend: String,
}
impl Default for Matching {
fn default() -> Self {
Self { backend: "cpu".into() }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Sparse {
pub incremental_triangulation: bool,
pub backend: String,
pub ba_backend: String,
pub stable_display: bool,
pub settle_tol: f64,
pub settle_max_zone: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub intrinsics_prior: Option<PathBuf>,
pub intrinsics_prior_sigma: f64,
pub intrinsics_prior_dist_sigma: f64,
}
impl Default for Sparse {
fn default() -> Self {
Self {
incremental_triangulation: false,
backend: "cpu".into(),
ba_backend: "cpu".into(),
stable_display: false,
settle_tol: 0.01,
settle_max_zone: 3,
intrinsics_prior: None,
intrinsics_prior_sigma: crate::intrinsics::DEFAULT_FOCAL_SIGMA,
intrinsics_prior_dist_sigma: crate::intrinsics::DEFAULT_DIST_SIGMA,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Live {
pub lookahead: usize,
pub batch: usize,
pub preview: bool,
}
impl Live {
pub fn live_node() -> Self {
Self { lookahead: 4, batch: 1, preview: false }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Dense {
pub enabled: bool,
pub backend: String,
pub undistort_backend: String,
pub profile: String,
pub max_image_size: i64,
pub views: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_levels: Option<usize>,
pub serialize: bool,
pub depth_cache: bool,
pub fusion: Fusion,
pub filter: Filter,
}
impl Default for Dense {
fn default() -> Self {
Self {
enabled: true,
backend: "cuda".into(),
undistort_backend: "cpu".into(),
profile: "fast".into(),
max_image_size: 960,
views: 10,
max_levels: None,
serialize: false,
depth_cache: false,
fusion: Fusion::default(),
filter: Filter::default(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Fusion {
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_views: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub depth_error: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub normal_error: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reproj_error: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub residual: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smooth_iters: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smooth_radius: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smooth_range: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smooth_normal: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility_views: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility_tau: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility_min_violations: Option<usize>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Filter {
#[serde(skip_serializing_if = "Option::is_none")]
pub min_views: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub geom_error: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_ncc: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub median: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fill_holes: Option<bool>,
}
impl Dense {
pub fn profile(name: &str) -> Self {
Self { profile: name.to_string(), ..Self::default() }
}
pub fn off() -> Self {
Self { enabled: false, ..Self::default() }
}
pub fn backend(mut self, name: &str) -> Self {
self.backend = name.to_string();
self
}
pub fn undistort_backend(mut self, name: &str) -> Self {
self.undistort_backend = name.to_string();
self
}
pub fn max_image_size(mut self, n: i64) -> Self {
self.max_image_size = n;
self
}
pub fn views(mut self, n: usize) -> Self {
self.views = n;
self
}
pub fn serialize(mut self, on: bool) -> Self {
self.serialize = on;
self
}
pub fn depth_cache(mut self, on: bool) -> Self {
self.depth_cache = on;
self
}
pub fn fusion_mode(mut self, mode: &str) -> Self {
self.fusion.mode = Some(mode.to_string());
self
}
pub fn fusion_min_views(mut self, n: usize) -> Self {
self.fusion.min_views = Some(n);
self
}
pub fn fusion_depth_error(mut self, v: f64) -> Self {
self.fusion.depth_error = Some(v);
self
}
pub fn fusion_normal_error(mut self, deg: f64) -> Self {
self.fusion.normal_error = Some(deg);
self
}
pub fn fusion_reproj_error(mut self, px: f64) -> Self {
self.fusion.reproj_error = Some(px);
self
}
pub fn fusion_residual(mut self, mode: &str) -> Self {
self.fusion.residual = Some(mode.to_string());
self
}
pub fn fusion_smooth_iters(mut self, n: usize) -> Self {
self.fusion.smooth_iters = Some(n);
self
}
pub fn fusion_smooth_radius(mut self, r: f64) -> Self {
self.fusion.smooth_radius = Some(r);
self
}
pub fn fusion_smooth_range(mut self, r: f64) -> Self {
self.fusion.smooth_range = Some(r);
self
}
pub fn fusion_smooth_normal(mut self, deg: f64) -> Self {
self.fusion.smooth_normal = Some(deg);
self
}
pub fn max_levels(mut self, n: usize) -> Self {
self.max_levels = Some(n);
self
}
pub fn fill_holes(mut self, on: bool) -> Self {
self.filter.fill_holes = Some(on);
self
}
pub fn filter_min_views(mut self, n: u32) -> Self {
self.filter.min_views = Some(n);
self
}
pub fn filter_geom_error(mut self, px: f32) -> Self {
self.filter.geom_error = Some(px);
self
}
pub fn filter_min_ncc(mut self, v: f32) -> Self {
self.filter.min_ncc = Some(v);
self
}
pub fn median_filter(mut self, on: bool) -> Self {
self.filter.median = Some(on);
self
}
pub fn apply(&self, o: &mut DensifyOptions) -> Result<(), String> {
let f = &self.fusion;
if let Some(v) = self.max_levels {
o.max_levels = v;
}
if let Some(m) = &f.mode {
o.set_fusion_mode(parse_fusion_mode(m)?);
}
if let Some(v) = f.min_views {
o.fusion.min_consistent_views = v;
}
if let Some(v) = f.depth_error {
o.fusion.max_depth_error = v;
}
if let Some(v) = f.normal_error {
o.fusion.max_normal_error_deg = v;
}
if let Some(v) = f.reproj_error {
o.fusion.max_reproj_error = v;
}
if let Some(v) = f.smooth_iters {
o.fusion.smooth.iterations = v;
}
if let Some(v) = f.smooth_radius {
o.fusion.smooth.radius_gsd = v;
}
if let Some(v) = f.smooth_range {
o.fusion.smooth.sigma_range_gsd = v;
}
if let Some(v) = f.smooth_normal {
o.fusion.smooth.normal_deg = v;
}
if let Some(v) = f.visibility_views {
o.fusion.visibility.check_views = v;
}
if let Some(v) = f.visibility_tau {
o.fusion.visibility.tau = v;
}
if let Some(v) = f.visibility_min_violations {
o.fusion.visibility.min_violations = v;
}
if let Some(m) = &f.residual {
o.fusion.residual =
FusionResidual::parse(m).ok_or_else(|| format!("알 수 없는 융합 남은 픽셀 처리 {m} (none|release|second-pass)"))?;
}
let g = &self.filter;
if let Some(v) = g.min_views {
o.filter.min_num_consistent = v;
}
if let Some(v) = g.geom_error {
o.filter.geom_max_cost = v;
}
if let Some(v) = g.min_ncc {
o.filter.min_ncc = v;
}
if let Some(v) = g.median {
o.filter.median_filter = v;
}
if let Some(v) = g.fill_holes {
o.post.fill_holes = v;
}
Ok(())
}
pub fn config(&self) -> Result<DenseConfig, String> {
let mut d = DenseConfig::new(make_pm_backend(&self.backend), parse_profile(&self.profile)?);
d.undistort_backend = make_undistort_backend(&self.undistort_backend)?;
d.undistort.max_image_size = self.max_image_size;
d.densify.neighbors.num_views = self.views;
self.apply(&mut d.densify)?;
if self.serialize {
d.lock = Some(Arc::new(Mutex::new(())));
}
if self.depth_cache {
d.depth_cache = Some(Arc::new(DepthMapCache::new()));
}
Ok(d)
}
}
fn parse_fusion_mode(m: &str) -> Result<FusionMode, String> {
FusionMode::parse(m).ok_or_else(|| format!("알 수 없는 융합 방식 {m} (consistency|traversal)"))
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Sinks {
#[serde(skip_serializing_if = "Option::is_none")]
pub out: Option<PathBuf>,
pub clean: bool,
pub echo: bool,
pub timeline: bool,
pub run_log: bool,
pub zone_ply: bool,
pub snapshots: bool,
pub models: bool,
pub live_stride: usize,
}
impl Default for Sinks {
fn default() -> Self {
Self { out: None, ..Self::default_files("") }
}
}
impl Sinks {
pub fn default_files(out: impl Into<PathBuf>) -> Self {
Self {
out: Some(out.into()),
clean: true,
echo: true,
timeline: true,
run_log: true,
zone_ply: true,
snapshots: true,
models: false,
live_stride: crate::post::LiveOptions::default().stride,
}
}
pub fn none() -> Self {
Self {
out: None,
clean: false,
echo: false,
timeline: false,
run_log: false,
zone_ply: false,
snapshots: false,
models: false,
live_stride: crate::post::LiveOptions::default().stride,
}
}
pub fn clean(mut self, on: bool) -> Self {
self.clean = on;
self
}
pub fn echo(mut self, on: bool) -> Self {
self.echo = on;
self
}
pub fn timeline(mut self, on: bool) -> Self {
self.timeline = on;
self
}
pub fn run_log(mut self, on: bool) -> Self {
self.run_log = on;
self
}
pub fn zone_ply(mut self, on: bool) -> Self {
self.zone_ply = on;
self
}
pub fn snapshots(mut self, on: bool) -> Self {
self.snapshots = on;
self
}
pub fn models(mut self, on: bool) -> Self {
self.models = on;
self
}
pub fn live_stride(mut self, stride: usize) -> Self {
self.live_stride = stride;
self
}
fn set(&self) -> SinkSet {
SinkSet {
timeline: self.timeline,
run_log: self.run_log,
zone_ply: self.zone_ply,
models: self.models,
snapshots: self.snapshots,
live_stream: None,
live_stride: self.live_stride,
..SinkSet::all()
}
}
}
impl Plan {
pub fn to_toml(&self) -> String {
toml::to_string(self).expect("계획은 항상 TOML 로 바꿀 수 있다")
}
pub fn to_toml_annotated(&self) -> String {
let s = self.to_toml();
let Ok(profile) = parse_profile(&self.dense.profile) else { return s };
let o = DensifyOptions::with_profile(profile);
let (f, g) = (&o.fusion, &o.filter);
let mode = match f.mode {
FusionMode::Consistency => "consistency",
FusionMode::Traversal => "traversal",
};
let fusion = format!(
"# {} profile defaults:\n# mode = \"{mode}\"\n# min_views = {}\n# depth_error = {}\n# normal_error = {}\n# reproj_error = {}\n# residual = \"{}\"\n# smooth_iters = {}\n# smooth_radius = {}\n# smooth_range = {}\n# smooth_normal = {}\n",
self.dense.profile, f.min_consistent_views, f.max_depth_error, f.max_normal_error_deg, f.max_reproj_error, f.residual.name(), f.smooth.iterations, f.smooth.radius_gsd, f.smooth.sigma_range_gsd, f.smooth.normal_deg
);
let filter = format!(
"# {} profile defaults:\n# min_views = {}\n# geom_error = {}\n# min_ncc = {}\n# median = {}\n# fill_holes = {}\n",
self.dense.profile, g.min_num_consistent, g.geom_max_cost, g.min_ncc, g.median_filter, o.post.fill_holes
);
let mut out = String::with_capacity(s.len() + fusion.len() + filter.len());
let mut lines = s.lines().peekable();
while let Some(l) = lines.next() {
out.push_str(l);
out.push('\n');
let empty = lines.peek().is_none_or(|n| n.trim().is_empty() || n.starts_with('['));
if empty && l == "[dense.fusion]" {
out.push_str(&fusion);
} else if empty && l == "[dense.filter]" {
out.push_str(&filter);
}
}
out
}
pub fn from_toml(s: &str) -> Result<Self, String> {
toml::from_str(s).map_err(|e| format!("계획 TOML: {e}"))
}
pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
let p = path.as_ref();
let s = std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?;
Self::from_toml(&s).map_err(|e| format!("{}: {e}", p.display()))
}
pub fn from_stream(c: &StreamConfig) -> Self {
Self {
seed: c.seed,
threads: c.threads,
gps: Some(c.src.join("gps_ref.txt")),
pairing: Pairing::Formation,
input: Source {
images: c.src.join("images"),
cameras: CAMS.iter().map(|s| s.to_string()).collect(),
stride: c.stride,
positions: c.max_positions,
},
zones: Zones { span: c.span, overlap: c.overlap },
align: Align { frame: AlignFrame::Enu, fixed_origin: c.fixed_enu_origin },
features: Features {
backend: c.sift_backend.clone(),
max_num_features: c.max_num_features,
max_image_size: c.sift_max_image_size,
},
matching: Matching { backend: c.match_backend.clone() },
sparse: Sparse {
incremental_triangulation: c.incremental_triangulation,
stable_display: c.stable_display,
settle_tol: c.settle_tol,
settle_max_zone: c.settle_max_zone,
intrinsics_prior: c.intrinsics_prior.clone(),
intrinsics_prior_sigma: c.intrinsics_prior_sigma,
intrinsics_prior_dist_sigma: c.intrinsics_prior_dist_sigma,
..Default::default()
},
live: Live { lookahead: c.lookahead, preview: c.preview, ..Live::default() },
dense: Dense {
enabled: !c.no_dense,
backend: c.pm_backend.clone(),
undistort_backend: c.undistort_backend.clone(),
profile: c.mvs_profile.clone(),
max_image_size: c.dense_max_image_size,
views: c.number_views,
max_levels: c.max_levels,
serialize: c.serialize_dense,
depth_cache: c.depth_cache,
fusion: c.fusion.clone(),
filter: Filter::default(),
},
sinks: Sinks { models: c.save_models, echo: c.echo, live_stride: c.live_stride, ..Sinks::default_files(&c.out) },
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Problem {
pub field: String,
pub message: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlanError {
pub problems: Vec<Problem>,
}
impl PlanError {
pub fn has(&self, field: &str) -> bool {
self.problems.iter().any(|p| p.field == field)
}
}
impl fmt::Display for PlanError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "계획 검사 실패({} 건)", self.problems.len())?;
for p in &self.problems {
write!(f, "\n - {}: {}", p.field, p.message)?;
}
Ok(())
}
}
impl std::error::Error for PlanError {}
#[derive(Default)]
struct Problems(Vec<Problem>);
impl Problems {
fn add(&mut self, field: &str, message: impl Into<String>) {
self.0.push(Problem { field: field.to_string(), message: message.into() });
}
fn check(&mut self, ok: bool, field: &str, message: impl FnOnce() -> String) {
if !ok {
self.add(field, message());
}
}
}
type Attach = Box<dyn FnOnce(Pipeline<Session>) -> Pipeline<Session>>;
pub struct ReconBuilder {
plan: Plan,
attach: Vec<Attach>,
pending: Vec<Problem>,
frames: bool,
}
impl fmt::Debug for ReconBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReconBuilder").field("plan", &self.plan).field("hooks", &self.attach.len()).field("pending", &self.pending).finish()
}
}
impl ReconBuilder {
pub fn plan(&self) -> &Plan {
&self.plan
}
pub fn input(mut self, src: impl AsRef<Path>) -> Self {
let src = src.as_ref();
self.plan.input.images = src.join("images");
self.plan.gps = Some(src.join("gps_ref.txt"));
self
}
pub fn images(mut self, dir: impl Into<PathBuf>) -> Self {
self.plan.input.images = dir.into();
self
}
pub fn cameras<S: Into<String>>(mut self, cams: impl IntoIterator<Item = S>) -> Self {
self.plan.input.cameras = cams.into_iter().map(Into::into).collect();
self
}
pub fn preset(mut self, name: &str) -> Self {
match name {
DEFAULT_PRESET => {
self.plan.input.cameras = CAMS.iter().map(|c| c.to_string()).collect();
self.plan.input.stride = 3;
self.plan.pairing = Pairing::Formation;
}
other => self
.pending
.push(Problem { field: "preset".into(), message: format!("알 수 없는 프리셋 {other} ({})", PRESETS.join("|")) }),
}
self
}
pub fn stride(mut self, n: usize) -> Self {
self.plan.input.stride = n;
self
}
pub fn positions(mut self, n: usize) -> Self {
self.plan.input.positions = Some(n);
self
}
pub fn pairing(mut self, p: Pairing) -> Self {
self.plan.pairing = p;
self
}
pub fn zones(mut self, span: usize, overlap: usize) -> Self {
self.plan.zones = Zones { span, overlap };
self
}
pub fn gps(mut self, path: impl Into<PathBuf>) -> Self {
self.plan.gps = Some(path.into());
self
}
pub fn no_gps(mut self) -> Self {
self.plan.gps = None;
self
}
pub fn align(mut self, frame: AlignFrame) -> Self {
self.plan.align.frame = frame;
self
}
pub fn fixed_enu_origin(mut self, on: bool) -> Self {
self.plan.align.fixed_origin = on;
self
}
pub fn features(mut self, f: Features) -> Self {
self.plan.features = f;
self
}
pub fn match_backend(mut self, name: &str) -> Self {
self.plan.matching.backend = name.to_string();
self
}
pub fn gpu(mut self) -> Self {
self.plan.features.backend = "cuda".into();
self.plan.matching.backend = "cuda".into();
self.plan.sparse.backend = "cuda".into();
self.plan.dense.backend = "cuda".into();
self.plan.sparse.ba_backend = "cuda".into();
self.plan.dense.undistort_backend = "cuda".into();
self
}
pub fn incremental_triangulation(mut self, on: bool) -> Self {
self.plan.sparse.incremental_triangulation = on;
self
}
pub fn stable_display(mut self, on: bool) -> Self {
self.plan.sparse.stable_display = on;
self
}
pub fn settle(mut self, tol: f64, max_zone: usize) -> Self {
self.plan.sparse.settle_tol = tol;
self.plan.sparse.settle_max_zone = max_zone;
self
}
pub fn lookahead(mut self, positions: usize) -> Self {
self.plan.live.lookahead = positions;
self
}
pub fn preview(mut self, on: bool) -> Self {
self.plan.live.preview = on;
self
}
pub fn dense(mut self, d: Dense) -> Self {
self.plan.dense = Dense { enabled: true, ..d };
self
}
pub fn no_dense(mut self) -> Self {
self.plan.dense.enabled = false;
self
}
pub fn sinks(mut self, s: Sinks) -> Self {
self.plan.sinks = s;
self
}
pub fn seed(mut self, seed: u64) -> Self {
self.plan.seed = Some(seed);
self
}
pub fn threads(mut self, n: usize) -> Self {
self.plan.threads = n;
self
}
fn hook(mut self, f: impl FnOnce(Pipeline<Session>) -> Pipeline<Session> + 'static) -> Self {
self.attach.push(Box::new(f));
self
}
pub fn on<F>(mut self, kind: EventKind, f: F) -> Self
where
F: FnMut(&crate::events::Event) + Send + 'static,
{
self.frames |= kind == EventKind::FrameDecoded;
self.hook(move |p| p.on(kind, f))
}
pub fn on_frame<F>(mut self, f: F) -> Self
where
F: FnMut(&Arc<Frame>) + Send + 'static,
{
self.frames = true;
self.hook(move |p| p.on_frame(f))
}
pub fn on_any<F>(self, f: F) -> Self
where
F: FnMut(&crate::events::Event) + Send + 'static,
{
self.hook(move |p| p.on_any(f))
}
pub fn on_zone_preview<F>(self, f: F) -> Self
where
F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
{
self.hook(move |p| p.on_zone_preview(f))
}
pub fn on_zone_refined<F>(self, f: F) -> Self
where
F: FnMut(ZoneRange, &Arc<PointCloud>) + Send + 'static,
{
self.hook(move |p| p.on_zone_refined(f))
}
pub fn on_snapshot<F>(self, f: F) -> Self
where
F: FnMut(usize, &Arc<PointCloud>) + Send + 'static,
{
self.hook(move |p| p.on_snapshot(f))
}
pub fn on_position_done<F>(self, f: F) -> Self
where
F: FnMut(usize, usize, usize) + Send + 'static,
{
self.hook(move |p| p.on_position_done(f))
}
pub fn on_message<F>(self, f: F) -> Self
where
F: FnMut(EventKind, &str) + Send + 'static,
{
self.hook(move |p| p.on_message(f))
}
pub fn policy(self, kind: EventKind, p: QueuePolicy) -> Self {
self.hook(move |pl| pl.policy(kind, p))
}
pub fn build(self) -> Result<Recon, PlanError> {
let ReconBuilder { plan, attach, pending, frames } = self;
let mut pr = Problems(pending);
let (layout, gps) = check(&plan, &mut pr);
if !pr.0.is_empty() {
return Err(PlanError { problems: pr.0 });
}
let (Some(layout), Some(gps)) = (layout, gps) else {
unreachable!("문제가 없으면 입력과 GPS 를 읽었다");
};
Ok(Recon { plan, attach, layout, gps, frames })
}
}
fn backend_ok(pr: &mut Problems, field: &str, name: &str, allowed: &[&str]) {
if !allowed.contains(&name) {
pr.add(field, format!("알 수 없는 백엔드 {name} ({})", allowed.join("|")));
} else if name == "cuda" && !cumulus3d_cuda::is_available() {
pr.add(field, "CUDA 장치를 찾지 못함 — cuda 백엔드를 쓸 수 없다");
}
}
fn writable_target(p: &Path) -> Result<(), String> {
if p.exists() {
let m = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?;
if !m.is_dir() {
return Err(format!("{} 는 폴더가 아님", p.display()));
}
if m.permissions().readonly() {
return Err(format!("{} 에 쓸 수 없음", p.display()));
}
return Ok(());
}
let mut a = p.parent();
while let Some(d) = a {
let d = if d.as_os_str().is_empty() { Path::new(".") } else { d };
if d.exists() {
let m = std::fs::metadata(d).map_err(|e| format!("{}: {e}", d.display()))?;
if !m.is_dir() {
return Err(format!("{} 는 폴더가 아님", d.display()));
}
if m.permissions().readonly() {
return Err(format!("{} 에 쓸 수 없음", d.display()));
}
return Ok(());
}
a = d.parent();
}
Ok(())
}
fn check(plan: &Plan, pr: &mut Problems) -> (Option<Layout>, Option<Vec<GpsRecord>>) {
let inp = &plan.input;
pr.check(inp.stride >= 1, "input.stride", || "1 이상이어야 함".into());
pr.check(inp.positions != Some(0), "input.positions", || "1 이상이어야 함".into());
pr.check(!inp.cameras.is_empty(), "input.cameras", || "카메라 폴더 목록이 비어 있음".into());
let mut seen = std::collections::BTreeSet::new();
for c in &inp.cameras {
if c.is_empty() || c.contains(['/', '\\']) {
pr.add("input.cameras", format!("잘못된 카메라 폴더 이름 {c:?}"));
} else if !seen.insert(c.as_str()) {
pr.add("input.cameras", format!("카메라 {c} 중복"));
}
}
let z = plan.zones;
pr.check(z.span >= 1, "zones.span", || "1 이상이어야 함".into());
pr.check(z.overlap < z.span.max(1), "zones.overlap", || format!("구역 크기({})보다 작아야 함 (지금 {})", z.span, z.overlap));
let f = &plan.features;
backend_ok(pr, "features.backend", &f.backend, &["cpu", "cuda"]);
pr.check(f.max_num_features >= 1, "features.max_num_features", || "1 이상이어야 함".into());
pr.check(f.max_image_size >= 16, "features.max_image_size", || "16 이상이어야 함".into());
backend_ok(pr, "matching.backend", &plan.matching.backend, &["cpu", "cuda"]);
backend_ok(pr, "sparse.backend", &plan.sparse.backend, &["cpu", "cuda"]);
backend_ok(pr, "sparse.ba_backend", &plan.sparse.ba_backend, &["cpu", "cuda"]);
backend_ok(pr, "dense.undistort_backend", &plan.dense.undistort_backend, &["cpu", "cuda"]);
let d = &plan.dense;
if d.enabled {
if d.backend != "cuda" {
pr.add("dense.backend", format!("알 수 없는 백엔드 {} (cuda)", d.backend));
} else if !cumulus3d_cuda::is_available() {
pr.add("dense.backend", "조밀화에는 CUDA 장치가 필요한데 장치를 찾지 못함 — `.no_dense()` 또는 [dense] enabled = false");
}
if let Err(e) = parse_profile(&d.profile) {
pr.add("dense.profile", e);
}
pr.check(d.max_image_size >= 16, "dense.max_image_size", || "16 이상이어야 함".into());
pr.check((1..=32).contains(&d.views), "dense.views", || format!("1..=32 이어야 함 (지금 {})", d.views));
if let Some(v) = d.max_levels {
pr.check(v >= 1, "dense.max_levels", || "1 이상이어야 함".into());
}
let fu = &d.fusion;
if let Some(m) = &fu.mode {
if let Err(e) = parse_fusion_mode(m) {
pr.add("dense.fusion.mode", e);
}
}
if let Some(m) = &fu.residual {
pr.check(FusionResidual::parse(m).is_some(), "dense.fusion.residual", || {
format!("알 수 없는 처리 {m} (none|release|second-pass)")
});
}
if let Some(v) = fu.min_views {
pr.check(v >= 1, "dense.fusion.min_views", || "1 이상이어야 함".into());
}
if let Some(v) = fu.depth_error {
pr.check(v > 0.0 && v.is_finite(), "dense.fusion.depth_error", || format!("양수여야 함 (지금 {v})"));
}
if let Some(v) = fu.normal_error {
pr.check(v > 0.0 && v <= 180.0, "dense.fusion.normal_error", || format!("(0, 180] 이어야 함 (지금 {v})"));
}
if let Some(v) = fu.reproj_error {
pr.check(v > 0.0 && v.is_finite(), "dense.fusion.reproj_error", || format!("양수여야 함 (지금 {v})"));
}
if let Some(v) = fu.visibility_tau {
pr.check(v > 0.0 && v < 1.0, "dense.fusion.visibility_tau", || format!("(0, 1) 이어야 함 (지금 {v})"));
}
if let Some(v) = fu.smooth_radius {
pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_radius", || format!("양수여야 함 (지금 {v})"));
}
if let Some(v) = fu.smooth_range {
pr.check(v > 0.0 && v.is_finite(), "dense.fusion.smooth_range", || format!("양수여야 함 (지금 {v})"));
}
if let Some(v) = fu.smooth_normal {
pr.check(v > 0.0 && v <= 180.0, "dense.fusion.smooth_normal", || format!("(0, 180] 이어야 함 (지금 {v})"));
}
let fi = &d.filter;
if let Some(v) = fi.geom_error {
pr.check(v > 0.0 && v.is_finite(), "dense.filter.geom_error", || format!("양수여야 함 (지금 {v})"));
}
if let Some(v) = fi.min_ncc {
pr.check((-1.0..=1.0).contains(&v), "dense.filter.min_ncc", || format!("[-1, 1] 이어야 함 (지금 {v})"));
}
}
if let Some(p) = &plan.sparse.intrinsics_prior {
if let Err(e) = IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma) {
pr.add("sparse.intrinsics_prior", e);
}
}
let s = &plan.sinks;
pr.check(s.live_stride >= 1, "sinks.live_stride", || "must be >= 1 (1 = keep all)".into());
if let Some(out) = &s.out {
if let Err(e) = writable_target(out) {
pr.add("sinks.out", e);
}
if s.clean && (inp.images.starts_with(out) || plan.gps.as_ref().is_some_and(|g| g.starts_with(out))) {
pr.add("sinks.out", format!("시작 때 지울 출력 폴더 {} 안에 입력이 있음", out.display()));
}
}
let mut layout = None;
if !inp.images.is_dir() {
pr.add("input.images", format!("영상 폴더 없음: {}", inp.images.display()));
} else {
let missing: Vec<&String> = inp.cameras.iter().filter(|c| !inp.images.join(c).is_dir()).collect();
for c in &missing {
pr.add("input.cameras", format!("카메라 폴더 없음: {}", inp.images.join(c).display()));
}
if missing.is_empty() && !inp.cameras.is_empty() && inp.stride >= 1 {
match Layout::discover_in(&inp.images, &inp.cameras, inp.stride) {
Ok(mut l) => {
if let Some(n) = inp.positions {
l.truncate(n);
}
if l.frames.is_empty() {
pr.add(
"input",
format!(
"선택된 영상 없음: {}/{c}/{c}_NNNN.jpg 중 번호 % {} == 0 인 파일",
inp.images.display(),
inp.stride,
c = inp.cameras[0]
),
);
} else {
layout = Some(l);
}
}
Err(e) => pr.add("input.images", e),
}
}
}
let mut gps = None;
match (plan.align.frame, &plan.gps) {
(AlignFrame::None, _) => gps = Some(Vec::new()),
(AlignFrame::Enu, None) => pr.add("gps", "ENU 정렬에는 GPS 파일이 필요함 — `.gps(경로)` 또는 정렬 끔([align] frame = \"none\")"),
(AlignFrame::Enu, Some(p)) => match read_gps_file(p) {
Err(e) => pr.add("gps", format!("{}: {e}", p.display())),
Ok(all) => {
if let Some(l) = &layout {
let sel: Vec<GpsRecord> = all.into_iter().filter(|g| l.pos_of.contains_key(&g.name)).collect();
if sel.is_empty() {
pr.add("gps", format!("{}: 선택된 영상의 GPS 기록이 하나도 없음", p.display()));
} else {
gps = Some(sel);
}
} else {
gps = Some(Vec::new());
}
}
},
}
(layout, gps)
}
pub struct Recon {
plan: Plan,
attach: Vec<Attach>,
layout: Layout,
gps: Vec<GpsRecord>,
frames: bool,
}
impl fmt::Debug for Recon {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Recon")
.field("plan", &self.plan)
.field("hooks", &self.attach.len())
.field("positions", &self.layout.frames.len())
.finish()
}
}
impl Recon {
pub fn declare() -> ReconBuilder {
Self::from_plan(Plan::default())
}
pub fn from_plan(plan: Plan) -> ReconBuilder {
ReconBuilder { plan, attach: Vec::new(), pending: Vec::new(), frames: false }
}
pub fn plan(&self) -> &Plan {
&self.plan
}
pub fn positions(&self) -> usize {
self.layout.frames.len()
}
pub fn layout(&self) -> &Layout {
&self.layout
}
pub fn run(self) -> Result<Summary, String> {
let Recon { plan, attach, layout, gps, frames } = self;
if plan.threads > 0 {
let _ = rayon::ThreadPoolBuilder::new().num_threads(plan.threads).build_global();
}
let dense = if plan.dense.enabled { Some(plan.dense.config()?) } else { None };
let prior = match &plan.sparse.intrinsics_prior {
Some(p) => {
Some(Arc::new(IntrinsicsPrior::load(p, plan.sparse.intrinsics_prior_sigma, plan.sparse.intrinsics_prior_dist_sigma)?))
}
None => None,
};
let s = &plan.sinks;
let sinks = match &s.out {
Some(out) => {
if s.clean && out.exists() {
std::fs::remove_dir_all(out).map_err(|e| format!("{}: {e}", out.display()))?;
}
let mut set = s.set();
if plan.sparse.stable_display {
let origin = gps.first().map(|g| [g.lat, g.lon, g.alt]);
set.live_stream = Some(LiveStream { enu: origin.is_some(), origin_gps: origin });
}
Some(DefaultSinks::with_set(out, s.echo, set).map_err(|e| e.to_string())?)
}
None => None,
};
let npos = layout.frames.len();
let mut sc = SessionConfig::new(plan.input.images.clone());
sc.gps = gps;
sc.span = plan.zones.span;
sc.overlap = plan.zones.overlap;
sc.total_positions = Some(npos);
sc.incremental_triangulation = plan.sparse.incremental_triangulation;
sc.stable_display = plan.sparse.stable_display;
sc.settle_tol = plan.sparse.settle_tol;
sc.settle_max_zone = plan.sparse.settle_max_zone;
sc.intrinsics_prior = prior;
sc.fixed_enu_origin = plan.align.fixed_origin;
sc.seed = plan.seed;
sc.extraction.sift.max_num_features = plan.features.max_num_features;
sc.extraction.reader.max_image_size = plan.features.max_image_size;
sc.sift = make_sift_backend(&plan.features.backend)?;
sc.matcher = Arc::from(make_match_backend(&plan.matching.backend)?);
sc.sparse = make_sparse_backend(&plan.sparse.backend)?;
sc.ba_backend = make_ba_backend(&plan.sparse.ba_backend)?;
sc.dense = dense;
sc.history = 0;
sc.decode_frames = frames;
sc.lookahead = plan.live.lookahead;
sc.preview = plan.live.preview;
let mut pl = Pipeline::new(Session::new(sc));
if let Some(sinks) = sinks {
pl = pl.on_any(sinks.into_hook());
}
for a in attach {
pl = a(pl);
}
let (span, ovl) = (plan.zones.span, plan.zones.overlap);
let mut reg_end: BTreeMap<usize, Vec<ZoneRange>> = BTreeMap::new();
for z in zones_upto(npos, span, ovl) {
reg_end.entry(z.hi - 1).or_default().push(z);
}
pl.push(Input::Log(format!(
"regions: {}",
reg_end
.iter()
.map(|(e, v)| format!("{e} ->{}", v.iter().map(|z| format!(" {}:{}:{}", z.zone, z.lo, z.hi)).collect::<String>()))
.collect::<Vec<_>>()
.join(", ")
)));
let d = &plan.dense;
pl.push(Input::Log(format!(
"설정: span {span} overlap {ovl} stride {} 증분삼각측량 {} 깊이캐시 {} 고정원점 {} pm {}({})/sift {}/match {}/ba {} 직렬조밀화 {} 왜곡보정최대 {} 뷰 {} 스레드 {}",
plan.input.stride,
plan.sparse.incremental_triangulation,
d.depth_cache,
plan.align.fixed_origin,
d.backend,
d.profile,
plan.features.backend,
plan.matching.backend,
plan.sparse.ba_backend,
d.serialize,
d.max_image_size,
d.views,
rayon::current_num_threads()
)));
let ahead = pl.reducer().lookahead();
for p in 0..npos {
for q in p..npos.min(p + 1 + plan.live.lookahead) {
if !ahead.hint(&frame_set_of(&layout, &plan.input.cameras, q)) {
break;
}
}
pl.push(frame_set_of(&layout, &plan.input.cameras, p).into());
if let Some(e) = pl.reducer().failed() {
return Err(e.to_string());
}
}
let (_session, summary) = pl.finish();
Ok(summary)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_plan_toml_roundtrip() {
let p = Plan::default();
let s = p.to_toml();
assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
assert_eq!(Plan::from_toml("").unwrap(), p);
}
#[test]
fn full_plan_toml_roundtrip() {
let b = Recon::declare()
.input("some/where")
.cameras(["a", "b"])
.stride(2)
.positions(5)
.zones(6, 1)
.fixed_enu_origin(true)
.incremental_triangulation(true)
.dense(
Dense::profile("quality")
.fusion_mode("traversal")
.fusion_min_views(3)
.fusion_depth_error(0.02)
.fusion_normal_error(12.5)
.fusion_reproj_error(1.5)
.fusion_residual("second-pass")
.fusion_smooth_normal(30.0)
.max_levels(1)
.filter_min_views(2)
.filter_geom_error(1.25)
.filter_min_ncc(0.1)
.median_filter(true)
.depth_cache(true)
.serialize(true),
)
.sinks(Sinks::default_files("o").models(true).echo(false))
.seed(42)
.threads(3);
let p = b.plan().clone();
let s = p.to_toml();
assert_eq!(Plan::from_toml(&s).unwrap(), p, "{s}");
assert!(s.contains("[dense.fusion]") && s.contains("min_views = 3"), "{s}");
assert!(s.contains("smooth_normal = 30.0") && s.contains("max_levels = 1"), "{s}");
}
#[test]
fn example_plan_parses() {
let s = include_str!("../../../examples/plans/aerial-formation.toml");
let p = Plan::from_toml(s).unwrap();
assert_eq!(p.input.cameras, ["camF", "camR", "camL"]);
assert_eq!(p.pairing, Pairing::Formation);
assert_eq!(Plan::from_toml(&p.to_toml()).unwrap(), p);
}
#[test]
fn unknown_field_rejected() {
assert!(Plan::from_toml("[zones]\nspam = 3\n").is_err());
assert!(Plan::from_toml("pairing = \"grid\"\n").is_err());
}
#[test]
fn stream_plan_matches_builder() {
let mut c = StreamConfig::new("src".into(), "dst".into());
(c.stride, c.span, c.overlap, c.seed, c.save_models, c.echo) = (1, 4, 1, Some(7), true, false);
let b = Recon::declare()
.input("src")
.preset(DEFAULT_PRESET)
.stride(1)
.zones(4, 1)
.seed(7)
.sinks(Sinks::default_files("dst").models(true).echo(false));
assert_eq!(Plan::from_stream(&c), *b.plan());
}
#[test]
fn build_collects_all_problems() {
let tmp = std::env::temp_dir().join(format!("cumulus3d_declare_missing_{}", std::process::id()));
let e = Recon::declare()
.input(tmp.join("in"))
.preset("grid")
.stride(0)
.zones(4, 4)
.dense(Dense::profile("slow").views(40).max_levels(0).fusion_smooth_normal(0.0).fusion_mode("magic").filter_min_ncc(2.0))
.features(Features { backend: "fpga".into(), ..Features::default() })
.sinks(Sinks::default_files(tmp.join("in")).clean(true))
.build()
.unwrap_err();
for f in [
"preset",
"input.stride",
"zones.overlap",
"features.backend",
"dense.profile",
"dense.views",
"dense.fusion.mode",
"dense.max_levels",
"dense.fusion.smooth_normal",
"dense.filter.min_ncc",
"sinks.out",
"input.images",
"gps",
] {
assert!(e.has(f), "{f} 없음:\n{e}");
}
assert_eq!(e.has("dense.backend"), !cumulus3d_cuda::is_available());
assert!(!tmp.exists());
assert!(e.to_string().starts_with("계획 검사 실패("));
}
#[test]
fn live_stride_roundtrip_and_validation() {
assert_eq!(Sinks::default().live_stride, 6);
let p = Plan { sinks: Sinks::default_files("o").live_stride(3), ..Plan::default() };
assert_eq!(Plan::from_toml(&p.to_toml()).unwrap().sinks.live_stride, 3);
let e = Recon::declare().sinks(Sinks::default_files("o").live_stride(0)).no_dense().build().unwrap_err();
assert!(e.has("sinks.live_stride"), "{e}");
}
#[test]
fn enu_without_gps_is_error() {
let e = Recon::declare().no_gps().no_dense().build().unwrap_err();
assert!(e.has("gps"), "{e}");
let e = Recon::declare().no_gps().align(AlignFrame::None).no_dense().build().unwrap_err();
assert!(!e.has("gps"), "{e}");
}
}