Skip to main content

cumulus3d_cli/
ahead.rs

1/*
2 * ahead.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//! Feature extraction ahead of the position loop.
29//!
30//! The position loop of a [`crate::session::Session`] runs feature extraction -> matching -> registration -> triangulation one
31//! position at a time. Feature extraction only needs the images of a position, so it can run for the positions that are about
32//! to arrive while the later stages still work on the earlier ones. A [`Lookahead`] handle gets the frame sets of upcoming
33//! positions ([`Lookahead::hint`]); one worker thread extracts them in arrival order (the images of a position in parallel) into the
34//! session's feature store, at most `limit` positions ahead. When the session reaches such a position it takes the finished
35//! result instead of extracting itself.
36//!
37//! The result is the same as without lookahead: image ids are issued in the same order (positions in arrival order, the images of a
38//! position in frame-set order), cameras are assigned the same way, and the events (`FeaturesExtracted`, `[feature]` log lines) are
39//! emitted when the position is processed, not when it was extracted.
40
41use crate::intrinsics::IntrinsicsPrior;
42use crate::session::{FrameImage, FrameSet};
43use cumulus3d_core::{CameraId, FeatureStore};
44use cumulus3d_features::{CameraMode, ExtractionOptions, FeatureExtractor, ImageReport};
45use std::collections::{BTreeMap, VecDeque};
46use std::path::PathBuf;
47use std::sync::atomic::{AtomicBool, Ordering};
48use std::sync::{Arc, Condvar, Mutex, MutexGuard};
49use std::time::{Duration, Instant};
50
51/// Result of extracting the features of one position.
52#[derive(Clone, Debug)]
53pub(crate) struct PositionExtract {
54    /// Per-image report in frame-set order.
55    pub reports: Vec<ImageReport>,
56    /// run.log lines to write (intrinsics prior applied to new cameras).
57    pub notes: Vec<String>,
58    /// Camera ids per camera name after this position.
59    pub cams: BTreeMap<String, CameraId>,
60    /// Wall time spent on this position (a share of the call when several positions were extracted together).
61    pub elapsed: Duration,
62}
63
64struct Job {
65    images: Vec<FrameImage>,
66    epoch: u64,
67    cancelled: AtomicBool,
68    done: Mutex<Option<Result<PositionExtract, String>>>,
69    cv: Condvar,
70}
71
72impl Job {
73    fn fulfil(&self, r: Result<PositionExtract, String>) {
74        *lock(&self.done) = Some(r);
75        self.cv.notify_all();
76    }
77
78    fn wait(&self) -> Result<PositionExtract, String> {
79        let mut d = lock(&self.done);
80        loop {
81            if let Some(r) = d.as_ref() {
82                return r.clone();
83            }
84            d = self.cv.wait(d).unwrap_or_else(|p| p.into_inner());
85        }
86    }
87}
88
89struct State {
90    store: Arc<FeatureStore>,
91    /// Hinted jobs not yet taken by the session, in hint order.
92    jobs: VecDeque<Arc<Job>>,
93    /// Jobs the worker has not started.
94    queue: VecDeque<Arc<Job>>,
95    epoch: u64,
96    worker_started: bool,
97    closed: bool,
98}
99
100pub(crate) struct AheadCore {
101    extractor: Arc<FeatureExtractor>,
102    root: PathBuf,
103    opts: ExtractionOptions,
104    prior: Option<Arc<IntrinsicsPrior>>,
105    limit: usize,
106    /// Camera ids per camera name; held while a position is extracted, so extraction is serialized (ids in order).
107    cams: Mutex<BTreeMap<String, CameraId>>,
108    st: Mutex<State>,
109    cv: Condvar,
110}
111
112fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
113    m.lock().unwrap_or_else(|p| p.into_inner())
114}
115
116impl AheadCore {
117    pub(crate) fn new(
118        extractor: Arc<FeatureExtractor>,
119        root: PathBuf,
120        opts: ExtractionOptions,
121        prior: Option<Arc<IntrinsicsPrior>>,
122        limit: usize,
123        store: Arc<FeatureStore>,
124    ) -> Arc<Self> {
125        Arc::new(Self {
126            extractor,
127            root,
128            opts,
129            prior,
130            limit,
131            cams: Mutex::new(BTreeMap::new()),
132            st: Mutex::new(State { store, jobs: VecDeque::new(), queue: VecDeque::new(), epoch: 0, worker_started: false, closed: false }),
133            cv: Condvar::new(),
134        })
135    }
136
137    /// Stops the worker thread (it ends after the job it is running).
138    pub(crate) fn close(&self) {
139        let mut st = lock(&self.st);
140        st.closed = true;
141        for j in st.jobs.drain(..) {
142            j.cancelled.store(true, Ordering::Relaxed);
143        }
144        st.queue.clear();
145        self.cv.notify_all();
146    }
147
148    /// Drops every hinted job and continues with `store` / `cams` (rewind, or a fresh session).
149    pub(crate) fn reset(&self, store: Arc<FeatureStore>, cams: BTreeMap<String, CameraId>) {
150        {
151            let mut st = lock(&self.st);
152            st.epoch += 1;
153            for j in st.jobs.drain(..) {
154                j.cancelled.store(true, Ordering::Relaxed);
155            }
156            st.queue.clear();
157            st.store = store;
158        }
159        // Waits for an extraction that is still running on the old store.
160        *lock(&self.cams) = cams;
161    }
162
163    /// Queues the extraction of a position. False when lookahead is off, the queue is full, or the position was hinted already
164    /// (then it is true). Never blocks.
165    pub(crate) fn hint(self: &Arc<Self>, images: &[FrameImage]) -> bool {
166        if self.limit == 0 || images.is_empty() {
167            return false;
168        }
169        let mut st = lock(&self.st);
170        if st.closed {
171            return false;
172        }
173        if st.jobs.iter().any(|j| j.images == images) {
174            return true;
175        }
176        if st.jobs.len() >= self.limit {
177            return false;
178        }
179        let job = Arc::new(Job {
180            images: images.to_vec(),
181            epoch: st.epoch,
182            cancelled: AtomicBool::new(false),
183            done: Mutex::new(None),
184            cv: Condvar::new(),
185        });
186        st.jobs.push_back(Arc::clone(&job));
187        st.queue.push_back(job);
188        if !st.worker_started {
189            st.worker_started = true;
190            let core = Arc::clone(self);
191            let spawned = std::thread::Builder::new().name("c3d-ahead".into()).spawn(move || core.work());
192            if spawned.is_err() {
193                st.worker_started = false;
194                st.jobs.pop_back();
195                st.queue.pop_back();
196                return false;
197            }
198        }
199        self.cv.notify_all();
200        true
201    }
202
203    /// Number of hinted positions the session has not taken yet.
204    pub(crate) fn pending(&self) -> usize {
205        lock(&self.st).jobs.len()
206    }
207
208    fn work(self: Arc<Self>) {
209        loop {
210            let job = {
211                let mut st = lock(&self.st);
212                loop {
213                    if st.closed {
214                        return;
215                    }
216                    if let Some(j) = st.queue.pop_front() {
217                        break j;
218                    }
219                    st = self.cv.wait(st).unwrap_or_else(|p| p.into_inner());
220                }
221            };
222            let mut cams = lock(&self.cams);
223            let (store, epoch) = {
224                let st = lock(&self.st);
225                (Arc::clone(&st.store), st.epoch)
226            };
227            let r = if job.epoch != epoch || job.cancelled.load(Ordering::Relaxed) {
228                Err("cancelled".to_string())
229            } else {
230                // A panic in a backend must not leave the session waiting for a result that never comes.
231                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
232                    self.extract_many(&store, &[job.images.as_slice()], &mut cams).map(|mut v| v.remove(0))
233                }))
234                .unwrap_or_else(|_| Err("특징 추출 중 패닉".to_string()))
235            };
236            drop(cams);
237            job.fulfil(r);
238        }
239    }
240
241    /// The result of a hinted position (waits until it is done); None when the position was not hinted. Jobs hinted before it
242    /// that were never taken are dropped.
243    pub(crate) fn take(&self, images: &[FrameImage]) -> Option<Result<PositionExtract, String>> {
244        let job = {
245            let mut st = lock(&self.st);
246            let i = st.jobs.iter().position(|j| j.images == images)?;
247            for stale in st.jobs.drain(..i) {
248                stale.cancelled.store(true, Ordering::Relaxed);
249            }
250            st.jobs.pop_front()?
251        };
252        Some(job.wait())
253    }
254
255    /// Extracts `positions` now, in order, one call for all their images. Returns one result per position.
256    pub(crate) fn run_now(&self, store: &FeatureStore, positions: &[&[FrameImage]]) -> Result<Vec<PositionExtract>, String> {
257        let mut cams = lock(&self.cams);
258        self.extract_many(store, positions, &mut cams)
259    }
260
261    /// Core of the extraction: the first bundle (no cameras known) is extracted alone with one camera per folder, every other
262    /// position attaches its images to the known cameras (a new camera name gets a camera of its own). Intrinsics priors are
263    /// applied to every new camera at once.
264    fn extract_many(
265        &self,
266        store: &FeatureStore,
267        positions: &[&[FrameImage]],
268        cams: &mut BTreeMap<String, CameraId>,
269    ) -> Result<Vec<PositionExtract>, String> {
270        let mut out: Vec<PositionExtract> = Vec::with_capacity(positions.len());
271        let mut rest = positions;
272        if cams.is_empty() {
273            let Some((first, tail)) = positions.split_first() else { return Ok(out) };
274            rest = tail;
275            let t0 = Instant::now();
276            let names: Vec<String> = first.iter().map(|i| i.name.clone()).collect();
277            let mut o = self.opts.clone();
278            o.reader.camera_mode = CameraMode::PerFolder;
279            let reports = self.extractor.extract_files(store, &self.root, &names, &o).map_err(|e| e.to_string())?;
280            for im in first.iter() {
281                let s = store.image_by_name(&im.name).ok_or_else(|| format!("{} 첫 영상 특징 추출 실패", im.camera))?;
282                cams.insert(im.camera.clone(), s.camera_id);
283            }
284            let notes = self.apply_prior(store, cams)?;
285            out.push(PositionExtract { reports, notes, cams: cams.clone(), elapsed: t0.elapsed() });
286        }
287        if rest.is_empty() {
288            return Ok(out);
289        }
290        // With lookahead off, a single position keeps the original order of work: one image after the other (each one with
291        // its own camera and prior handling); lookahead and batches extract all their images in parallel.
292        let parallel = self.limit > 0 || rest.len() > 1;
293        let extract_group =
294            |group: &[&FrameImage], cams: &mut BTreeMap<String, CameraId>| -> Result<(Vec<ImageReport>, Vec<String>), String> {
295                let items: Vec<(String, CameraMode)> = group
296                    .iter()
297                    .map(|im| (im.name.clone(), cams.get(&im.camera).copied().map_or(CameraMode::PerFolder, CameraMode::Existing)))
298                    .collect();
299                let reports = self.extractor.extract_ordered(store, &self.root, &items, &self.opts).map_err(|e| e.to_string())?;
300                let mut new_cam = false;
301                for im in group {
302                    if !cams.contains_key(&im.camera) {
303                        if let Some(s) = store.image_by_name(&im.name) {
304                            cams.insert(im.camera.clone(), s.camera_id);
305                            new_cam = true;
306                        }
307                    }
308                }
309                let notes = if new_cam { self.apply_prior(store, cams)? } else { Vec::new() };
310                Ok((reports, notes))
311            };
312        let t0 = Instant::now();
313        let mut done: Vec<(Vec<ImageReport>, Vec<String>)> = Vec::with_capacity(rest.len());
314        if parallel {
315            let all: Vec<&FrameImage> = rest.iter().flat_map(|p| p.iter()).collect();
316            let (reports, notes) = extract_group(&all, cams)?;
317            let mut reports = reports.into_iter();
318            for (i, pos) in rest.iter().enumerate() {
319                done.push((reports.by_ref().take(pos.len()).collect(), if i == 0 { notes.clone() } else { Vec::new() }));
320            }
321        } else {
322            for pos in rest {
323                let (mut reports, mut notes) = (Vec::new(), Vec::new());
324                for im in pos.iter() {
325                    let (r, n) = extract_group(&[im], cams)?;
326                    reports.extend(r);
327                    notes.extend(n);
328                }
329                done.push((reports, notes));
330            }
331        }
332        let share = t0.elapsed() / rest.len() as u32;
333        for (reports, notes) in done {
334            out.push(PositionExtract { reports, notes, cams: cams.clone(), elapsed: share });
335        }
336        Ok(out)
337    }
338
339    /// Soft intrinsics prior: sets the store's cameras to the prior values (before any registration or model uses them).
340    /// A camera without an entry is left alone; an entry whose length differs from the camera model's is an error.
341    fn apply_prior(&self, store: &FeatureStore, cams: &BTreeMap<String, CameraId>) -> Result<Vec<String>, String> {
342        let mut notes = Vec::new();
343        let Some(pr) = &self.prior else { return Ok(notes) };
344        let pr = pr.resolve(cams);
345        for mut cam in store.cameras() {
346            let before = cam.params.clone();
347            if pr.apply(&mut cam)? && cam.params != before {
348                notes.push(format!(
349                    "[prior] 카메라 {}: 내부값 초기화 {} → {}",
350                    cam.camera_id,
351                    crate::session::fmt_params(&before),
352                    crate::session::fmt_params(&cam.params)
353                ));
354                store.update_camera(cam).map_err(|e| e.to_string())?;
355            }
356        }
357        Ok(notes)
358    }
359}
360
361/// Handle that tells a session which positions are coming, so that their features are extracted ahead of time (see the module
362/// documentation). Cheap to clone; usable from any thread without touching the session.
363#[derive(Clone)]
364pub struct Lookahead(pub(crate) Arc<AheadCore>);
365
366impl Lookahead {
367    /// Queues the feature extraction of the position with these images. Returns false when it was not queued (lookahead is off,
368    /// `SessionConfig::lookahead` positions are already waiting, or the set is empty); the session then extracts the position
369    /// itself when it gets there. Never blocks.
370    pub fn hint(&self, frames: &FrameSet) -> bool {
371        self.0.hint(&frames.images)
372    }
373
374    /// The configured lookahead depth (`SessionConfig::lookahead`; 0 = off).
375    pub fn limit(&self) -> usize {
376        self.0.limit
377    }
378
379    /// Positions queued or extracted but not yet processed by the session.
380    pub fn pending(&self) -> usize {
381        self.0.pending()
382    }
383}
384
385impl std::fmt::Debug for Lookahead {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        f.debug_struct("Lookahead").field("limit", &self.0.limit).field("pending", &self.pending()).finish()
388    }
389}