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