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 generic driver [`detect_multiscale`] is parameterised over
12//! [`DenseDetector`], so both the ChESS and Radon detectors flow
13//! through the same orchestrator. The dispatch in
14//! [`crate::Detector::detect_view`] selects the detector ZST per
15//! [`crate::DetectionStrategy`]. End users should reach detection
16//! through [`crate::Detector`].
17
18#[cfg(feature = "ml-refiner")]
19use crate::ml_refiner;
20#[cfg(feature = "ml-refiner")]
21use crate::ChessParams;
22use crate::{DetectionStrategy, DetectorConfig};
23use box_image_pyramid::{build_pyramid, PyramidBuffers, PyramidParams};
24#[cfg(feature = "ml-refiner")]
25use chess_corners_core::detect::chess::response::{
26    chess_response_u8, chess_response_u8_patch, Roi,
27};
28#[cfg(feature = "ml-refiner")]
29use chess_corners_core::detect::detect_corners_from_response_with_refiner;
30use chess_corners_core::detect::merge_corners_simple;
31use chess_corners_core::detect::Corner;
32use chess_corners_core::orientation::describe_corners;
33#[cfg(feature = "ml-refiner")]
34use chess_corners_core::ResponseMap;
35use chess_corners_core::{ChessBuffers, ChessDetector, CornerDescriptor, DenseDetector};
36use chess_corners_core::{
37    CornerRefiner, ImageView, OrientationMethod, RadonBuffers, RadonDetector, Refiner, RefinerKind,
38};
39
40/// Bridge from `chess_corners_core::ImageView` to `box_image_pyramid::ImageView`.
41fn to_pyramid_view(v: ImageView<'_>) -> box_image_pyramid::ImageView<'_> {
42    // invariant: v was already validated as a coherent ImageView, so the pyramid view cannot fail.
43    box_image_pyramid::ImageView::new(v.width, v.height, v.data).unwrap()
44}
45#[cfg(feature = "tracing")]
46use tracing::info_span;
47
48/// Parameters controlling the coarse-to-fine multiscale detector.
49///
50/// The default keeps `num_levels = 1`, so callers start in the single-scale
51/// regime unless they explicitly opt into a pyramid.
52#[derive(Clone, Debug)]
53#[non_exhaustive]
54pub struct CoarseToFineParams {
55    /// Image pyramid shape and construction parameters.
56    pub pyramid: PyramidParams,
57    /// ROI radius at the coarse level (ignored when `pyramid.num_levels <= 1`).
58    /// Expressed in coarse-level pixels and automatically scaled to the base
59    /// image, with a minimum enforced to keep refinement away from borders.
60    pub refinement_radius: u32,
61    /// Radius (in base-image pixels) used to merge near-duplicate refined
62    /// corners after coarse-to-fine refinement.
63    pub merge_radius: f32,
64}
65
66impl Default for CoarseToFineParams {
67    fn default() -> Self {
68        Self {
69            pyramid: PyramidParams::default(),
70            // Smaller coarse-level ROI around each coarse prediction. With the
71            // default 3-level pyramid this maps to roughly a 12px radius
72            // (~25px window) at the base resolution.
73            refinement_radius: 3,
74            // merge duplicates within ~3 pixels
75            merge_radius: 3.0,
76        }
77    }
78}
79
80impl CoarseToFineParams {
81    pub fn new() -> Self {
82        Self::default()
83    }
84}
85
86// ---------------------------------------------------------------------------
87// ML-refiner adapter: kept as a closure-driven specialisation because it
88// is ChESS-only and threads mutable state through the per-seed loop.
89// ---------------------------------------------------------------------------
90
91#[cfg(feature = "ml-refiner")]
92fn detect_with_ml_refiner(
93    resp: &ResponseMap,
94    params: &ChessParams,
95    image: Option<ImageView<'_>>,
96    ml_state: &mut ml_refiner::MlRefinerState,
97) -> Vec<Corner> {
98    ml_refiner::detect_corners_with_ml(resp, params, image, ml_state)
99}
100
101#[cfg(feature = "ml-refiner")]
102fn detect_with_refiner_kind(
103    resp: &ResponseMap,
104    params: &ChessParams,
105    image: Option<ImageView<'_>>,
106    refiner_kind: &RefinerKind,
107) -> Vec<Corner> {
108    let mut refiner = Refiner::from_kind(refiner_kind.clone());
109    detect_corners_from_response_with_refiner(resp, params, image, &mut refiner)
110}
111
112fn refiner_radius(refiner_kind: &RefinerKind) -> i32 {
113    Refiner::from_kind(refiner_kind.clone()).radius()
114}
115
116// ---------------------------------------------------------------------------
117// Shared coarse-to-fine pipeline
118// ---------------------------------------------------------------------------
119
120/// Pre-computed parameters for per-seed ROI refinement.
121struct RoiContext {
122    inv_scale: f32,
123    border: i32,
124    safe_margin: i32,
125    roi_r: i32,
126    base_w_i: i32,
127    base_h_i: i32,
128}
129
130impl RoiContext {
131    /// Compute a clamped, validated ROI around a coarse seed projected to base
132    /// image coordinates. Returns `None` if the seed is too close to the border
133    /// or the resulting ROI is too small.
134    fn compute_roi(&self, c: &Corner) -> Option<(i32, i32, i32, i32)> {
135        let cx = (c.x * self.inv_scale).round() as i32;
136        let cy = (c.y * self.inv_scale).round() as i32;
137
138        if cx < self.safe_margin
139            || cy < self.safe_margin
140            || cx >= self.base_w_i - self.safe_margin
141            || cy >= self.base_h_i - self.safe_margin
142        {
143            return None;
144        }
145
146        let mut x0 = cx - self.roi_r;
147        let mut y0 = cy - self.roi_r;
148        let mut x1 = cx + self.roi_r + 1;
149        let mut y1 = cy + self.roi_r + 1;
150
151        let min_xy = self.border;
152        let max_x = self.base_w_i - self.border;
153        let max_y = self.base_h_i - self.border;
154
155        if x0 < min_xy {
156            x0 = min_xy;
157        }
158        if y0 < min_xy {
159            y0 = min_xy;
160        }
161        if x1 > max_x {
162            x1 = max_x;
163        }
164        if y1 > max_y {
165            y1 = max_y;
166        }
167
168        if x1 - x0 <= 2 * self.border || y1 - y0 <= 2 * self.border {
169            return None;
170        }
171
172        Some((x0, y0, x1, y1))
173    }
174}
175
176fn make_roi_context(
177    base: ImageView<'_>,
178    coarse_scale: f32,
179    detector_border: i32,
180    refine_border: i32,
181    cf: &CoarseToFineParams,
182) -> RoiContext {
183    let border = (detector_border + refine_border).max(0);
184    let safe_margin = border + 1;
185    let roi_r_base = (cf.refinement_radius as f32 / coarse_scale).ceil() as i32;
186    let min_roi_r = border + 2;
187
188    RoiContext {
189        inv_scale: 1.0 / coarse_scale,
190        border,
191        safe_margin,
192        roi_r: roi_r_base.max(min_roi_r),
193        base_w_i: base.width as i32,
194        base_h_i: base.height as i32,
195    }
196}
197
198// ---------------------------------------------------------------------------
199// Generic multiscale orchestrator (driven by DenseDetector)
200// ---------------------------------------------------------------------------
201
202/// Pixel-shape arguments common to every detector path (descriptor
203/// sampling + orientation + post-detection merge). Lets the generic
204/// orchestrator stay parameter-symmetric over [`DenseDetector`].
205struct DetectorShape<'r> {
206    refiner_kind: &'r RefinerKind,
207    descriptor_ring_radius: u32,
208    orientation_method: OrientationMethod,
209    merge_radius: f32,
210}
211
212/// Generic multiscale corner detection driver.
213///
214/// Runs a single-scale detection when `multiscale` is `None` or
215/// resolves to a 1-level pyramid; otherwise builds the pyramid,
216/// detects seeds on the coarsest level, and refines each seed inside
217/// a base-image ROI before merging duplicates and producing
218/// descriptors.
219///
220/// The detector is selected through the [`DenseDetector`] trait, so
221/// both ChESS and Radon share the same control flow. Detector-domain
222/// peak extraction stays inside [`DenseDetector::detect_corners`]
223/// (which returns peaks with the response-map subpixel position only
224/// — e.g. the ChESS quadratic / Radon 3-point Gaussian); image-domain
225/// refinement (`CenterOfMassRefiner`, `ForstnerRefiner`, …) runs as a
226/// separate post-detection stage via
227/// [`refine_corners_on_image`](chess_corners_core::detect::refine_corners_on_image).
228///
229/// `descriptor_ring_radius` and `orientation_method` are sourced from
230/// the ChESS-derived params even when the active detector is Radon —
231/// descriptor sampling and orientation are detector-agnostic stages
232/// downstream of peak extraction.
233fn detect_multiscale<D: DenseDetector>(
234    base: ImageView<'_>,
235    detector: &D,
236    params: &D::Params,
237    detector_buffers: &mut D::Buffers,
238    pyramid_buffers: &mut PyramidBuffers,
239    multiscale: Option<&CoarseToFineParams>,
240    shape: &DetectorShape<'_>,
241) -> Vec<CornerDescriptor> {
242    let base_view = ImageView::from_u8_slice(base.width, base.height, base.data)
243        .expect("base image dimensions must match buffer length");
244
245    // The refiner only contributes to the ROI margin when the
246    // detector actually consumes it. Detectors whose
247    // `refine_peaks_on_image` is a no-op (today: Radon) declare
248    // `refines_on_image() == false` so that switching the active
249    // strategy's refiner between e.g. `CenterOfMass` and
250    // `SaddlePoint` doesn't silently shrink Radon's valid seed
251    // area near the image border.
252    let refine_border = if detector.refines_on_image() {
253        refiner_radius(shape.refiner_kind)
254    } else {
255        0
256    };
257
258    // Single-scale path: no pyramid, run detector once on the full
259    // base view, refine through the detector's image-domain step,
260    // merge, describe.
261    let Some(cf) = multiscale else {
262        let resp = detector.compute_response(base, params, detector_buffers);
263        let peaks = detector.detect_corners(&resp, params, refine_border);
264        let mut refiner = Refiner::from_kind(shape.refiner_kind.clone());
265        let mut corners = detector.refine_peaks_on_image(peaks, base_view, &resp, &mut refiner);
266        let merged = merge_corners_simple(&mut corners, shape.merge_radius);
267        return describe_corners(
268            base.data,
269            base.width,
270            base.height,
271            shape.descriptor_ring_radius,
272            merged,
273            shape.orientation_method,
274        );
275    };
276
277    let pyramid = build_pyramid(to_pyramid_view(base), &cf.pyramid, pyramid_buffers);
278    if pyramid.levels.is_empty() {
279        return Vec::new();
280    }
281
282    // Single-level pyramid: same as single-scale but on the pyramid
283    // level's data (which equals the base for num_levels==1 / no
284    // downsampling).
285    if pyramid.levels.len() == 1 {
286        let lvl = &pyramid.levels[0];
287        let lvl_view = ImageView::from_u8_slice(lvl.img.width, lvl.img.height, lvl.img.data)
288            .expect("pyramid level dimensions must match buffer length");
289        let resp = detector.compute_response(lvl_view, params, detector_buffers);
290        let peaks = detector.detect_corners(&resp, params, refine_border);
291        let mut refiner = Refiner::from_kind(shape.refiner_kind.clone());
292        let mut corners = detector.refine_peaks_on_image(peaks, lvl_view, &resp, &mut refiner);
293        let merged = merge_corners_simple(&mut corners, cf.merge_radius);
294        return describe_corners(
295            lvl.img.data,
296            lvl.img.width,
297            lvl.img.height,
298            shape.descriptor_ring_radius,
299            merged,
300            shape.orientation_method,
301        );
302    }
303
304    // --- Coarse-to-fine path ---
305
306    // invariant: pyramid was built from a validated input image, so at least one level exists.
307    let coarse_lvl = pyramid.levels.last().unwrap();
308    let coarse_w = coarse_lvl.img.width;
309    let coarse_h = coarse_lvl.img.height;
310
311    #[cfg(feature = "tracing")]
312    let coarse_span = info_span!("coarse_detect", w = coarse_w, h = coarse_h).entered();
313    // invariant: coarse level dimensions come from the pyramid which already validated them.
314    let coarse_view = ImageView::from_u8_slice(coarse_w, coarse_h, coarse_lvl.img.data).unwrap();
315    let coarse_resp = detector.compute_response(coarse_view, params, detector_buffers);
316    let coarse_peaks = detector.detect_corners(&coarse_resp, params, refine_border);
317    let mut refiner = Refiner::from_kind(shape.refiner_kind.clone());
318    let coarse_corners =
319        detector.refine_peaks_on_image(coarse_peaks, coarse_view, &coarse_resp, &mut refiner);
320    // Drop the response borrow before reusing detector_buffers for the
321    // per-seed patch path.
322    drop(coarse_resp);
323    #[cfg(feature = "tracing")]
324    drop(coarse_span);
325
326    if coarse_corners.is_empty() {
327        return Vec::new();
328    }
329
330    let detector_border = detector.roi_border(params);
331    let roi_ctx = make_roi_context(base, coarse_lvl.scale, detector_border, refine_border, cf);
332
333    #[cfg(feature = "tracing")]
334    let refine_span = info_span!(
335        "refine",
336        seeds = coarse_corners.len(),
337        roi_r = roi_ctx.roi_r
338    )
339    .entered();
340
341    // Per-seed refinement runs sequentially in the generic path: the
342    // detector's `compute_response_patch` mutates `detector_buffers`,
343    // and rayon parallelism over seeds would require cloning the
344    // buffers per worker. The cost-benefit here flips toward
345    // simplicity — the heavy per-seed work for ChESS is a small ROI
346    // and parallelism gained little; for Radon, the SAT build inside
347    // each patch dominates.
348    let mut refined: Vec<Corner> = Vec::new();
349    for c in coarse_corners {
350        let Some(roi_bounds) = roi_ctx.compute_roi(&c) else {
351            continue;
352        };
353        let (x0, y0, _x1, _y1) = roi_bounds;
354        let patch_resp =
355            detector.compute_response_patch(base, roi_bounds, params, detector_buffers);
356        // Width/height inferred from the response's shape: ChESS
357        // emits a ResponseMap sized to the ROI (with reach-outside
358        // border math); Radon emits a working-resolution
359        // RadonResponseView whose pixels are in the *patch* coord
360        // frame. detector.detect_corners returns corners in the same
361        // patch-local frame.
362        let patch_peaks = detector.detect_corners(&patch_resp, params, refine_border);
363        if patch_peaks.is_empty() {
364            continue;
365        }
366
367        // Image-domain refinement over the base image, with origin
368        // [x0, y0]: the refiner sees patch-local seed coords and
369        // samples base pixels at (cx + x0, cy + y0). The detector
370        // decides whether its response is forwardable to the refiner
371        // (ChESS → yes, ResponseMap; Radon → no, returns peaks as-is).
372        let patch_image = ImageView::with_origin(base.width, base.height, base.data, [x0, y0])
373            .expect("base image dimensions must match buffer length");
374        let mut patch_refined =
375            detector.refine_peaks_on_image(patch_peaks, patch_image, &patch_resp, &mut refiner);
376
377        // Drop the patch_resp borrow so detector_buffers is free for
378        // the next iteration.
379        drop(patch_resp);
380
381        // Shift patch-local refined positions to base coords.
382        for pc in &mut patch_refined {
383            pc.x += x0 as f32;
384            pc.y += y0 as f32;
385        }
386        refined.extend(patch_refined);
387    }
388
389    #[cfg(feature = "tracing")]
390    drop(refine_span);
391
392    #[cfg(feature = "tracing")]
393    let merge_span = info_span!(
394        "merge",
395        merge_radius = cf.merge_radius,
396        candidates = refined.len()
397    )
398    .entered();
399    let merged = merge_corners_simple(&mut refined, cf.merge_radius);
400    #[cfg(feature = "tracing")]
401    drop(merge_span);
402
403    describe_corners(
404        base.data,
405        base.width,
406        base.height,
407        shape.descriptor_ring_radius,
408        merged,
409        shape.orientation_method,
410    )
411}
412
413// ---------------------------------------------------------------------------
414// Detector-typed entry points (called by the facade Detector dispatch)
415// ---------------------------------------------------------------------------
416
417/// Detect corners through the generic orchestrator. The `cfg.strategy`
418/// selects between [`ChessDetector`] and [`RadonDetector`]; both
419/// flow through the same control flow.
420pub(crate) fn detect_with_buffers(
421    base: ImageView<'_>,
422    cfg: &DetectorConfig,
423    pyramid_buffers: &mut PyramidBuffers,
424    chess_buffers: &mut ChessBuffers,
425    radon_buffers: &mut RadonBuffers,
426) -> Vec<CornerDescriptor> {
427    let multiscale = cfg.to_coarse_to_fine_params();
428
429    match &cfg.strategy {
430        DetectionStrategy::Chess(_) => {
431            let chess_params = cfg.to_chess_params();
432            let refiner_kind = chess_params.refiner.clone();
433            let shape = DetectorShape {
434                refiner_kind: &refiner_kind,
435                descriptor_ring_radius: chess_params.descriptor_ring_radius(),
436                orientation_method: chess_params.orientation_method,
437                merge_radius: cfg.merge_radius,
438            };
439            detect_multiscale(
440                base,
441                &ChessDetector,
442                &chess_params,
443                chess_buffers,
444                pyramid_buffers,
445                multiscale.as_ref(),
446                &shape,
447            )
448        }
449        DetectionStrategy::Radon(_) => {
450            let radon_params = cfg.to_radon_detector_params();
451            let refiner_kind = radon_params.refiner.clone();
452            // Radon strategies don't carry a descriptor-ring knob;
453            // descriptors sample the canonical r=5 ring downstream.
454            // Orientation method is top-level on DetectorConfig.
455            let shape = DetectorShape {
456                refiner_kind: &refiner_kind,
457                descriptor_ring_radius: chess_corners_core::ChessParams::default()
458                    .descriptor_ring_radius(),
459                orientation_method: cfg.orientation_method,
460                merge_radius: cfg.merge_radius,
461            };
462            detect_multiscale(
463                base,
464                &RadonDetector,
465                &radon_params,
466                radon_buffers,
467                pyramid_buffers,
468                multiscale.as_ref(),
469                &shape,
470            )
471        }
472    }
473}
474
475// ---------------------------------------------------------------------------
476// ML refiner path (ChESS-only specialisation)
477// ---------------------------------------------------------------------------
478
479/// ML-refiner detection path. ChESS-only: the ML model expects
480/// ChESS-shaped intensity patches, so Radon+ML is a category error
481/// and falls back to the generic Radon path.
482///
483/// Kept as a separate specialisation because the ML refiner threads
484/// per-frame mutable state (`MlRefinerState`) through the per-seed
485/// loop, which the generic [`detect_multiscale`] driver intentionally
486/// does not.
487#[cfg(feature = "ml-refiner")]
488pub(crate) fn detect_with_ml(
489    base: ImageView<'_>,
490    cfg: &DetectorConfig,
491    pyramid_buffers: &mut PyramidBuffers,
492    chess_buffers: &mut ChessBuffers,
493    radon_buffers: &mut RadonBuffers,
494    ml: &ml_refiner::MlRefinerParams,
495    ml_state: &mut ml_refiner::MlRefinerState,
496) -> Vec<CornerDescriptor> {
497    // ML pairs only with ChESS-style patches; fall back to the
498    // generic Radon path otherwise.
499    if matches!(&cfg.strategy, DetectionStrategy::Radon(_)) {
500        return detect_with_buffers(base, cfg, pyramid_buffers, chess_buffers, radon_buffers);
501    }
502
503    let _ = (radon_buffers,); // unused on the ChESS branch but kept in the signature for symmetry.
504
505    let params = cfg.to_chess_params();
506    let ml_border = ml_refiner::patch_radius(ml);
507    coarse_to_fine_with_ml(
508        base,
509        cfg,
510        pyramid_buffers,
511        chess_buffers,
512        &params,
513        ml_border,
514        &mut |resp, p, image| detect_with_ml_refiner(resp, p, image, ml_state),
515    )
516}
517
518/// Sequential coarse-to-fine driver for the ChESS+ML path. Threads a
519/// `&mut FnMut(...)` so the caller can hold mutable ML state without
520/// the borrow-checker conflict that would arise from splitting the
521/// closure across coarse and per-seed call sites.
522#[cfg(feature = "ml-refiner")]
523fn coarse_to_fine_with_ml<R>(
524    base: ImageView<'_>,
525    cfg: &DetectorConfig,
526    pyramid_buffers: &mut PyramidBuffers,
527    chess_buffers: &mut ChessBuffers,
528    params: &ChessParams,
529    refine_border: i32,
530    detect_fn: &mut R,
531) -> Vec<CornerDescriptor>
532where
533    R: FnMut(&ResponseMap, &ChessParams, Option<ImageView<'_>>) -> Vec<Corner>,
534{
535    // Single-scale ChESS+ML.
536    let Some(cf) = cfg.to_coarse_to_fine_params() else {
537        let detector = ChessDetector;
538        let resp = detector.compute_response(base, params, chess_buffers);
539        let view = ImageView::from_u8_slice(base.width, base.height, base.data)
540            .expect("image dimensions must match buffer length");
541        let mut raw = detect_fn(resp, params, Some(view));
542        let merged = merge_corners_simple(&mut raw, cfg.merge_radius);
543        return describe_corners(
544            base.data,
545            base.width,
546            base.height,
547            params.descriptor_ring_radius(),
548            merged,
549            params.orientation_method,
550        );
551    };
552
553    let pyramid = build_pyramid(to_pyramid_view(base), &cf.pyramid, pyramid_buffers);
554    if pyramid.levels.is_empty() {
555        return Vec::new();
556    }
557
558    // Single-scale fallback for ChESS+ML when num_levels=1.
559    if pyramid.levels.len() == 1 {
560        let lvl = &pyramid.levels[0];
561        let resp = chess_response_u8(lvl.img.data, lvl.img.width, lvl.img.height, params);
562        let view = ImageView::from_u8_slice(lvl.img.width, lvl.img.height, lvl.img.data)
563            .expect("image dimensions must match buffer length");
564        let mut raw = detect_fn(&resp, params, Some(view));
565        let merged = merge_corners_simple(&mut raw, cf.merge_radius);
566        return describe_corners(
567            lvl.img.data,
568            lvl.img.width,
569            lvl.img.height,
570            params.descriptor_ring_radius(),
571            merged,
572            params.orientation_method,
573        );
574    }
575
576    // Coarse-to-fine: coarse seeds via the classic ChESS refiner; ROI
577    // refinement via the ML pipeline.
578    let coarse_lvl = pyramid.levels.last().unwrap();
579    let coarse_w = coarse_lvl.img.width;
580    let coarse_h = coarse_lvl.img.height;
581
582    #[cfg(feature = "tracing")]
583    let coarse_span = info_span!("coarse_detect", w = coarse_w, h = coarse_h).entered();
584    let coarse_resp = chess_response_u8(coarse_lvl.img.data, coarse_w, coarse_h, params);
585    let coarse_view = ImageView::from_u8_slice(coarse_w, coarse_h, coarse_lvl.img.data).unwrap();
586    let coarse_corners =
587        detect_with_refiner_kind(&coarse_resp, params, Some(coarse_view), &params.refiner);
588    #[cfg(feature = "tracing")]
589    drop(coarse_span);
590
591    if coarse_corners.is_empty() {
592        return Vec::new();
593    }
594
595    let detector_border = ChessDetector.roi_border(params);
596    let roi_ctx = make_roi_context(base, coarse_lvl.scale, detector_border, refine_border, &cf);
597
598    #[cfg(feature = "tracing")]
599    let refine_span = info_span!(
600        "refine",
601        seeds = coarse_corners.len(),
602        roi_r = roi_ctx.roi_r
603    )
604    .entered();
605
606    let mut refined: Vec<Corner> = Vec::new();
607    for c in coarse_corners {
608        let Some((x0, y0, x1, y1)) = roi_ctx.compute_roi(&c) else {
609            continue;
610        };
611        let roi = match Roi::new(x0 as usize, y0 as usize, x1 as usize, y1 as usize) {
612            Some(r) => r,
613            None => continue,
614        };
615        let patch_resp = chess_response_u8_patch(base.data, base.width, base.height, params, roi);
616        if patch_resp.width() == 0 || patch_resp.height() == 0 {
617            continue;
618        }
619        let refine_view = ImageView::with_origin(base.width, base.height, base.data, [x0, y0])
620            .expect("base image dimensions must match buffer length");
621        let mut patch_corners = detect_fn(&patch_resp, params, Some(refine_view));
622        for pc in &mut patch_corners {
623            pc.x += x0 as f32;
624            pc.y += y0 as f32;
625        }
626        refined.extend(patch_corners);
627    }
628
629    #[cfg(feature = "tracing")]
630    drop(refine_span);
631
632    #[cfg(feature = "tracing")]
633    let merge_span = info_span!(
634        "merge",
635        merge_radius = cf.merge_radius,
636        candidates = refined.len()
637    )
638    .entered();
639    let merged = merge_corners_simple(&mut refined, cf.merge_radius);
640    #[cfg(feature = "tracing")]
641    drop(merge_span);
642
643    describe_corners(
644        base.data,
645        base.width,
646        base.height,
647        params.descriptor_ring_radius(),
648        merged,
649        params.orientation_method,
650    )
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use box_image_pyramid::ImageBuffer;
657
658    #[test]
659    fn default_coarse_to_fine_config_is_single_scale() {
660        let cfg = CoarseToFineParams::default();
661        assert_eq!(cfg.pyramid.num_levels, 1);
662        assert_eq!(cfg.pyramid.min_size, 128);
663        assert_eq!(cfg.refinement_radius, 3);
664        assert_eq!(cfg.merge_radius, 3.0);
665    }
666
667    #[test]
668    fn chess_config_multiscale_preset_has_expected_pyramid() {
669        let cfg = DetectorConfig::chess_multiscale();
670        let cf = cfg
671            .to_coarse_to_fine_params()
672            .expect("chess_multiscale preset must produce CoarseToFineParams");
673        assert_eq!(cf.pyramid.num_levels, 3);
674        assert_eq!(cf.pyramid.min_size, 128);
675        assert_eq!(cf.refinement_radius, 3);
676        assert_eq!(cf.merge_radius, 3.0);
677    }
678
679    #[test]
680    fn coarse_to_fine_trace_reports_timings() {
681        let buf = ImageBuffer::new(32, 32);
682        let view = ImageView::from_u8_slice(buf.width, buf.height, &buf.data)
683            .expect("dimensions must match");
684        let cfg = DetectorConfig::default();
685        let mut pyramid = PyramidBuffers::default();
686        let mut chess_buffers = ChessBuffers::default();
687        let mut radon_buffers = RadonBuffers::default();
688        let corners = detect_with_buffers(
689            view,
690            &cfg,
691            &mut pyramid,
692            &mut chess_buffers,
693            &mut radon_buffers,
694        );
695        assert!(corners.is_empty());
696    }
697}