Skip to main content

chess_corners/
multiscale.rs

1//! Unified corner detection (single or multiscale).
2//!
3//! This module implements the coarse-to-fine detector used by the
4//! `chess-corners` facade. It can:
5//!
6//! - run a single-scale detection when `pyramid.num_levels <= 1`, or
7//! - build an image pyramid, run a coarse detector on the smallest
8//!   level, and refine each seed in the base image (coarse-to-fine)
9//!   before merging duplicates.
10//!
11//! The main entry points are:
12//!
13//! - [`find_chess_corners`] – convenience wrapper that allocates
14//!   pyramid buffers internally and returns [`CornerDescriptor`]
15//!   values in base-image coordinates.
16//! - [`find_chess_corners_buff`] – lower-level helper that accepts a
17//!   caller-provided [`PyramidBuffers`] so you can reuse allocations
18//!   across frames in a tight loop.
19//! - ML-backed refinement variants (feature `ml-refiner`):
20//!   `find_chess_corners_with_ml` / `find_chess_corners_buff_with_ml`.
21
22#[cfg(feature = "ml-refiner")]
23use crate::ml_refiner;
24use crate::{ChessConfig, ChessParams};
25use box_image_pyramid::{build_pyramid, PyramidBuffers, PyramidParams};
26use chess_corners_core::descriptor::{corners_to_descriptors, Corner};
27use chess_corners_core::detect::{detect_corners_from_response_with_refiner, merge_corners_simple};
28use chess_corners_core::response::{chess_response_u8, chess_response_u8_patch, Roi};
29use chess_corners_core::{CornerDescriptor, CornerRefiner};
30use chess_corners_core::{ImageView, Refiner, RefinerKind, ResponseMap};
31
32/// Bridge from `chess_corners_core::ImageView` to `box_image_pyramid::ImageView`.
33fn to_pyramid_view(v: ImageView<'_>) -> box_image_pyramid::ImageView<'_> {
34    box_image_pyramid::ImageView::new(v.width, v.height, v.data).unwrap()
35}
36#[cfg(feature = "rayon")]
37use rayon::prelude::*;
38#[cfg(feature = "tracing")]
39use tracing::{info_span, instrument};
40
41/// Parameters controlling the coarse-to-fine multiscale detector.
42///
43/// The default keeps `num_levels = 1`, so callers start in the single-scale
44/// regime unless they explicitly opt into a pyramid.
45#[derive(Clone, Debug)]
46#[non_exhaustive]
47pub struct CoarseToFineParams {
48    /// Image pyramid shape and construction parameters.
49    pub pyramid: PyramidParams,
50    /// ROI radius at the coarse level (ignored when `pyramid.num_levels <= 1`).
51    /// Expressed in coarse-level pixels and automatically scaled to the base
52    /// image, with a minimum enforced to keep refinement away from borders.
53    pub refinement_radius: u32,
54    /// Radius (in base-image pixels) used to merge near-duplicate refined
55    /// corners after coarse-to-fine refinement.
56    pub merge_radius: f32,
57}
58
59impl Default for CoarseToFineParams {
60    fn default() -> Self {
61        Self {
62            pyramid: PyramidParams::default(),
63            // Smaller coarse-level ROI around each coarse prediction. With the
64            // default 3-level pyramid this maps to roughly a 12px radius
65            // (~25px window) at the base resolution.
66            refinement_radius: 3,
67            // merge duplicates within ~3 pixels
68            merge_radius: 3.0,
69        }
70    }
71}
72
73impl CoarseToFineParams {
74    pub fn new() -> Self {
75        Self::default()
76    }
77}
78
79// ---------------------------------------------------------------------------
80// Detector helpers: thin wrappers that adapt the classic and ML detectors
81// to a common call signature.
82// ---------------------------------------------------------------------------
83
84#[cfg(feature = "ml-refiner")]
85fn detect_with_ml_refiner(
86    resp: &ResponseMap,
87    params: &ChessParams,
88    image: Option<ImageView<'_>>,
89    ml_state: &mut ml_refiner::MlRefinerState,
90) -> Vec<Corner> {
91    ml_refiner::detect_corners_with_ml(resp, params, image, ml_state)
92}
93
94fn detect_with_refiner_kind(
95    resp: &ResponseMap,
96    params: &ChessParams,
97    image: Option<ImageView<'_>>,
98    refiner_kind: &RefinerKind,
99) -> Vec<Corner> {
100    let mut refiner = Refiner::from_kind(refiner_kind.clone());
101    detect_corners_from_response_with_refiner(resp, params, image, &mut refiner)
102}
103
104fn refiner_radius(refiner_kind: &RefinerKind) -> i32 {
105    Refiner::from_kind(refiner_kind.clone()).radius()
106}
107
108// ---------------------------------------------------------------------------
109// Shared coarse-to-fine pipeline
110// ---------------------------------------------------------------------------
111
112/// Pre-computed parameters for per-seed ROI refinement.
113struct RoiContext {
114    inv_scale: f32,
115    border: i32,
116    safe_margin: i32,
117    roi_r: i32,
118    base_w_i: i32,
119    base_h_i: i32,
120}
121
122impl RoiContext {
123    /// Compute a clamped, validated ROI around a coarse seed projected to base
124    /// image coordinates. Returns `None` if the seed is too close to the border
125    /// or the resulting ROI is too small.
126    fn compute_roi(&self, c: &Corner) -> Option<(i32, i32, i32, i32)> {
127        let cx = (c.x * self.inv_scale).round() as i32;
128        let cy = (c.y * self.inv_scale).round() as i32;
129
130        if cx < self.safe_margin
131            || cy < self.safe_margin
132            || cx >= self.base_w_i - self.safe_margin
133            || cy >= self.base_h_i - self.safe_margin
134        {
135            return None;
136        }
137
138        let mut x0 = cx - self.roi_r;
139        let mut y0 = cy - self.roi_r;
140        let mut x1 = cx + self.roi_r + 1;
141        let mut y1 = cy + self.roi_r + 1;
142
143        let min_xy = self.border;
144        let max_x = self.base_w_i - self.border;
145        let max_y = self.base_h_i - self.border;
146
147        if x0 < min_xy {
148            x0 = min_xy;
149        }
150        if y0 < min_xy {
151            y0 = min_xy;
152        }
153        if x1 > max_x {
154            x1 = max_x;
155        }
156        if y1 > max_y {
157            y1 = max_y;
158        }
159
160        if x1 - x0 <= 2 * self.border || y1 - y0 <= 2 * self.border {
161            return None;
162        }
163
164        Some((x0, y0, x1, y1))
165    }
166}
167
168/// Compute the response patch for a seed ROI and return offset corners.
169///
170/// This is the shared "inner loop" body for both the classic and ML refine
171/// paths. It computes the ChESS response inside the ROI, runs the provided
172/// `detect` closure, and shifts patch-local coordinates back into base-image
173/// space.
174fn refine_seed_in_roi(
175    base: ImageView<'_>,
176    params: &ChessParams,
177    roi_bounds: (i32, i32, i32, i32),
178    mut detect: impl FnMut(&ResponseMap, &ChessParams, Option<ImageView<'_>>) -> Vec<Corner>,
179) -> Option<Vec<Corner>> {
180    let (x0, y0, x1, y1) = roi_bounds;
181    let base_w = base.width;
182    let base_h = base.height;
183
184    let roi = Roi::new(x0 as usize, y0 as usize, x1 as usize, y1 as usize)?;
185    let patch_resp = chess_response_u8_patch(base.data, base_w, base_h, params, roi);
186
187    if patch_resp.width() == 0 || patch_resp.height() == 0 {
188        return None;
189    }
190
191    let refine_view = ImageView::with_origin(base_w, base_h, base.data, [x0, y0])
192        .expect("base image dimensions must match buffer length");
193    let mut patch_corners = detect(&patch_resp, params, Some(refine_view));
194
195    for pc in &mut patch_corners {
196        pc.x += x0 as f32;
197        pc.y += y0 as f32;
198    }
199
200    if patch_corners.is_empty() {
201        None
202    } else {
203        Some(patch_corners)
204    }
205}
206
207/// Single-scale detection: runs detection on the full image (level 0 only).
208fn single_scale_detect(
209    lvl_data: &[u8],
210    lvl_w: usize,
211    lvl_h: usize,
212    params: &ChessParams,
213    merge_radius: f32,
214    mut detect: impl FnMut(&ResponseMap, &ChessParams, Option<ImageView<'_>>) -> Vec<Corner>,
215) -> Vec<CornerDescriptor> {
216    #[cfg(feature = "tracing")]
217    let single_span = info_span!("single_scale", w = lvl_w, h = lvl_h).entered();
218
219    let resp = chess_response_u8(lvl_data, lvl_w, lvl_h, params);
220    let refine_view = ImageView::from_u8_slice(lvl_w, lvl_h, lvl_data)
221        .expect("image dimensions must match buffer length");
222    let mut raw = detect(&resp, params, Some(refine_view));
223    let merged = merge_corners_simple(&mut raw, merge_radius);
224    let desc = corners_to_descriptors(
225        lvl_data,
226        lvl_w,
227        lvl_h,
228        params.descriptor_ring_radius(),
229        merged,
230    );
231
232    #[cfg(feature = "tracing")]
233    drop(single_span);
234    desc
235}
236
237/// Merge refined corners and convert to descriptors.
238fn merge_and_describe(
239    base: ImageView<'_>,
240    params: &ChessParams,
241    merge_radius: f32,
242    refined: &mut Vec<Corner>,
243) -> Vec<CornerDescriptor> {
244    #[cfg(feature = "tracing")]
245    let merge_span = info_span!(
246        "merge",
247        merge_radius = merge_radius,
248        candidates = refined.len()
249    )
250    .entered();
251    let merged = merge_corners_simple(refined, merge_radius);
252    #[cfg(feature = "tracing")]
253    drop(merge_span);
254
255    corners_to_descriptors(
256        base.data,
257        base.width,
258        base.height,
259        params.descriptor_ring_radius(),
260        merged,
261    )
262}
263
264// ---------------------------------------------------------------------------
265// Classic (RefinerKind) path
266// ---------------------------------------------------------------------------
267
268/// Detect corners using a caller-provided pyramid buffer.
269///
270/// - When `cfg.multiscale.pyramid.num_levels <= 1`, this behaves as a
271///   single-scale detector on `base`.
272/// - Otherwise, it builds a pyramid into `buffers`, runs a coarse
273///   detector on the smallest level, refines each coarse seed inside a
274///   base-image ROI, merges near-duplicate corners, and finally
275///   converts them into [`CornerDescriptor`] values sampled at the
276///   full resolution.
277pub fn find_chess_corners_buff(
278    base: ImageView<'_>,
279    cfg: &ChessConfig,
280    buffers: &mut PyramidBuffers,
281) -> Vec<CornerDescriptor> {
282    find_chess_corners_buff_with_refiner(base, cfg, buffers, &cfg.params.refiner)
283}
284
285/// Variant of [`find_chess_corners_buff`] that accepts an explicit refiner selection.
286pub fn find_chess_corners_buff_with_refiner(
287    base: ImageView<'_>,
288    cfg: &ChessConfig,
289    buffers: &mut PyramidBuffers,
290    refiner: &RefinerKind,
291) -> Vec<CornerDescriptor> {
292    let params = &cfg.params;
293    let cf = &cfg.multiscale;
294
295    let pyramid = build_pyramid(to_pyramid_view(base), &cf.pyramid, buffers);
296    if pyramid.levels.is_empty() {
297        return Vec::new();
298    }
299
300    // Single-scale fallback.
301    if pyramid.levels.len() == 1 {
302        let lvl = &pyramid.levels[0];
303        return single_scale_detect(
304            lvl.img.data,
305            lvl.img.width,
306            lvl.img.height,
307            params,
308            cf.merge_radius,
309            |resp, params, image| detect_with_refiner_kind(resp, params, image, refiner),
310        );
311    }
312
313    // --- Coarse-to-fine path ---
314
315    let coarse_lvl = pyramid.levels.last().unwrap();
316    let coarse_w = coarse_lvl.img.width;
317    let coarse_h = coarse_lvl.img.height;
318
319    #[cfg(feature = "tracing")]
320    let coarse_span = info_span!("coarse_detect", w = coarse_w, h = coarse_h).entered();
321    let coarse_resp = chess_response_u8(coarse_lvl.img.data, coarse_w, coarse_h, params);
322    let coarse_view = ImageView::from_u8_slice(coarse_w, coarse_h, coarse_lvl.img.data).unwrap();
323    let coarse_corners = detect_with_refiner_kind(&coarse_resp, params, Some(coarse_view), refiner);
324    #[cfg(feature = "tracing")]
325    drop(coarse_span);
326
327    if coarse_corners.is_empty() {
328        return Vec::new();
329    }
330
331    let roi_ctx = make_roi_context(base, coarse_lvl.scale, params, refiner_radius(refiner), cf);
332
333    let refine_one = |c: Corner| -> Option<Vec<Corner>> {
334        let roi_bounds = roi_ctx.compute_roi(&c)?;
335        refine_seed_in_roi(base, params, roi_bounds, |resp, params, image| {
336            detect_with_refiner_kind(resp, params, image, refiner)
337        })
338    };
339
340    #[cfg(feature = "tracing")]
341    let refine_span = info_span!(
342        "refine",
343        seeds = coarse_corners.len(),
344        roi_r = roi_ctx.roi_r
345    )
346    .entered();
347
348    #[cfg(feature = "rayon")]
349    let mut refined: Vec<Corner> = coarse_corners
350        .into_par_iter()
351        .filter_map(refine_one)
352        .flatten()
353        .collect();
354
355    #[cfg(not(feature = "rayon"))]
356    let mut refined: Vec<Corner> = coarse_corners
357        .into_iter()
358        .filter_map(refine_one)
359        .flatten()
360        .collect();
361
362    #[cfg(feature = "tracing")]
363    drop(refine_span);
364
365    merge_and_describe(base, params, cf.merge_radius, &mut refined)
366}
367
368// ---------------------------------------------------------------------------
369// ML refiner path
370// ---------------------------------------------------------------------------
371
372/// Variant of [`find_chess_corners_buff`] that uses the ML refiner pipeline.
373#[cfg(feature = "ml-refiner")]
374pub fn find_chess_corners_buff_with_ml(
375    base: ImageView<'_>,
376    cfg: &ChessConfig,
377    buffers: &mut PyramidBuffers,
378) -> Vec<CornerDescriptor> {
379    let ml_params = ml_refiner::MlRefinerParams::default();
380    let mut ml_state = ml_refiner::MlRefinerState::new(&ml_params, &cfg.params.refiner);
381    find_chess_corners_buff_with_ml_state(base, cfg, buffers, &ml_params, &mut ml_state)
382}
383
384#[cfg(feature = "ml-refiner")]
385fn find_chess_corners_buff_with_ml_state(
386    base: ImageView<'_>,
387    cfg: &ChessConfig,
388    buffers: &mut PyramidBuffers,
389    ml: &ml_refiner::MlRefinerParams,
390    ml_state: &mut ml_refiner::MlRefinerState,
391) -> Vec<CornerDescriptor> {
392    let params = &cfg.params;
393    let cf = &cfg.multiscale;
394
395    let pyramid = build_pyramid(to_pyramid_view(base), &cf.pyramid, buffers);
396    if pyramid.levels.is_empty() {
397        return Vec::new();
398    }
399
400    // Single-scale fallback.
401    if pyramid.levels.len() == 1 {
402        let lvl = &pyramid.levels[0];
403        return single_scale_detect(
404            lvl.img.data,
405            lvl.img.width,
406            lvl.img.height,
407            params,
408            cf.merge_radius,
409            |resp, params, image| detect_with_ml_refiner(resp, params, image, ml_state),
410        );
411    }
412
413    // --- Coarse-to-fine path ---
414    // Coarse detection always uses the classic refiner regardless of refinement path.
415
416    let coarse_lvl = pyramid.levels.last().unwrap();
417    let coarse_w = coarse_lvl.img.width;
418    let coarse_h = coarse_lvl.img.height;
419
420    #[cfg(feature = "tracing")]
421    let coarse_span = info_span!("coarse_detect", w = coarse_w, h = coarse_h).entered();
422    let coarse_resp = chess_response_u8(coarse_lvl.img.data, coarse_w, coarse_h, params);
423    let coarse_view = ImageView::from_u8_slice(coarse_w, coarse_h, coarse_lvl.img.data).unwrap();
424    let coarse_corners =
425        detect_with_refiner_kind(&coarse_resp, params, Some(coarse_view), &params.refiner);
426    #[cfg(feature = "tracing")]
427    drop(coarse_span);
428
429    if coarse_corners.is_empty() {
430        return Vec::new();
431    }
432
433    let roi_ctx = make_roi_context(
434        base,
435        coarse_lvl.scale,
436        params,
437        ml_refiner::patch_radius(ml),
438        cf,
439    );
440
441    #[cfg(feature = "tracing")]
442    let refine_span = info_span!(
443        "refine",
444        seeds = coarse_corners.len(),
445        roi_r = roi_ctx.roi_r
446    )
447    .entered();
448
449    // ML refiner holds mutable state, so refinement is sequential.
450    let mut refined: Vec<Corner> = coarse_corners
451        .into_iter()
452        .filter_map(|c| {
453            let roi_bounds = roi_ctx.compute_roi(&c)?;
454            refine_seed_in_roi(base, params, roi_bounds, |resp, params, image| {
455                detect_with_ml_refiner(resp, params, image, ml_state)
456            })
457        })
458        .flatten()
459        .collect();
460
461    #[cfg(feature = "tracing")]
462    drop(refine_span);
463
464    merge_and_describe(base, params, cf.merge_radius, &mut refined)
465}
466
467// ---------------------------------------------------------------------------
468// Shared helpers
469// ---------------------------------------------------------------------------
470
471fn make_roi_context(
472    base: ImageView<'_>,
473    coarse_scale: f32,
474    params: &ChessParams,
475    refine_border: i32,
476    cf: &CoarseToFineParams,
477) -> RoiContext {
478    let ring_r = params.ring_radius() as i32;
479    let nms_r = params.nms_radius as i32;
480    let border = (ring_r + nms_r + refine_border).max(0);
481    let safe_margin = border + 1;
482    let roi_r_base = (cf.refinement_radius as f32 / coarse_scale).ceil() as i32;
483    let min_roi_r = border + 2;
484
485    RoiContext {
486        inv_scale: 1.0 / coarse_scale,
487        border,
488        safe_margin,
489        roi_r: roi_r_base.max(min_roi_r),
490        base_w_i: base.width as i32,
491        base_h_i: base.height as i32,
492    }
493}
494
495// ---------------------------------------------------------------------------
496// Convenience wrappers (allocate pyramid buffers internally)
497// ---------------------------------------------------------------------------
498
499/// Detect corners from a base-level grayscale view, allocating
500/// pyramid storage internally.
501///
502/// This is the high-level entry point used by
503/// [`crate::find_chess_corners_u8`] and the `image` helpers. For
504/// repeated calls on successive frames, prefer
505/// [`find_chess_corners_buff`] with a reusable [`PyramidBuffers`] to
506/// avoid repeated allocations.
507#[must_use]
508#[cfg_attr(
509    feature = "tracing",
510    instrument(
511        level = "info",
512        skip(base, cfg),
513        fields(levels = cfg.multiscale.pyramid.num_levels, min_size = cfg.multiscale.pyramid.min_size)
514    )
515)]
516pub fn find_chess_corners(base: ImageView<'_>, cfg: &ChessConfig) -> Vec<CornerDescriptor> {
517    find_chess_corners_with_refiner(base, cfg, &cfg.params.refiner)
518}
519
520/// Single-call helper that lets callers pick the refiner.
521#[must_use]
522pub fn find_chess_corners_with_refiner(
523    base: ImageView<'_>,
524    cfg: &ChessConfig,
525    refiner: &RefinerKind,
526) -> Vec<CornerDescriptor> {
527    let mut buffers = PyramidBuffers::with_capacity(cfg.multiscale.pyramid.num_levels);
528    find_chess_corners_buff_with_refiner(base, cfg, &mut buffers, refiner)
529}
530
531/// Single-call helper that runs the ML refiner pipeline.
532#[cfg(feature = "ml-refiner")]
533#[must_use]
534pub fn find_chess_corners_with_ml(base: ImageView<'_>, cfg: &ChessConfig) -> Vec<CornerDescriptor> {
535    let mut buffers = PyramidBuffers::with_capacity(cfg.multiscale.pyramid.num_levels);
536    find_chess_corners_buff_with_ml(base, cfg, &mut buffers)
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use box_image_pyramid::ImageBuffer;
543
544    #[test]
545    fn default_coarse_to_fine_config_is_single_scale() {
546        let cfg = CoarseToFineParams::default();
547        assert_eq!(cfg.pyramid.num_levels, 1);
548        assert_eq!(cfg.pyramid.min_size, 128);
549        assert_eq!(cfg.refinement_radius, 3);
550        assert_eq!(cfg.merge_radius, 3.0);
551    }
552
553    #[test]
554    fn chess_config_multiscale_preset_has_expected_pyramid() {
555        let cfg = ChessConfig::multiscale();
556        assert_eq!(cfg.multiscale.pyramid.num_levels, 3);
557        assert_eq!(cfg.multiscale.pyramid.min_size, 128);
558        assert_eq!(cfg.multiscale.refinement_radius, 3);
559        assert_eq!(cfg.multiscale.merge_radius, 3.0);
560    }
561
562    #[test]
563    fn coarse_to_fine_trace_reports_timings() {
564        let buf = ImageBuffer::new(32, 32);
565        let view = ImageView::from_u8_slice(buf.width, buf.height, &buf.data)
566            .expect("dimensions must match");
567        let cfg = ChessConfig::default();
568        let corners = find_chess_corners(view, &cfg);
569        assert!(corners.is_empty());
570    }
571}