Skip to main content

cumulus3d_cli/
session.rs

1/*
2 * session.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//! State value and reducer: [`Session`] + [`step`] / [`poll`] / [`command`] / [`finish`].
29//!
30//! Each time the frame bundle of one position ([`FrameSet`]) arrives, it performs features -> matching of new pairs -> (first model /
31//! continued registration + triangulation)
32//! -> on zone completion, preview (foreground) densification + refinement (background thread: BA -> GPS ENU alignment -> densification),
33//! and emits that process as per-stage [`Event`]s. It writes no files or logs (run.log lines also go out as [`Event::Log`]).
34//!
35//! # State and sharing
36//! `Session` is a value. `step(&s, frames)` returns a new `Session` and `s` can still be used.
37//! Models, image lists, correspondence graphs etc. are shared via `Arc` / copy on write. However, the feature store is append-only shared
38//! data,
39//! so calling `step` twice from the same earlier value with different frames mixes up the store (rewind with [`Command::ResetFrom`]).
40//! Background refinement results (refined models and point clouds) and the event version number are also shared by the whole session
41//! lineage.
42//!
43//! # Event delivery
44//! - Default: foreground events go out in the return vector of that call; background refinement events accumulate in an internal inbox
45//!   and go out
46//!   at the front of the return vector of the next `step`/`poll`/`command`/`finish`.
47//! - If a receiver is set with [`Session::set_sink`], **all** events (foreground and background) are delivered to it in version order as
48//!   soon as they are emitted.
49//!   In that case the foreground events in the return vector are copies of what was already delivered (do not deliver them again). The
50//!   receiver must not block
51//!   (it is called inside an internal lock).
52//! - `meta.version` increases monotonically across the whole session lineage and gives the emission order (= timeline order).
53//!
54//! # Rules for reproducing run.log and timeline (for output hooks)
55//! - The timeline text is given by [`Event::timeline_text`], and it is also written to run.log in the same place as `[HH:MM:SS] text`.
56//! - [`Event::Log`] is one run.log line at that place (with a `[HH:MM:SS] ` prefix if `stamped`).
57//! - [`Event::Warning`]/[`Event::Error`] go out together with a `Log` of the same content, so do not write them to run.log separately.
58//! - The stage times of [`Event::Finished`] have no post-processing ("post") time (post-processing is done by the output hook).
59
60use crate::densewrap::{dense_model, DenseConfig};
61use crate::events::{Command, Event, Frame, Meta, ZoneRange};
62use crate::intrinsics::IntrinsicsPrior;
63use crate::post::fit_shared_points;
64use crate::util::StageTimes;
65use cumulus3d_align::shared::NameFilter;
66use cumulus3d_align::{align_to_gps, EnuOrigin, ModelAlignerOptions};
67use cumulus3d_ba::{bundle_adjust_with, BaBackend, BaBackendRef, BaConfig};
68use cumulus3d_core::analyzer::ModelStats;
69use cumulus3d_core::io::ply::PointCloud;
70use cumulus3d_core::io::GpsRecord;
71use cumulus3d_core::{Camera, CameraId, FeatureStore, ImageId, MatchGraph, MatchGraphOptions, Reconstruction, Rigid3};
72use cumulus3d_features::{CameraMode, ExtractionOptions, FeatureExtractor, ImageReport, ImageStatus, SiftEngine};
73use cumulus3d_matching::{match_pairs_with, MatcherBackend, PairMatchingOptions, TwoViewBackend};
74use cumulus3d_sfm::{
75    global_mapper, register_images_with, triangulate_points_with, AbsPoseBackend, GlobalSfmOptions, PointTriangulatorOptions,
76    RegistrationOptions, TriangulationBackend, TriangulationScope,
77};
78use rayon::prelude::*;
79use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
80use std::path::PathBuf;
81use std::sync::{Arc, Mutex, MutexGuard};
82use std::thread::JoinHandle;
83use std::time::{Instant, SystemTime};
84
85/// One image of one position.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct FrameImage {
88    /// Camera name (e.g. `camF`). The same name uses the same camera intrinsics.
89    pub camera: String,
90    /// Image name (path relative to `image_root`, e.g. `camF/camF_0000.jpg`).
91    pub name: String,
92}
93
94/// Frame bundle of one position (one image per camera).
95#[derive(Clone, Debug, Default)]
96pub struct FrameSet {
97    /// Images per camera.
98    pub images: Vec<FrameImage>,
99    /// GPS records that came with this bundle (appended after `gps` in the settings).
100    pub gps: Vec<GpsRecord>,
101}
102
103impl FrameSet {
104    /// As a (camera, name) list.
105    pub fn new<C: Into<String>, N: Into<String>>(images: impl IntoIterator<Item = (C, N)>) -> Self {
106        Self { images: images.into_iter().map(|(c, n)| FrameImage { camera: c.into(), name: n.into() }).collect(), gps: Vec::new() }
107    }
108}
109
110/// Backends of the sparse stages that run per position. All CPU by default.
111#[derive(Clone)]
112pub struct SparseBackends {
113    /// Two-view geometric verification.
114    pub two_view: Arc<dyn TwoViewBackend>,
115    /// Absolute pose RANSAC of registration.
116    pub abs_pose: Arc<dyn AbsPoseBackend>,
117    /// Triangulation (creation RANSAC, depth and reprojection checks).
118    pub triangulation: Arc<dyn TriangulationBackend>,
119}
120
121impl Default for SparseBackends {
122    fn default() -> Self {
123        Self {
124            two_view: Arc::new(cumulus3d_matching::CpuTwoView),
125            abs_pose: Arc::new(cumulus3d_sfm::CpuAbsPose),
126            triangulation: Arc::new(cumulus3d_sfm::CpuTriangulation),
127        }
128    }
129}
130
131/// Session settings.
132#[derive(Clone)]
133pub struct SessionConfig {
134    /// Input image folder (base of the image names).
135    pub image_root: PathBuf,
136    /// GPS records (alignment order = this order).
137    pub gps: Vec<GpsRecord>,
138    /// Zone size and overlap (in units of positions).
139    pub span: usize,
140    /// Zone overlap (number of positions).
141    pub overlap: usize,
142    /// Total number of known positions. If set, the last zones are closed at the last position; if not, they are closed in [`finish`].
143    pub total_positions: Option<usize>,
144    /// Triangulate only newly registered images.
145    pub incremental_triangulation: bool,
146    /// Fix the ENU origin to the first GPS record (default: the first common record at each alignment).
147    pub fixed_enu_origin: bool,
148    /// RANSAC seed for two-view geometry and GPS alignment.
149    pub seed: Option<u64>,
150    /// Feature extraction options.
151    pub extraction: ExtractionOptions,
152    /// SIFT backend.
153    pub sift: Arc<dyn SiftEngine>,
154    /// Descriptor matching backend.
155    pub matcher: Arc<dyn MatcherBackend>,
156    /// Bundle adjustment backend (zone refinement BA, BA of the first model's global mapper).
157    pub ba_backend: Arc<dyn BaBackend>,
158    /// Sparse-stage backends (geometric verification, registration, triangulation).
159    pub sparse: SparseBackends,
160    /// Densification settings (None = skip densification, point-cloud events carry `dense: false`).
161    pub dense: Option<DenseConfig>,
162    /// Number of per-position rewind points kept for [`Command::ResetFrom`] (0 = no rewind, saves memory).
163    pub history: usize,
164    /// Decode input frames to RGB and emit them as [`Event::FrameDecoded`] before processing (off by default).
165    pub decode_frames: bool,
166    /// Stable display (default off). No preview densification; the refinement of zone K waits for zone K−1, takes the
167    /// cameras already shown by it (pose and intrinsics, copied exactly) as fixed in its bundle adjustment, and is not
168    /// re-aligned to GPS, so a zone never moves after it is shown and the space only grows. The coordinate frame is
169    /// the first refined zone's (local). `AllRefinedDone::chain` is the final model aligned to GPS (ENU) once.
170    pub stable_display: bool,
171    /// Stable display: standard deviation (m) of the GPS position prior on each newly placed camera. ≤ 0 disables
172    /// the prior (then the shown frame is local and drifts with the chain).
173    pub gps_prior_sigma: f64,
174    /// Stable display: until settled, every refined zone re-adjusts all earlier cameras and the intrinsics (shown zones are
175    /// re-placed). Once the largest relative focal change between two consecutive refined models is below this (default
176    /// 0.01 = 1 %), the display settles: from then on shown cameras and intrinsics are frozen.
177    pub settle_tol: f64,
178    /// Stable display: the display settles by force at this zone at the latest (default 3).
179    pub settle_max_zone: usize,
180    /// Soft intrinsics prior (see [`crate::intrinsics`]): every camera starts from the prior values as soon as it exists and
181    /// each refinement pulls its refined parameters back towards them. None = off.
182    pub intrinsics_prior: Option<Arc<IntrinsicsPrior>>,
183}
184
185impl SessionConfig {
186    /// Defaults: zone 12 / overlap 2, SIFT up to 8192 features and input 3200, CPU SIFT and matching, no densification, rewind 8.
187    pub fn new(image_root: impl Into<PathBuf>) -> Self {
188        let mut extraction = ExtractionOptions::default();
189        extraction.sift.max_num_features = 8192;
190        extraction.reader.max_image_size = 3200;
191        Self {
192            image_root: image_root.into(),
193            gps: Vec::new(),
194            span: 12,
195            overlap: 2,
196            total_positions: None,
197            incremental_triangulation: false,
198            fixed_enu_origin: false,
199            seed: None,
200            extraction,
201            sift: Arc::new(cumulus3d_features::CpuSift::default()),
202            matcher: Arc::new(cumulus3d_matching::CpuMatcher::default()),
203            ba_backend: Arc::new(cumulus3d_ba::CpuBaBackend),
204            sparse: SparseBackends::default(),
205            dense: None,
206            history: 8,
207            decode_frames: false,
208            stable_display: false,
209            gps_prior_sigma: 1.0,
210            settle_tol: 0.01,
211            settle_max_zone: 3,
212            intrinsics_prior: None,
213        }
214    }
215}
216
217/// Reducer input.
218#[derive(Clone, Debug)]
219pub enum Input {
220    /// Frame bundle of the next position.
221    Frames(FrameSet),
222    /// A command that changes the state.
223    Command(Command),
224    /// Only collect background events.
225    Poll,
226    /// One line to run.log (`Event::Log`).
227    Log(String),
228    /// Close the remaining zones -> wait for background jobs -> summary. Later inputs only produce warnings.
229    Finish,
230}
231
232impl From<FrameSet> for Input {
233    fn from(f: FrameSet) -> Self {
234        Input::Frames(f)
235    }
236}
237
238impl From<Command> for Input {
239    fn from(c: Command) -> Self {
240        Input::Command(c)
241    }
242}
243
244/// The session as a reducer driven by [`crate::pipeline::Pipeline`]. `finish` closes the remaining zones and waits for all background
245/// refinement jobs.
246impl crate::pipeline::Reducer for Session {
247    type Input = Input;
248    fn step(&mut self, input: Input) -> Vec<Event> {
249        self.apply(input)
250    }
251    fn prelude(&mut self, input: &Input) -> Vec<Event> {
252        let Input::Frames(fs) = input else { return Vec::new() };
253        let sh = self.shared.clone();
254        let mut evs = Vec::new();
255        {
256            let mut out = Out { sh: &sh, fg: Some(&mut evs), guard: None };
257            self.announce(fs, &mut out);
258        }
259        evs
260    }
261    fn poll(&mut self) -> Vec<Event> {
262        self.apply(Input::Poll)
263    }
264    fn command(&mut self, c: Command) -> Vec<Event> {
265        self.apply(Input::Command(c))
266    }
267    fn finish(&mut self) -> Vec<Event> {
268        self.apply(Input::Finish)
269    }
270}
271
272/// Immediate-delivery receiver.
273pub type EventSink = Arc<dyn Fn(Event) + Send + Sync>;
274
275/// Zone preview result.
276#[derive(Clone, Debug)]
277pub struct PreviewZone {
278    /// Zone range.
279    pub range: ZoneRange,
280    /// Preview model (zone 0 is the GPS-aligned one).
281    pub model: Arc<Reconstruction>,
282    /// Preview dense point cloud (None if densification was not run).
283    pub cloud: Option<Arc<PointCloud>>,
284    /// Point-cloud coordinate frame ("gps" | "refined_K" | "unaligned").
285    pub frame: String,
286}
287
288/// Zone refined result (present from the time the pose is finalized; the point cloud exists once densification finishes).
289#[derive(Clone, Debug)]
290pub struct RefinedZone {
291    /// Zone range.
292    pub range: ZoneRange,
293    /// Model after BA + GPS alignment.
294    pub model: Arc<Reconstruction>,
295    /// Refined dense point cloud (None before densification finishes or if it is skipped).
296    pub cloud: Option<Arc<PointCloud>>,
297    /// Whether refined densification has finished.
298    pub done: bool,
299    generation: u64,
300}
301
302// ---------------------------------------------------------------- Event emission
303
304struct EmitState {
305    version: u64,
306    sink: Option<EventSink>,
307    inbox: Vec<Event>,
308}
309
310struct Emitter(Mutex<EmitState>);
311
312impl Emitter {
313    fn lock(&self) -> MutexGuard<'_, EmitState> {
314        self.0.lock().unwrap_or_else(|e| e.into_inner())
315    }
316
317    /// Attaches version and time and emits it. If `fg` is present, also puts it in the return vector.
318    fn emit(&self, make: impl FnOnce(Meta) -> Event, fg: Option<&mut Vec<Event>>) {
319        let mut st = self.lock();
320        st.version += 1;
321        let ev = make(Meta { version: st.version, at: SystemTime::now() });
322        match (&st.sink, fg) {
323            (Some(s), fg) => {
324                let s = s.clone();
325                if let Some(v) = fg {
326                    v.push(ev.clone());
327                }
328                s(ev);
329            }
330            (None, Some(v)) => v.push(ev),
331            (None, None) => st.inbox.push(ev),
332        }
333    }
334}
335
336/// Immutable settings + background state shared by the session lineage.
337struct Shared {
338    cfg: SessionConfig,
339    extractor: FeatureExtractor,
340    emitter: Emitter,
341    times: StageTimes,
342    bg: Mutex<Background>,
343    start: Mutex<Option<Instant>>,
344}
345
346#[derive(Default)]
347struct Background {
348    jobs: Vec<JoinHandle<()>>,
349    ready: BTreeMap<usize, RefinedZone>,
350    generation: BTreeMap<usize, u64>,
351    /// Stable display: the zone at which the display settled (shown cameras and intrinsics frozen from the next zone on).
352    settled: Option<usize>,
353    /// Stable display: image-name set each zone was densified with (to densify it again while settling).
354    zone_keep: BTreeMap<usize, Arc<HashSet<String>>>,
355    /// Stable display: the model each zone's cloud was densified with (its intrinsics decide whether to densify again).
356    dense_basis: BTreeMap<usize, Arc<Reconstruction>>,
357    /// Stable display: zone -> generation of the last refine job of that zone that ran to its end.
358    finished: BTreeMap<usize, u64>,
359}
360
361/// Marks a stable-display refine job as finished (also on panic) so the next zone's settle work can proceed.
362struct FinishMark<'a> {
363    sh: &'a Shared,
364    zone: usize,
365    generation: u64,
366}
367
368impl Drop for FinishMark<'_> {
369    fn drop(&mut self) {
370        self.sh.bg().finished.insert(self.zone, self.generation);
371    }
372}
373
374impl Shared {
375    fn bg(&self) -> MutexGuard<'_, Background> {
376        self.bg.lock().unwrap_or_else(|e| e.into_inner())
377    }
378
379    fn generation(&self, zone: usize) -> u64 {
380        self.bg().generation.get(&zone).copied().unwrap_or(0)
381    }
382
383    /// Discard a zone result: raise the generation so that later results and events of in-flight jobs are dropped.
384    fn invalidate(&self, zone: usize) {
385        let mut bg = self.bg();
386        *bg.generation.entry(zone).or_insert(0) += 1;
387        bg.ready.remove(&zone);
388        bg.zone_keep.remove(&zone);
389        bg.dense_basis.remove(&zone);
390    }
391
392    fn aligner_options(&self, gps: &[GpsRecord]) -> ModelAlignerOptions {
393        let mut o = ModelAlignerOptions { max_error: 3.0, ..Default::default() };
394        o.ransac.random_seed = self.cfg.seed;
395        if self.cfg.fixed_enu_origin {
396            if let Some(g) = gps.first() {
397                o.origin = EnuOrigin::Explicit { lat: g.lat, lon: g.lon, alt: g.alt };
398            }
399        }
400        o
401    }
402
403    /// ENU alignment using the GPS of registered images. On failure, keep the model as is and warn (design decision: do not stop).
404    /// Waits for the refined pose of zone `k` (stable display orders refinements). None if it does not arrive
405    /// (zone invalidated or refinement failed) within the time limit, or the generation changed.
406    fn wait_refined(&self, k: usize, generation: u64) -> Option<Arc<Reconstruction>> {
407        let t0 = Instant::now();
408        let me = k + 1;
409        loop {
410            if let Some(z) = self.bg().ready.get(&k) {
411                return Some(z.model.clone());
412            }
413            if self.generation(me) != generation || t0.elapsed().as_secs() > 1800 {
414                return None;
415            }
416            std::thread::sleep(std::time::Duration::from_millis(20));
417        }
418    }
419
420    /// Waits until the refine job of zone `k` has run to its end (stable display, settling: earlier zones are only
421    /// re-placed once nothing else writes them). False on timeout or when zone `me` (generation `generation`) was invalidated.
422    fn wait_finished(&self, k: usize, me: usize, generation: u64) -> bool {
423        let t0 = Instant::now();
424        loop {
425            {
426                let bg = self.bg();
427                if bg.finished.get(&k).is_some_and(|g| *g == bg.generation.get(&k).copied().unwrap_or(0)) {
428                    return true;
429                }
430            }
431            if self.generation(me) != generation || t0.elapsed().as_secs() > 1800 {
432                return false;
433            }
434            std::thread::sleep(std::time::Duration::from_millis(20));
435        }
436    }
437
438    /// Stable display, not yet settled: zone `k`'s adjustment moved the earlier cameras, so every shown zone `j < k` is
439    /// placed into `model` again. A zone whose cloud was made with intrinsics differing by `settle_tol` or more from
440    /// `model`'s is densified again; otherwise its cloud is moved with the Sim3 from its previous model to `model`.
441    /// The zone is emitted again (`ZoneRefinedPose` + `ZoneRefined`) under its own generation.
442    fn replace_shown(&self, k: usize, generation: u64, model: &Arc<Reconstruction>, st: &ModelStats) {
443        if !self.wait_finished(k - 1, k, generation) {
444            return;
445        }
446        for j in 0..k {
447            let snap = {
448                let bg = self.bg();
449                let gj = bg.generation.get(&j).copied().unwrap_or(0);
450                bg.ready
451                    .get(&j)
452                    .filter(|z| z.generation == gj)
453                    .map(|z| (z.clone(), gj, bg.zone_keep.get(&j).cloned(), bg.dense_basis.get(&j).cloned()))
454            };
455            let Some((z, gj, keep, basis)) = snap else { continue };
456            let mut out = Out { sh: self, fg: None, guard: Some((j, gj)) };
457            let change = basis.as_ref().map_or(0.0, |b| intrinsics_change(b.cameras(), model.cameras()));
458            let mut basis_new = None;
459            let cloud = z.cloud.as_ref().map(|old| {
460                if change >= self.cfg.settle_tol {
461                    if let Some(c) = keep.as_ref().and_then(|keep| self.dense_region("refined", j, model, keep, &mut out)) {
462                        out.log(format!("[settle] 구역 {j} 다시 조밀화 (내부값 변화 {:.2}%)", change * 100.0));
463                        basis_new = Some(model.clone());
464                        return c;
465                    }
466                }
467                // Shared 3D points seen by zone j's images first, then all shared points, then the camera-center fit.
468                let by_points = keep
469                    .as_ref()
470                    .and_then(|keep| {
471                        let keep = keep.clone();
472                        let filter: NameFilter = Arc::new(move |n: &str| keep.contains(n));
473                        fit_shared_points(&z.model, model, Some(filter), MIN_SHARED_PAIRS)
474                    })
475                    .or_else(|| fit_shared_points(&z.model, model, None, MIN_SHARED_PAIRS));
476                let mut c = (**old).clone();
477                if let Some((f, n)) = by_points {
478                    let t = f.sim3;
479                    cumulus3d_align::transform_cloud(&mut c, &t);
480                    out.log(format!(
481                        "[settle] 구역 {j} 점군 이동: 공유 점 {n}, 잔차 중앙 {:.3}m, 축척 {:.4}, 이동 {:.2}m",
482                        f.median,
483                        t.scale,
484                        t.translation.norm()
485                    ));
486                } else {
487                    let t = keep.as_ref().and_then(|keep| sim3_between(&z.model, model, |n| keep.contains(n)));
488                    match t {
489                        Some(t) => {
490                            cumulus3d_align::transform_cloud(&mut c, &t);
491                            out.log(format!(
492                                "[settle] 구역 {j} 점군 이동(카메라 중심 대체): 이동 {:.3}m 축척 {:.4}",
493                                t.translation.norm(),
494                                t.scale
495                            ));
496                        }
497                        None => out.log(format!("[settle] 구역 {j} 점군 이동 생략: 공통 영상 부족")),
498                    }
499                }
500                Arc::new(c)
501            });
502            {
503                let mut bg = self.bg();
504                if bg.generation.get(&j).copied().unwrap_or(0) != gj {
505                    continue;
506                }
507                let Some(r) = bg.ready.get_mut(&j).filter(|r| r.generation == gj) else { continue };
508                r.model = model.clone();
509                r.cloud = cloud.clone();
510                r.done = true;
511                if let Some(b) = basis_new {
512                    bg.dense_basis.insert(j, b);
513                }
514            }
515            let (range, m) = (z.range, model.clone());
516            let (registered, mean_reproj_px) = (st.registered_image_count, st.mean_reprojection_error);
517            out.emit(|meta| Event::ZoneRefinedPose { meta, range, registered, mean_reproj_px, model: m });
518            let m = model.clone();
519            out.emit(|meta| Event::ZoneRefined { meta, range, dense: cloud.is_some(), cloud: cloud.unwrap_or_default(), model: m });
520        }
521    }
522
523    fn align_enu(&self, rec: &mut Reconstruction, what: &str, gps_all: &[GpsRecord], out: &mut Out) -> bool {
524        let names: BTreeSet<String> = rec.registered_images().into_iter().filter_map(|i| rec.image(i).map(|im| im.name.clone())).collect();
525        let gps: Vec<GpsRecord> = gps_all.iter().filter(|g| names.contains(&g.name)).cloned().collect();
526        let opts = self.aligner_options(gps_all);
527        let t0 = Instant::now();
528        let r = align_to_gps(rec, &gps, &opts);
529        out.stage("align", what, t0);
530        match r {
531            Ok(a) => {
532                out.log(format!(
533                    "[align] {what}: 공통 {} 인라이어 {} 오차 평균 {:.3}m 중앙 {:.3}m 축척 {:.4}",
534                    a.common.len(),
535                    a.num_inliers,
536                    a.mean_error,
537                    a.median_error,
538                    a.sim3.scale
539                ));
540                if let Some(g) = &a.gravity {
541                    out.log(gravity_note(&format!("align {what}"), g));
542                }
543                true
544            }
545            Err(e) => {
546                out.warn(format!("[align] {what}: 정렬 실패({e}) — 정렬 없이 계속"));
547                false
548            }
549        }
550    }
551
552    /// Densification (excluding images outside the zone). None if densification is off or fails.
553    fn dense_region(&self, kind: &str, k: usize, model: &Reconstruction, keep: &HashSet<String>, out: &mut Out) -> Option<Arc<PointCloud>> {
554        let dense = self.cfg.dense.as_ref()?;
555        let what = format!("{kind} {k}");
556        let t = Instant::now();
557        let r = dense_model(model, |n| keep.contains(n), &self.cfg.image_root, dense);
558        let total = t.elapsed();
559        match r {
560            Ok(run) => {
561                self.times.add("undistort", run.undistort_time);
562                self.times.add("densify", run.densify_time);
563                let tm = &run.output.timings;
564                out.stamped(format!(
565                    "[time] dense {what}: 전체 {:.1}s (왜곡 보정+장면 {:.1}s, 대기 {:.1}s, 조밀화 {:.1}s = 이웃 {:.1} 준비 {:.1} 깊이 {:.1} 필터 {:.1} 융합 {:.1}, 캐시 적중 {})",
566                    total.as_secs_f64(),
567                    run.undistort_time.as_secs_f64(),
568                    run.lock_wait.as_secs_f64(),
569                    run.densify_time.as_secs_f64(),
570                    tm.neighbors.as_secs_f64(),
571                    tm.prepare.as_secs_f64(),
572                    tm.depth().as_secs_f64(),
573                    tm.filter.as_secs_f64(),
574                    tm.fusion.as_secs_f64(),
575                    run.output.cache_hits
576                ));
577                out.log(format!("[dense] {kind} {k} frames {} points {}", run.frames, run.output.cloud.len()));
578                let mut cloud = run.output.cloud;
579                // Same value as the PLY round trip: white if there is no color.
580                if !cloud.is_empty() && cloud.colors.is_empty() {
581                    cloud.colors = vec![[255, 255, 255]; cloud.len()];
582                }
583                Some(Arc::new(cloud))
584            }
585            Err(e) => {
586                out.warn(format!("[dense] {kind} {k} 실패: {e}"));
587                None
588            }
589        }
590    }
591
592    /// Background refinement job: copy -> BA -> ENU alignment -> (adoptable) -> densification.
593    fn refine_job(
594        self: Arc<Self>,
595        range: ZoneRange,
596        mut rec: Reconstruction,
597        generation: u64,
598        gps: Arc<Vec<GpsRecord>>,
599        keep: Arc<HashSet<String>>,
600        cam_ids: Arc<BTreeMap<String, CameraId>>,
601    ) {
602        let k = range.zone;
603        let sh = &*self;
604        let mut out = Out { sh, fg: None, guard: Some((k, generation)) };
605        let _finish = FinishMark { sh, zone: k, generation };
606        out.emit(|meta| Event::RefineStarted { meta, zone: k });
607        let mut ba = BaConfig::default();
608        // Stable display: the previous zone's model, and whether the display is still settling (intrinsics and shown
609        // cameras free) in this zone.
610        let mut prev: Option<Arc<Reconstruction>> = None;
611        let mut settling = false;
612        if sh.cfg.stable_display {
613            let mut bg = sh.bg();
614            if bg.generation.get(&k).copied().unwrap_or(0) == generation {
615                bg.zone_keep.insert(k, keep.clone());
616            }
617        }
618        if sh.cfg.stable_display {
619            let targets = if sh.cfg.gps_prior_sigma > 0.0 { enu_targets(&rec, &gps) } else { HashMap::new() };
620            if k == 0 {
621                // Bring the first model into ENU once; every later zone is placed in this frame.
622                let (src, dst): (Vec<_>, Vec<_>) = targets.iter().filter_map(|(id, g)| rec.projection_center(*id).map(|c| (c, *g))).unzip();
623                if src.len() >= 3 {
624                    if let Some((t, g)) = cumulus3d_align::fit_enu_sim3(&rec, &src, &dst) {
625                        rec.transform(&t);
626                        out.log(format!("[stable] refined 0: ENU 초기 정렬 GPS {} 개, 축척 {:.4}", src.len(), t.scale));
627                        if let Some(g) = g {
628                            out.log(gravity_note("stable refined 0", &g));
629                        }
630                    }
631                }
632                settling = true;
633            } else {
634                prev = sh.wait_refined(k - 1, generation);
635                settling = sh.bg().settled.is_none();
636                match (&prev, settling) {
637                    (Some(p), false) => {
638                        let (n, scale) = anchor_to_shown(&mut rec, p, &mut ba);
639                        out.log(format!("[stable] refined {k}: 고정 영상 {n}, 이전 구역에 맞춘 축척 {scale:.4}"));
640                    }
641                    (Some(p), true) => {
642                        let scale = sim3_onto_shown(&mut rec, p).map_or(1.0, |(t, _)| t.scale);
643                        out.log(format!("[stable] refined {k}: 정착 전 — 모든 자세·내부값 자유, 이전 구역에 맞춘 축척 {scale:.4}"));
644                    }
645                    (None, _) => out.warn(format!("[stable] refined {k}: 앞 구역 결과 없음 — 고정 없이 조정")),
646                }
647            }
648            let sigma = sh.cfg.gps_prior_sigma;
649            for (id, g) in &targets {
650                if !ba.constant_poses.contains(id) {
651                    ba.position_priors.insert(*id, (*g, sigma));
652                }
653            }
654            if !ba.position_priors.is_empty() {
655                out.log(format!("[stable] refined {k}: GPS 위치 제약 {} 개 (σ {sigma} m)", ba.position_priors.len()));
656            }
657        }
658        if let Some(pr) = &sh.cfg.intrinsics_prior {
659            let pr = pr.resolve(&cam_ids);
660            for (cid, cam) in rec.cameras() {
661                if !ba.constant_cameras.contains(cid) {
662                    if let Some(p) = pr.ba_prior(cam) {
663                        ba.intrinsics_priors.insert(*cid, p);
664                    }
665                }
666            }
667            if !ba.intrinsics_priors.is_empty() {
668                out.log(format!(
669                    "[prior] refined {k}: 내부값 사전 카메라 {} 개 (초점 σ {} 상대, 왜곡 σ {})",
670                    ba.intrinsics_priors.len(),
671                    pr.focal_sigma,
672                    pr.dist_sigma
673                ));
674            }
675        }
676        let t0 = Instant::now();
677        let r = bundle_adjust_with(&mut rec, &ba, &*sh.cfg.ba_backend);
678        out.stage("ba", &format!("refined {k}"), t0);
679        match r {
680            Ok(s) => out.log(format!(
681                "[ba] refined {k}: 반복 {} 비용 {:.4e} → {:.4e} ({:?}), RMS {:.3}px",
682                s.num_iterations,
683                s.initial_cost,
684                s.final_cost,
685                s.termination,
686                s.rms_reprojection_error()
687            )),
688            Err(e) => out.warn(format!("[ba] refined {k} 실패: {e}")),
689        }
690        // Settling BA moved the whole model (gauge freedom); bring it back into the previously shown frame.
691        if let (true, Some(p)) = (settling && k > 0, &prev) {
692            match fit_shared_points(&rec, p, None, MIN_SHARED_PAIRS) {
693                Some((f, n)) => {
694                    let t = f.sim3;
695                    rec.transform(&t);
696                    out.log(format!(
697                        "[settle] refined {k}: 화면 좌표계 재정렬 공유 점 {n}, 잔차 중앙 {:.3}m, 축척 {:.4}, 이동 {:.2}m",
698                        f.median,
699                        t.scale,
700                        t.translation.norm()
701                    ));
702                }
703                None => match sim3_onto_shown(&mut rec, p) {
704                    Some((t, _)) => out.log(format!(
705                        "[settle] refined {k}: 화면 좌표계 재정렬 (카메라 중심 대체) 축척 {:.4}, 이동 {:.2}m",
706                        t.scale,
707                        t.translation.norm()
708                    )),
709                    None => out.warn(format!("[settle] refined {k}: 화면 좌표계 재정렬 생략: 공통 영상 부족")),
710                },
711            }
712        }
713        let adjusted = Arc::new(rec.clone());
714        out.emit(|meta| Event::ZoneAdjusted { meta, zone: k, model: adjusted });
715        if !sh.cfg.stable_display {
716            sh.align_enu(&mut rec, &format!("refined {k}"), &gps, &mut out);
717        }
718        let st = ModelStats::compute(&rec);
719        // Settle decision (before the pose is published, so the next zone sees it).
720        let mut settle_now = false;
721        if settling {
722            let change = prev.as_ref().map(|p| intrinsics_change(p.cameras(), rec.cameras()));
723            let forced = k >= sh.cfg.settle_max_zone;
724            settle_now = forced || change.is_some_and(|c| c < sh.cfg.settle_tol);
725            let verdict = match (settle_now, forced) {
726                (true, true) => "강제 고정",
727                (true, false) => "고정",
728                _ => "정착 중",
729            };
730            match change {
731                Some(c) => out.log(format!("[settle] refined {k}: 내부값 변화 {:.2}% → {verdict}", c * 100.0)),
732                None => out.log(format!("[settle] refined {k}: → {verdict}")),
733            }
734        }
735        let model = Arc::new(rec);
736        {
737            let mut bg = sh.bg();
738            if bg.generation.get(&k).copied().unwrap_or(0) == generation {
739                if settle_now {
740                    bg.settled = Some(k);
741                }
742                bg.ready.insert(k, RefinedZone { range, model: model.clone(), cloud: None, done: false, generation });
743            }
744        }
745        let m = model.clone();
746        out.emit(|meta| Event::ZoneRefinedPose {
747            meta,
748            range,
749            registered: st.registered_image_count,
750            mean_reproj_px: st.mean_reprojection_error,
751            model: m,
752        });
753        let cloud = sh.dense_region("refined", k, &model, &keep, &mut out);
754        {
755            let mut bg = sh.bg();
756            if bg.generation.get(&k).copied().unwrap_or(0) == generation {
757                if let Some(z) = bg.ready.get_mut(&k).filter(|z| z.generation == generation) {
758                    z.cloud = cloud.clone();
759                    z.done = true;
760                }
761                bg.dense_basis.insert(k, model.clone());
762            }
763        }
764        let m = model.clone();
765        out.emit(|meta| Event::ZoneRefined { meta, range, dense: cloud.is_some(), cloud: cloud.unwrap_or_default(), model: m });
766        // Still settling: the earlier cameras moved with this adjustment, so the zones already shown are placed again.
767        if settling && k > 0 {
768            sh.replace_shown(k, generation, &model, &st);
769        }
770    }
771}
772
773/// Event exit (foreground: return vector, background: receiver/inbox). The background drops events when the zone generation changes.
774struct Out<'a> {
775    sh: &'a Shared,
776    fg: Option<&'a mut Vec<Event>>,
777    guard: Option<(usize, u64)>,
778}
779
780impl Out<'_> {
781    fn emit(&mut self, make: impl FnOnce(Meta) -> Event) {
782        if let Some((k, g)) = self.guard {
783            if self.sh.generation(k) != g {
784                return;
785            }
786        }
787        self.sh.emitter.emit(make, self.fg.as_deref_mut());
788    }
789
790    fn log(&mut self, line: impl Into<String>) {
791        let line = line.into();
792        self.emit(|meta| Event::Log { meta, line, stamped: false });
793    }
794
795    fn stamped(&mut self, line: impl Into<String>) {
796        let line = line.into();
797        self.emit(|meta| Event::Log { meta, line, stamped: true });
798    }
799
800    /// run.log line + a warning with the same content.
801    fn warn(&mut self, line: String) {
802        self.log(line.clone());
803        self.emit(|meta| Event::Warning { meta, message: line });
804    }
805
806    /// Accumulate stage time + a `[time] stage target: seconds` line.
807    fn stage(&mut self, stage: &str, what: &str, t0: Instant) {
808        let d = t0.elapsed();
809        self.sh.times.add(stage, d);
810        self.stamped(format!("[time] {stage} {what}: {:.3}s", d.as_secs_f64()));
811    }
812}
813
814// ---------------------------------------------------------------- State value
815
816#[derive(Clone)]
817struct Checkpoint {
818    /// The state right after processing up to this position.
819    position: usize,
820    chain: Option<Reconstruction>,
821    adopted: Option<usize>,
822    cam_ids: Arc<BTreeMap<String, CameraId>>,
823    graph: Arc<MatchGraph>,
824    gps_len: usize,
825}
826
827/// Incremental reconstruction state value.
828#[derive(Clone)]
829pub struct Session {
830    shared: Arc<Shared>,
831    store: Arc<FeatureStore>,
832    graph: Arc<MatchGraph>,
833    frames: Arc<Vec<Vec<FrameImage>>>,
834    position_of: Arc<HashMap<String, usize>>,
835    ingested: usize,
836    cam_ids: Arc<BTreeMap<String, CameraId>>,
837    gps: Arc<Vec<GpsRecord>>,
838    chain: Option<Reconstruction>,
839    adopted: Option<usize>,
840    arrived: BTreeMap<usize, ZoneRange>,
841    previews: Arc<BTreeMap<usize, PreviewZone>>,
842    checkpoints: VecDeque<Checkpoint>,
843    started: bool,
844    finished: bool,
845    failed: Option<String>,
846    /// Positions whose frames were already emitted (so the same bundle is not emitted twice).
847    announced: Option<usize>,
848}
849
850/// The new state and events after processing the next frame bundle.
851pub fn step(session: &Session, frames: FrameSet) -> (Session, Vec<Event>) {
852    session.clone().advance(Input::Frames(frames))
853}
854
855/// Collect background refinement events (when there is no receiver). The state does not change.
856pub fn poll(session: &Session) -> (Session, Vec<Event>) {
857    session.clone().advance(Input::Poll)
858}
859
860/// Command handling.
861pub fn command(session: &Session, cmd: Command) -> (Session, Vec<Event>) {
862    session.clone().advance(Input::Command(cmd))
863}
864
865/// Close the remaining zones -> wait for background jobs -> summary.
866pub fn finish(session: &Session) -> (Session, Vec<Event>) {
867    session.clone().advance(Input::Finish)
868}
869
870impl Session {
871    /// Create an empty session from the settings.
872    pub fn new(cfg: SessionConfig) -> Self {
873        let extractor = FeatureExtractor::with_backend(cfg.sift.clone());
874        let gps = Arc::new(cfg.gps.clone());
875        let shared = Arc::new(Shared {
876            cfg,
877            extractor,
878            emitter: Emitter(Mutex::new(EmitState { version: 0, sink: None, inbox: Vec::new() })),
879            times: StageTimes::default(),
880            bg: Mutex::new(Background::default()),
881            start: Mutex::new(None),
882        });
883        Self {
884            shared,
885            store: Arc::new(FeatureStore::new()),
886            graph: Arc::new(MatchGraph::new()),
887            frames: Arc::new(Vec::new()),
888            position_of: Arc::new(HashMap::new()),
889            ingested: 0,
890            cam_ids: Arc::new(BTreeMap::new()),
891            gps,
892            chain: None,
893            adopted: None,
894            arrived: BTreeMap::new(),
895            previews: Arc::new(BTreeMap::new()),
896            checkpoints: VecDeque::new(),
897            started: false,
898            announced: None,
899            finished: false,
900            failed: None,
901        }
902    }
903
904    /// Set/clear the immediate-delivery receiver (shared by the session lineage). When set, background events accumulated so far are
905    /// delivered first.
906    pub fn set_sink(&self, sink: Option<EventSink>) {
907        let mut st = self.shared.emitter.lock();
908        if let Some(s) = &sink {
909            for ev in std::mem::take(&mut st.inbox) {
910                s(ev);
911            }
912        }
913        st.sink = sink;
914    }
915
916    /// Emit an event from outside (e.g. a hook runner) in this session's version order. If there is no receiver, it accumulates in the
917    /// inbox.
918    pub fn emit_external(&self, make: impl FnOnce(Meta) -> Event) {
919        self.shared.emitter.emit(make, None);
920    }
921
922    /// State transition (owned version, no copying). `step`/`poll`/`command`/`finish` are thin wrappers around this.
923    pub fn advance(mut self, input: Input) -> (Session, Vec<Event>) {
924        let evs = self.apply(input);
925        (self, evs)
926    }
927
928    /// In-place state transition. Returned events are in version order (events emitted by background jobs in the meantime are also
929    /// inserted in place).
930    pub fn apply(&mut self, input: Input) -> Vec<Event> {
931        let sh = self.shared.clone();
932        let mut evs: Vec<Event> = Vec::new();
933        {
934            let mut out = Out { sh: &sh, fg: Some(&mut evs), guard: None };
935            if self.finished {
936                if !matches!(input, Input::Poll) {
937                    out.emit(|meta| Event::Warning { meta, message: "세션이 이미 끝남 — 입력 무시".into() });
938                }
939            } else {
940                if !matches!(input, Input::Poll) {
941                    self.ensure_started(&mut out);
942                }
943                match input {
944                    Input::Frames(fs) => {
945                        self.announce(&fs, &mut out);
946                        if let Some(e) = &self.failed {
947                            let message = format!("이전 오류로 중단된 세션 — 프레임 무시 ({e})");
948                            out.emit(|meta| Event::Warning { meta, message });
949                        } else if let Err(e) = self.process_frames(fs, &mut out) {
950                            self.failed = Some(e.clone());
951                            out.emit(|meta| Event::Error { meta, message: e });
952                        }
953                    }
954                    Input::Command(c) => self.apply_command(c, &mut out),
955                    Input::Poll => {}
956                    Input::Log(line) => out.log(line),
957                    Input::Finish => {
958                        if self.failed.is_none() {
959                            self.finish_session(&mut out);
960                        }
961                        self.finished = true;
962                    }
963                }
964            }
965        }
966        // Merge, in version order, the events that background jobs put into the inbox during (and before) this call.
967        let bg = std::mem::take(&mut sh.emitter.lock().inbox);
968        if !bg.is_empty() {
969            evs.extend(bg);
970            evs.sort_by_key(|e| e.meta().version);
971        }
972        evs
973    }
974
975    // ------------------------------------------------------------ Queries
976
977    /// Number of processed positions.
978    pub fn positions(&self) -> usize {
979        self.frames.len()
980    }
981    /// Frame bundle per position.
982    pub fn frames(&self) -> &[Vec<FrameImage>] {
983        &self.frames
984    }
985    /// Position of an image name.
986    pub fn position_of(&self, name: &str) -> Option<usize> {
987        self.position_of.get(name).copied()
988    }
989    /// Current chain model (None before the first model).
990    pub fn model(&self) -> Option<&Reconstruction> {
991        self.chain.as_ref()
992    }
993    /// The last refined zone adopted as the chain reference.
994    pub fn adopted(&self) -> Option<usize> {
995        self.adopted
996    }
997    /// Feature store.
998    pub fn store(&self) -> &FeatureStore {
999        &self.store
1000    }
1001    /// Correspondence graph.
1002    pub fn graph(&self) -> &MatchGraph {
1003        &self.graph
1004    }
1005    /// Session settings.
1006    pub fn config(&self) -> &SessionConfig {
1007        &self.shared.cfg
1008    }
1009    /// Zones that have arrived (been processed).
1010    pub fn arrived_zones(&self) -> Vec<ZoneRange> {
1011        self.arrived.values().copied().collect()
1012    }
1013    /// Per-zone preview results.
1014    pub fn previews(&self) -> &BTreeMap<usize, PreviewZone> {
1015        &self.previews
1016    }
1017    /// Per-zone refined results (those with finalized poses, the current value shared with background jobs).
1018    pub fn refined(&self) -> BTreeMap<usize, RefinedZone> {
1019        self.shared.bg().ready.clone()
1020    }
1021    /// Number of background refinement jobs not yet finished.
1022    pub fn pending_jobs(&self) -> usize {
1023        self.shared.bg().jobs.iter().filter(|j| !j.is_finished()).count()
1024    }
1025    /// The last event version issued.
1026    pub fn version(&self) -> u64 {
1027        self.shared.emitter.lock().version
1028    }
1029    /// Fatal error (feature extraction, matching, first model failure). If set, later frames are ignored.
1030    pub fn failed(&self) -> Option<&str> {
1031        self.failed.as_deref()
1032    }
1033    /// Whether `Finish` was handled.
1034    pub fn is_finished(&self) -> bool {
1035        self.finished
1036    }
1037
1038    // ------------------------------------------------------------ Processing
1039
1040    fn ensure_started(&mut self, out: &mut Out) {
1041        if self.started {
1042            return;
1043        }
1044        self.started = true;
1045        *self.shared.start.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
1046        let positions = self.shared.cfg.total_positions;
1047        out.emit(|meta| Event::Started { meta, positions });
1048    }
1049
1050    /// Decode the next position's frames to RGB and emit them as [`Event::FrameDecoded`] (once per position, cameras in parallel).
1051    fn announce(&mut self, fs: &FrameSet, out: &mut Out) {
1052        let sh = self.shared.clone();
1053        let p = self.frames.len();
1054        if !sh.cfg.decode_frames || self.finished || self.failed.is_some() || self.announced == Some(p) {
1055            return;
1056        }
1057        self.ensure_started(out);
1058        self.announced = Some(p);
1059        let decoded: Vec<Result<Frame, String>> = fs
1060            .images
1061            .par_iter()
1062            .map(|im| {
1063                let path = sh.cfg.image_root.join(&im.name);
1064                let img = image::ImageReader::open(&path)
1065                    .and_then(|r| r.with_guessed_format())
1066                    .map_err(|e| format!("{}: {e}", path.display()))?
1067                    .decode()
1068                    .map_err(|e| format!("{}: 디코딩 실패: {e}", path.display()))?
1069                    .into_rgb8();
1070                let gps = fs.gps.iter().chain(self.gps.iter()).find(|g| g.name == im.name).cloned();
1071                let (width, height) = img.dimensions();
1072                Ok(Frame {
1073                    position: p,
1074                    camera: im.camera.clone(),
1075                    name: im.name.clone(),
1076                    path,
1077                    width,
1078                    height,
1079                    rgb: Arc::from(img.into_raw()),
1080                    gps,
1081                })
1082            })
1083            .collect();
1084        for d in decoded {
1085            match d {
1086                Ok(f) => {
1087                    let frame = Arc::new(f);
1088                    out.emit(|meta| Event::FrameDecoded { meta, frame });
1089                }
1090                Err(e) => out.emit(|meta| Event::Warning { meta, message: format!("[frame] {e}") }),
1091            }
1092        }
1093    }
1094
1095    fn process_frames(&mut self, fs: FrameSet, out: &mut Out) -> Result<(), String> {
1096        let sh = self.shared.clone();
1097        let cfg = &sh.cfg;
1098        let p = self.frames.len();
1099        let names: Vec<String> = fs.images.iter().map(|i| i.name.clone()).collect();
1100        {
1101            let pos = Arc::make_mut(&mut self.position_of);
1102            for n in &names {
1103                pos.insert(n.clone(), p);
1104            }
1105        }
1106        Arc::make_mut(&mut self.frames).push(fs.images.clone());
1107        self.ingested += fs.images.len();
1108        if !fs.gps.is_empty() {
1109            Arc::make_mut(&mut self.gps).extend(fs.gps);
1110        }
1111        out.emit(|meta| Event::FrameIngested { meta, position: p, images: names });
1112
1113        // 1) Features: for the first bundle, one camera per folder; afterwards the same camera id.
1114        let t0 = Instant::now();
1115        let r = self.extract(p, &fs.images, out);
1116        out.stage("feature", &format!("pos {p}"), t0);
1117        r?;
1118
1119        // 2) Match only pairs that involve a new image.
1120        let pairs: Vec<(ImageId, ImageId)> = pairs_for(&self.frames, p)
1121            .into_iter()
1122            .filter_map(|(a, b)| Some((self.store.image_id_by_name(&a)?, self.store.image_id_by_name(&b)?)))
1123            .collect();
1124        let mut mopts = PairMatchingOptions::default();
1125        mopts.geometry.ransac.random_seed = cfg.seed;
1126        let t0 = Instant::now();
1127        let st = match_pairs_with(&self.store, &pairs, &mopts, cfg.matcher.as_ref(), cfg.sparse.two_view.as_ref());
1128        out.stage("matching", &format!("pos {p} ({} pairs)", pairs.len()), t0);
1129        let st = st.map_err(|e| e.to_string())?;
1130        Arc::make_mut(&mut self.graph).update_from_store(&self.store, &MatchGraphOptions::default());
1131        out.log(format!(
1132            "[match] pos {p}: 짝 {} 매칭 {} 유효 기하 {} (그래프 영상 {})",
1133            pairs.len(),
1134            st.num_matched,
1135            st.num_valid_geometries,
1136            self.graph.image_ids().len()
1137        ));
1138        let (np, nv, nm) = (pairs.len(), st.num_valid_geometries, st.num_matched);
1139        out.emit(|meta| Event::PairsMatched { meta, pairs: np, verified: nv, position: p, matched: nm });
1140
1141        // 3) Registration.
1142        let (span, ovl) = (cfg.span, cfg.overlap);
1143        if p + 1 < span + ovl {
1144            self.checkpoint(p);
1145            return Ok(());
1146        }
1147        if p + 1 == span + ovl {
1148            let t0 = Instant::now();
1149            let gopts = GlobalSfmOptions { ba_backend: BaBackendRef::new(cfg.ba_backend.clone()), ..GlobalSfmOptions::script() };
1150            let o = global_mapper(&self.store, &self.graph, &gopts);
1151            out.stage("mapper", &format!("pos {p}"), t0);
1152            let o = o.map_err(|e| e.to_string())?;
1153            if let Some(f) = &o.failure {
1154                out.warn(format!("[mapper] 실패 보고: {f}"));
1155            }
1156            out.log(format!("[mapper] {:?}", o.summary));
1157            let rec = o.reconstruction;
1158            let model = Arc::new(rec.clone());
1159            let registered = rec.registered_image_count();
1160            out.emit(|meta| Event::ModelInitialized { meta, registered, position: p, model });
1161            let ids = rec.registered_images();
1162            self.emit_registered(&rec, &ids, out);
1163            self.chain = Some(rec);
1164        } else {
1165            // If a new refined model has appeared, continue registering on top of it.
1166            let mut k = self.adopted.map_or(0, |a| a + 1);
1167            loop {
1168                let r = sh.bg().ready.get(&k).map(|z| z.model.clone());
1169                let Some(r) = r else { break };
1170                self.chain = Some((*r).clone());
1171                self.adopted = Some(k);
1172                out.emit(|meta| Event::BaseAdopted { meta, zone: k });
1173                k += 1;
1174            }
1175            if let Some(base) = &self.chain {
1176                let mut reg = base.clone();
1177                let t0 = Instant::now();
1178                let rr =
1179                    register_images_with(&mut reg, &self.store, &self.graph, &RegistrationOptions::default(), cfg.sparse.abs_pose.as_ref());
1180                out.stage("registration", &format!("pos {p}"), t0);
1181                match rr {
1182                    Ok(rep) => {
1183                        let new_ids = rep.registered();
1184                        out.log(format!("[register] pos {p}: 시도 {} 등록 {}", rep.attempts.len(), new_ids.len()));
1185                        let scope = if cfg.incremental_triangulation {
1186                            TriangulationScope::Images(new_ids.clone())
1187                        } else {
1188                            TriangulationScope::AllRegistered
1189                        };
1190                        let topts = PointTriangulatorOptions { clear_points: false, scope, ..Default::default() };
1191                        let t0 = Instant::now();
1192                        let tr = triangulate_points_with(&mut reg, &self.graph, &topts, cfg.sparse.triangulation.as_ref());
1193                        out.stage("triangulation", &format!("pos {p}"), t0);
1194                        match tr {
1195                            Ok(t) => {
1196                                out.log(format!(
1197                                    "[triangulate] pos {p}: 생성 {} 연장 {} 완성 {} 병합 {} 필터 {} (점 {})",
1198                                    t.num_created,
1199                                    t.num_continued,
1200                                    t.num_completed,
1201                                    t.num_merged,
1202                                    t.num_filtered,
1203                                    reg.num_points3d()
1204                                ));
1205                                self.emit_registered(&reg, &new_ids, out);
1206                                self.chain = Some(reg);
1207                            }
1208                            Err(e) => out.warn(format!("[triangulate] pos {p} 실패: {e} (이전 모델 유지)")),
1209                        }
1210                    }
1211                    Err(e) => out.warn(format!("[register] pos {p} 실패: {e}")),
1212                }
1213            }
1214        }
1215        let registered = self.chain.as_ref().map_or(0, |r| r.registered_image_count());
1216        let expected = self.ingested;
1217        out.emit(|meta| Event::PositionDone { meta, position: p, registered, expected });
1218
1219        // 4) Zone completion -> preview (foreground) + refinement (background).
1220        for z in zones_ending_at(p, span, ovl, cfg.total_positions) {
1221            if !self.arrived.contains_key(&z.zone) {
1222                self.process_zone(z, out);
1223            }
1224        }
1225        self.checkpoint(p);
1226        Ok(())
1227    }
1228
1229    fn extract(&mut self, p: usize, images: &[FrameImage], out: &mut Out) -> Result<(), String> {
1230        let sh = self.shared.clone();
1231        let cfg = &sh.cfg;
1232        if self.cam_ids.is_empty() {
1233            let names: Vec<String> = images.iter().map(|i| i.name.clone()).collect();
1234            let mut o = cfg.extraction.clone();
1235            o.reader.camera_mode = CameraMode::PerFolder;
1236            let rep = sh.extractor.extract_files(&self.store, &cfg.image_root, &names, &o).map_err(|e| e.to_string())?;
1237            report_extraction(&rep, p, out);
1238            let cams = Arc::make_mut(&mut self.cam_ids);
1239            for im in images {
1240                let s = self.store.image_by_name(&im.name).ok_or_else(|| format!("{} 첫 영상 특징 추출 실패", im.camera))?;
1241                cams.insert(im.camera.clone(), s.camera_id);
1242            }
1243            self.apply_intrinsics_prior(out)?;
1244        } else {
1245            for im in images {
1246                let mut o = cfg.extraction.clone();
1247                let known = self.cam_ids.get(&im.camera).copied();
1248                o.reader.camera_mode = known.map_or(CameraMode::PerFolder, CameraMode::Existing);
1249                let rep = sh
1250                    .extractor
1251                    .extract_files(&self.store, &cfg.image_root, std::slice::from_ref(&im.name), &o)
1252                    .map_err(|e| e.to_string())?;
1253                report_extraction(&rep, p, out);
1254                if known.is_none() {
1255                    if let Some(s) = self.store.image_by_name(&im.name) {
1256                        Arc::make_mut(&mut self.cam_ids).insert(im.camera.clone(), s.camera_id);
1257                    }
1258                    self.apply_intrinsics_prior(out)?;
1259                }
1260            }
1261        }
1262        Ok(())
1263    }
1264
1265    /// Soft intrinsics prior: sets the store's cameras to the prior values (before any registration or model uses them).
1266    /// A camera without an entry is left alone; an entry whose length differs from the camera model's is an error.
1267    fn apply_intrinsics_prior(&self, out: &mut Out) -> Result<(), String> {
1268        let Some(pr) = &self.shared.cfg.intrinsics_prior else { return Ok(()) };
1269        let pr = pr.resolve(&self.cam_ids);
1270        for mut cam in self.store.cameras() {
1271            let before = cam.params.clone();
1272            if pr.apply(&mut cam)? && cam.params != before {
1273                out.log(format!("[prior] 카메라 {}: 내부값 초기화 {} → {}", cam.camera_id, fmt_params(&before), fmt_params(&cam.params)));
1274                self.store.update_camera(cam).map_err(|e| e.to_string())?;
1275            }
1276        }
1277        Ok(())
1278    }
1279
1280    fn emit_registered(&self, rec: &Reconstruction, ids: &[ImageId], out: &mut Out) {
1281        for &id in ids {
1282            let (Some(im), Some(pose)) = (rec.image(id), rec.world_to_cam(id)) else { continue };
1283            let image = im.name.clone();
1284            let position = self.position_of(&image).unwrap_or(usize::MAX);
1285            out.emit(|meta| Event::FrameRegistered { meta, position, image, image_id: id, cam_from_world: pose });
1286        }
1287    }
1288
1289    /// Zone arrival: preview (foreground, only zone 0 is GPS-aligned) + start the refinement job (background).
1290    fn process_zone(&mut self, range: ZoneRange, out: &mut Out) {
1291        let sh = self.shared.clone();
1292        let k = range.zone;
1293        let snap = self.chain.clone().unwrap_or_default();
1294        let m = Arc::new(snap.clone());
1295        out.emit(|meta| Event::ZoneArrived { meta, range, model: m });
1296        if sh.cfg.stable_display {
1297            let keep: Arc<HashSet<String>> =
1298                Arc::new(self.position_of.iter().filter(|(_, p)| **p >= range.lo && **p < range.hi).map(|(n, _)| n.clone()).collect());
1299            let generation = sh.generation(k);
1300            let (sh2, gps, cam_ids) = (sh.clone(), self.gps.clone(), self.cam_ids.clone());
1301            let h = std::thread::spawn(move || sh2.refine_job(range, snap, generation, gps, keep, cam_ids));
1302            sh.bg().jobs.push(h);
1303            self.arrived.insert(k, range);
1304            return;
1305        }
1306        let mut preview = snap.clone();
1307        let mut frame = match self.adopted {
1308            Some(j) => format!("refined_{j}"),
1309            None => "unaligned".to_string(),
1310        };
1311        if k == 0 && sh.align_enu(&mut preview, "preview 0", &self.gps, out) {
1312            frame = "gps".to_string();
1313        }
1314        let keep: Arc<HashSet<String>> =
1315            Arc::new(self.position_of.iter().filter(|(_, p)| **p >= range.lo && **p < range.hi).map(|(n, _)| n.clone()).collect());
1316        let generation = sh.generation(k);
1317        {
1318            let (sh2, gps, keep, cam_ids) = (sh.clone(), self.gps.clone(), keep.clone(), self.cam_ids.clone());
1319            let h = std::thread::spawn(move || sh2.refine_job(range, snap, generation, gps, keep, cam_ids));
1320            sh.bg().jobs.push(h);
1321        }
1322        let cloud = sh.dense_region("preview", k, &preview, &keep, out);
1323        let model = Arc::new(preview);
1324        self.arrived.insert(k, range);
1325        Arc::make_mut(&mut self.previews)
1326            .insert(k, PreviewZone { range, model: model.clone(), cloud: cloud.clone(), frame: frame.clone() });
1327        out.emit(|meta| Event::ZonePreview { meta, range, dense: cloud.is_some(), cloud: cloud.unwrap_or_default(), model, frame });
1328    }
1329
1330    fn checkpoint(&mut self, position: usize) {
1331        let n = self.shared.cfg.history;
1332        if n == 0 {
1333            return;
1334        }
1335        self.checkpoints.push_back(Checkpoint {
1336            position,
1337            chain: self.chain.clone(),
1338            adopted: self.adopted,
1339            cam_ids: self.cam_ids.clone(),
1340            graph: self.graph.clone(),
1341            gps_len: self.gps.len(),
1342        });
1343        while self.checkpoints.len() > n {
1344            self.checkpoints.pop_front();
1345        }
1346    }
1347
1348    fn drop_zone(&mut self, k: usize, out: &mut Out) {
1349        self.arrived.remove(&k);
1350        Arc::make_mut(&mut self.previews).remove(&k);
1351        self.shared.invalidate(k);
1352        out.emit(|meta| Event::ZoneInvalidated { meta, zone: k });
1353    }
1354
1355    fn apply_command(&mut self, cmd: Command, out: &mut Out) {
1356        match cmd {
1357            Command::ResetFrom(p) => self.reset_from(p, out),
1358            Command::InvalidateZone(k) => {
1359                let Some(range) = self.arrived.get(&k).copied() else {
1360                    out.emit(|meta| Event::Warning {
1361                        meta, message: format!("구역 {k} 은 아직 도착하지 않음 — 무효화할 것 없음")
1362                    });
1363                    return;
1364                };
1365                self.drop_zone(k, out);
1366                // Rebuild with the current chain.
1367                self.process_zone(range, out);
1368            }
1369        }
1370    }
1371
1372    /// Discard the state after position p: roll the store, graph and chain back to right after position p-1, and invalidate the zones
1373    /// closed after that.
1374    fn reset_from(&mut self, p: usize, out: &mut Out) {
1375        let npos = self.frames.len();
1376        if p > npos {
1377            out.emit(|meta| Event::Warning { meta, message: format!("ResetFrom({p}): 처리한 위치 {npos} 보다 큼 — 무시") });
1378            return;
1379        }
1380        let cp = if p == 0 {
1381            None
1382        } else {
1383            match self.checkpoints.iter().find(|c| c.position + 1 == p) {
1384                Some(c) => Some(c.clone()),
1385                None => {
1386                    out.emit(|meta| Event::Warning {
1387                        meta, message: format!("ResetFrom({p}): 되감기 지점 없음(history 부족) — 무시")
1388                    });
1389                    return;
1390                }
1391            }
1392        };
1393        let gone: Vec<usize> = self.arrived.iter().filter(|(_, r)| r.hi.saturating_sub(1) >= p).map(|(k, _)| *k).collect();
1394        for k in gone {
1395            self.drop_zone(k, out);
1396        }
1397        let pos = self.position_of.clone();
1398        self.store = Arc::new(self.store.retain_copy(|im| pos.get(&im.name).is_some_and(|q| *q < p)));
1399        Arc::make_mut(&mut self.frames).truncate(p);
1400        Arc::make_mut(&mut self.position_of).retain(|_, q| *q < p);
1401        self.ingested = self.frames.iter().map(|f| f.len()).sum();
1402        self.checkpoints.retain(|c| c.position < p);
1403        match cp {
1404            Some(c) => {
1405                self.chain = c.chain;
1406                self.adopted = c.adopted;
1407                self.cam_ids = c.cam_ids;
1408                self.graph = c.graph;
1409                Arc::make_mut(&mut self.gps).truncate(c.gps_len);
1410            }
1411            None => {
1412                self.chain = None;
1413                self.adopted = None;
1414                self.cam_ids = Arc::new(BTreeMap::new());
1415                self.graph = Arc::new(MatchGraph::new());
1416                self.gps = Arc::new(self.shared.cfg.gps.clone());
1417            }
1418        }
1419        out.emit(|meta| Event::Reset { meta, position: p });
1420    }
1421
1422    fn finish_session(&mut self, out: &mut Out) {
1423        let sh = self.shared.clone();
1424        let (span, ovl) = (sh.cfg.span, sh.cfg.overlap);
1425        // If the total count was unknown, close the remaining zones at the last position.
1426        let npos = self.frames.len();
1427        if npos > 0 && npos >= span + ovl {
1428            for z in zones_upto(npos, span, ovl) {
1429                if !self.arrived.contains_key(&z.zone) {
1430                    self.process_zone(z, out);
1431                }
1432            }
1433        }
1434        out.emit(|meta| Event::AllPositionsDone { meta });
1435        loop {
1436            let jobs = std::mem::take(&mut sh.bg().jobs);
1437            if jobs.is_empty() {
1438                break;
1439            }
1440            for j in jobs {
1441                let _ = j.join();
1442            }
1443        }
1444        let chain = match (&self.chain, sh.cfg.stable_display) {
1445            (Some(c), true) => {
1446                // Final outputs in ENU through one GPS alignment of a copy of the whole model (with or without GPS priors the
1447                // display frame is the first shown zone's frame); the display chain itself stays untouched. On failure the
1448                // display-frame chain is handed on.
1449                let mut enu = c.clone();
1450                if sh.align_enu(&mut enu, "final (stable display)", &self.gps, out) {
1451                    // Display → ENU scale on the camera centers: close to the zone-0 initial ENU fit (1.0 when the display frame
1452                    // stayed ENU through the GPS priors); a large gap means the frame drifted.
1453                    match display_to_enu_scale(c, &enu) {
1454                        Some(s) => out.log(format!("[stable] 표시→ENU 축척 {s:.4} (1.0 이면 표시 좌표계가 ENU 와 같음)")),
1455                        None => out.log("[stable] 표시→ENU 축척: 공통 영상 부족".to_string()),
1456                    }
1457                    Some(Arc::new(enu))
1458                } else {
1459                    out.warn(
1460                        "[align] final (stable display): GPS 정렬 실패 — 표시 좌표계 모델을 그대로 넘김(final_frame/ 은 표시 좌표계)"
1461                            .to_string(),
1462                    );
1463                    Some(Arc::new(c.clone()))
1464                }
1465            }
1466            (c, _) => c.clone().map(Arc::new),
1467        };
1468        out.emit(|meta| Event::AllRefinedDone { meta, chain });
1469        let pipeline_secs = sh.start.lock().unwrap_or_else(|e| e.into_inner()).map_or(0.0, |t| t.elapsed().as_secs_f64());
1470        let stage_times = sh.times.snapshot().into_iter().map(|(k, (d, n))| (k, d, n)).collect();
1471        let chain_stats = self.chain.as_ref().map(|c| ModelStats::compute(c).lines()).unwrap_or_default();
1472        out.emit(|meta| Event::Finished { meta, pipeline_secs, stage_times, chain_stats });
1473    }
1474}
1475
1476fn report_extraction(rep: &[ImageReport], position: usize, out: &mut Out) {
1477    for r in rep {
1478        match &r.status {
1479            ImageStatus::Extracted { image_id, camera_id, num_features } => {
1480                out.log(format!("[feature] {} id {image_id} cam {camera_id} 특징 {num_features}", r.name));
1481                let (image, image_id, count) = (r.name.clone(), *image_id, *num_features);
1482                out.emit(|meta| Event::FeaturesExtracted { meta, image, count, position, image_id });
1483            }
1484            ImageStatus::AlreadyExists { image_id } => out.log(format!("[feature] {} id {image_id} 이미 있음", r.name)),
1485            ImageStatus::Failed { error } => out.warn(format!("[feature] {} 실패: {error}", r.name)),
1486        }
1487    }
1488}
1489
1490fn fmt_params(p: &[f64]) -> String {
1491    format!("[{}]", p.iter().map(|x| format!("{x:.5}")).collect::<Vec<_>>().join(" "))
1492}
1493
1494/// Scale of the Sim3 (Umeyama on the projection centers of the images registered in both) taking `display` to `enu`.
1495fn display_to_enu_scale(display: &Reconstruction, enu: &Reconstruction) -> Option<f64> {
1496    let (mut src, mut dst) = (Vec::new(), Vec::new());
1497    for id in display.registered_images() {
1498        let name = display.image(id)?.name.clone();
1499        let Some(eid) = enu.image_by_name(&name).map(|im| im.image_id) else { continue };
1500        if let (Some(a), Some(b)) = (display.projection_center(id), enu.projection_center(eid)) {
1501            src.push(a);
1502            dst.push(b);
1503        }
1504    }
1505    if src.len() < 3 {
1506        return None;
1507    }
1508    cumulus3d_align::umeyama(&src, &dst, true).map(|t| t.scale)
1509}
1510
1511/// One log line for a GPS fit whose positions were collinear.
1512fn gravity_note(what: &str, g: &cumulus3d_align::GravityResolution) -> String {
1513    use cumulus3d_align::GravityPrior;
1514    let prior = match g.prior {
1515        GravityPrior::Ground { inliers } => format!("지면 평면(점 {inliers} 개)"),
1516        GravityPrior::ViewDirection => "카메라 시선(아래 방향)".to_string(),
1517        GravityPrior::None => "사용 불가 — 회전 그대로".to_string(),
1518    };
1519    format!("[gravity] {what}: GPS 가 거의 일직선(σ2/σ1 {:.3}) — 직선 둘레 회전을 {prior} 로 결정", g.ratio)
1520}
1521
1522/// ENU positions (origin = first GPS record) of the registered images of `rec` that have a GPS record.
1523fn enu_targets(rec: &Reconstruction, gps: &[GpsRecord]) -> HashMap<ImageId, cumulus3d_core::Vec3> {
1524    let Some(o) = gps.first() else { return HashMap::new() };
1525    let frame = cumulus3d_align::EnuFrame::new(o.lat, o.lon, o.alt);
1526    let by_name: HashMap<&str, &GpsRecord> = gps.iter().map(|g| (g.name.as_str(), g)).collect();
1527    rec.registered_images()
1528        .into_iter()
1529        .filter_map(|id| {
1530            let g = by_name.get(rec.image(id)?.name.as_str())?;
1531            Some((id, frame.lla_to_enu(g.lat, g.lon, g.alt)))
1532        })
1533        .collect()
1534}
1535
1536/// Brings `rec` into the frame of the already shown model `shown` (Sim3 on the projection centers of the images registered
1537/// in both). Returns the Sim3 and, for each shared image, its pose in `shown`.
1538fn sim3_onto_shown(rec: &mut Reconstruction, shown: &Reconstruction) -> Option<(cumulus3d_core::Sim3, Vec<(ImageId, Rigid3)>)> {
1539    let mut pairs = Vec::new();
1540    for id in rec.registered_images() {
1541        let Some(name) = rec.image(id).map(|im| im.name.clone()) else { continue };
1542        let Some(sid) = shown.image_by_name(&name).map(|im| im.image_id) else { continue };
1543        if let (Some(a), Some(b), Some(pose)) = (rec.projection_center(id), shown.projection_center(sid), shown.world_to_cam(sid)) {
1544            pairs.push((id, a, b, pose));
1545        }
1546    }
1547    let (src, dst): (Vec<_>, Vec<_>) = pairs.iter().map(|(_, a, b, _)| (*a, *b)).unzip();
1548    let t = cumulus3d_align::umeyama(&src, &dst, true)?;
1549    rec.transform(&t);
1550    Some((t, pairs.into_iter().map(|(id, _, _, pose)| (id, pose)).collect()))
1551}
1552
1553/// Stable display, settled: brings `rec` into the frame of the already shown model `shown`, then copies the shown poses and
1554/// the camera intrinsics exactly and marks them constant in `ba`. Returns (fixed image count, Sim3 scale).
1555fn anchor_to_shown(rec: &mut Reconstruction, shown: &Reconstruction, ba: &mut BaConfig) -> (usize, f64) {
1556    let Some((t, poses)) = sim3_onto_shown(rec, shown) else { return (0, 1.0) };
1557    for (id, pose) in &poses {
1558        if rec.set_world_to_cam(*id, *pose).is_ok() {
1559            ba.constant_poses.insert(*id);
1560        }
1561    }
1562    for (cid, cam) in shown.cameras() {
1563        if let Some(c) = rec.camera_mut(*cid) {
1564            *c = cam.clone();
1565            ba.constant_cameras.insert(*cid);
1566        }
1567    }
1568    (ba.constant_poses.len(), t.scale)
1569}
1570
1571/// Fewest shared 3D points for which a point-based Sim3 fit is trusted over the camera-center fit.
1572const MIN_SHARED_PAIRS: usize = 20;
1573
1574/// Sim3 taking `from` into the frame of `to` (umeyama with scale on the projection centers of the images, accepted by
1575/// `keep`, registered in both). None with fewer than 3 common images.
1576fn sim3_between(from: &Reconstruction, to: &Reconstruction, keep: impl Fn(&str) -> bool) -> Option<cumulus3d_core::Sim3> {
1577    let (mut src, mut dst) = (Vec::new(), Vec::new());
1578    for id in from.registered_images() {
1579        let Some(name) = from.image(id).map(|im| im.name.as_str()).filter(|n| keep(n)) else { continue };
1580        let Some(tid) = to.image_by_name(name).map(|im| im.image_id) else { continue };
1581        if let (Some(a), Some(b)) = (from.projection_center(id), to.projection_center(tid)) {
1582            src.push(a);
1583            dst.push(b);
1584        }
1585    }
1586    if src.len() < 3 {
1587        return None;
1588    }
1589    cumulus3d_align::umeyama(&src, &dst, true)
1590}
1591
1592/// Largest relative change of a focal parameter (fx, fy; just f for single-focal models) over the cameras present in both
1593/// `a` and `b`, as a fraction of `a`'s value. Infinite when a camera changed its model kind; 0 when no camera is shared.
1594pub fn intrinsics_change(a: &BTreeMap<CameraId, Camera>, b: &BTreeMap<CameraId, Camera>) -> f64 {
1595    let mut worst = 0.0f64;
1596    for (id, ca) in a {
1597        let Some(cb) = b.get(id) else { continue };
1598        if ca.model != cb.model {
1599            return f64::INFINITY;
1600        }
1601        for &s in ca.model.focal_slots() {
1602            let (fa, fb) = (ca.params[s], cb.params[s]);
1603            worst = worst.max(if fa != 0.0 {
1604                ((fb - fa) / fa).abs()
1605            } else if fb != 0.0 {
1606                f64::INFINITY
1607            } else {
1608                0.0
1609            });
1610        }
1611    }
1612    worst
1613}
1614
1615/// Pairing rule: same camera at position gaps 1..5, 8, 16 / different cameras at position difference 0..4. Only pairs involving an image
1616/// of position p, as sorted name pairs.
1617pub fn pairs_for(frames: &[Vec<FrameImage>], p: usize) -> Vec<(String, String)> {
1618    let mut pr = BTreeSet::new();
1619    let Some(cur) = frames.get(p) else { return Vec::new() };
1620    for im in cur {
1621        for d in [1usize, 2, 3, 4, 5, 8, 16] {
1622            if p >= d {
1623                if let Some(o) = frames[p - d].iter().find(|x| x.camera == im.camera) {
1624                    pr.insert((o.name.clone(), im.name.clone()));
1625                }
1626            }
1627        }
1628    }
1629    for a in cur {
1630        for fq in &frames[p.saturating_sub(4)..=p] {
1631            for b in fq {
1632                if a.camera == b.camera {
1633                    continue;
1634                }
1635                let (x, y) = (a.name.clone(), b.name.clone());
1636                pr.insert(if x <= y { (x, y) } else { (y, x) });
1637            }
1638        }
1639    }
1640    pr.into_iter().collect()
1641}
1642
1643/// Zones: start = 0, span, ...; [start-ovl, start+span+ovl) intersect [0, total). Completion position = hi-1.
1644pub fn zones_upto(total: usize, span: usize, ovl: usize) -> Vec<ZoneRange> {
1645    let mut v = Vec::new();
1646    let mut start = 0;
1647    let mut k = 0;
1648    while start < total {
1649        v.push(ZoneRange { zone: k, lo: start.saturating_sub(ovl), hi: (start + span + ovl).min(total) });
1650        k += 1;
1651        start += span.max(1);
1652    }
1653    v
1654}
1655
1656/// Zones closed at position p (if the total count is unknown, only zones that are not truncated).
1657fn zones_ending_at(p: usize, span: usize, ovl: usize, total: Option<usize>) -> Vec<ZoneRange> {
1658    let mut v = Vec::new();
1659    let mut k = 0;
1660    loop {
1661        let start = k * span.max(1);
1662        if start > p || total.is_some_and(|n| start >= n) {
1663            break;
1664        }
1665        let hi = total.map_or(start + span + ovl, |n| (start + span + ovl).min(n));
1666        if hi == p + 1 {
1667            v.push(ZoneRange { zone: k, lo: start.saturating_sub(ovl), hi });
1668        }
1669        k += 1;
1670    }
1671    v
1672}
1673
1674#[cfg(test)]
1675mod tests {
1676    use super::*;
1677
1678    fn frames(n: usize) -> Vec<Vec<FrameImage>> {
1679        (0..n)
1680            .map(|p| {
1681                ["camF", "camR", "camL"]
1682                    .iter()
1683                    .map(|c| FrameImage { camera: c.to_string(), name: format!("{c}/{c}_{:04}.jpg", p * 3) })
1684                    .collect()
1685            })
1686            .collect()
1687    }
1688
1689    #[test]
1690    fn zones_match_regions() {
1691        let z = |v: Vec<ZoneRange>| v.into_iter().map(|r| (r.zone, r.lo, r.hi)).collect::<Vec<_>>();
1692        assert_eq!(z(zones_upto(26, 12, 2)), vec![(0, 0, 14), (1, 10, 26), (2, 22, 26)]);
1693        // Collecting the zones closed at each position gives the same list.
1694        let mut all = Vec::new();
1695        for p in 0..26 {
1696            all.extend(zones_ending_at(p, 12, 2, Some(26)));
1697        }
1698        all.sort_by_key(|r| r.zone);
1699        assert_eq!(z(all), z(zones_upto(26, 12, 2)));
1700        // If the total count is unknown, only non-truncated zones are closed at a position.
1701        assert_eq!(z(zones_ending_at(13, 12, 2, None)), vec![(0, 0, 14)]);
1702        assert_eq!(z(zones_ending_at(25, 12, 2, None)), vec![(1, 10, 26)]);
1703        assert!(zones_ending_at(25, 12, 2, Some(40)).iter().any(|r| r.zone == 1));
1704    }
1705
1706    #[test]
1707    fn intrinsics_change_is_max_relative_focal_change() {
1708        use cumulus3d_core::CameraModelKind;
1709        let cam = |id: CameraId, model: CameraModelKind, params: Vec<f64>| Camera::new(id, model, 4000, 3000, params).unwrap();
1710        let single = |id, f: f64| cam(id, CameraModelKind::SingleFocalRadial, vec![f, 2000.0, 1500.0, 0.1]);
1711        let pinhole = |id, fx: f64, fy: f64| cam(id, CameraModelKind::Pinhole, vec![fx, fy, 2000.0, 1500.0]);
1712        let map = |v: Vec<Camera>| v.into_iter().map(|c| (c.camera_id, c)).collect::<BTreeMap<_, _>>();
1713        let a = map(vec![single(1, 1000.0), pinhole(2, 2000.0, 2000.0)]);
1714        assert_eq!(intrinsics_change(&a, &a), 0.0);
1715        // Principal point / distortion changes do not count; the worst focal parameter of any camera does.
1716        let b = map(vec![single(1, 1005.0), pinhole(2, 2000.0, 2040.0), single(3, 1.0)]);
1717        assert!((intrinsics_change(&a, &b) - 0.02).abs() < 1e-12);
1718        let c = map(vec![single(1, 990.0), pinhole(2, 2000.0, 2000.0)]);
1719        assert!((intrinsics_change(&a, &c) - 0.01).abs() < 1e-12);
1720        // No shared camera: nothing changed; model kind change: infinite.
1721        assert_eq!(intrinsics_change(&a, &map(vec![single(9, 5.0)])), 0.0);
1722        assert!(intrinsics_change(&a, &map(vec![pinhole(1, 1000.0, 1000.0)])).is_infinite());
1723    }
1724
1725    #[test]
1726    fn pair_rule() {
1727        let f = frames(20);
1728        assert_eq!(pairs_for(&f, 0).len(), 3);
1729        let p17 = pairs_for(&f, 17);
1730        assert_eq!(p17.len(), 21 + 27);
1731        assert!(p17.contains(&("camF/camF_0003.jpg".to_string(), "camF/camF_0051.jpg".to_string())));
1732    }
1733}