Skip to main content

chess_corners_core/refine/
mod.rs

1//! Pluggable subpixel-refinement backends.
2//!
3//! All refiners implement the [`CornerRefiner`] trait and are
4//! addressed through the user-facing [`RefinerKind`] enum. Default
5//! settings select the center-of-mass refiner on the response map.
6//!
7//! - [`center_of_mass`] — 5×5 weighted centroid on the response
8//!   map. Cheap; the library default.
9//! - [`forstner`] — gradient structure-tensor refinement on the image
10//!   intensity patch.
11//! - [`saddle_point`] — quadratic surface fit on the image patch.
12
13use crate::imageview::ImageView;
14use crate::ResponseMap;
15use serde::{Deserialize, Serialize};
16
17pub mod center_of_mass;
18pub mod forstner;
19pub mod saddle_point;
20
21pub use center_of_mass::{CenterOfMassConfig, CenterOfMassRefiner};
22pub use forstner::{ForstnerConfig, ForstnerRefiner};
23pub use saddle_point::{SaddlePointConfig, SaddlePointRefiner};
24
25/// Sealing supertrait for [`CornerRefiner`]. Implemented only for the
26/// in-crate refiners, so downstream crates cannot add their own
27/// [`CornerRefiner`] implementations.
28mod private {
29    pub trait Sealed {}
30}
31
32/// Status of a refinement attempt.
33///
34/// A refiner returns one of these variants to indicate whether it
35/// produced a usable subpixel location and, if not, why it did not.
36///
37/// - [`Accepted`](RefineStatus::Accepted) — refinement converged; use
38///   `RefineResult::x` and `y`.
39/// - [`Rejected`](RefineStatus::Rejected) — the refiner ran but the
40///   result did not pass an acceptance criterion (e.g. the computed
41///   displacement exceeded [`ForstnerConfig::max_offset`]). Fall back to
42///   the original seed or skip this candidate.
43/// - [`OutOfBounds`](RefineStatus::OutOfBounds) — the seed is too close
44///   to the image border for the refiner's patch window. The seed
45///   coordinates in `RefineResult` are unchanged from the input.
46/// - [`IllConditioned`](RefineStatus::IllConditioned) — the patch does
47///   not contain enough structure for the refiner to produce a reliable
48///   estimate (e.g. too-flat gradient for Förstner, or a degenerate
49///   Hessian for saddle-point). The seed coordinates are unchanged.
50#[derive(Copy, Clone, Debug, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum RefineStatus {
53    /// Refinement converged; the result's `x`/`y` are usable.
54    Accepted,
55    /// The refiner ran but the result failed its acceptance criterion.
56    Rejected,
57    /// The seed is too close to the image border for the refiner's patch window.
58    OutOfBounds,
59    /// The patch lacks enough structure for a reliable estimate.
60    IllConditioned,
61}
62
63/// Result of refining a single corner candidate.
64///
65/// Coordinates are in the **input image pixel frame**, with the origin
66/// at the top-left corner of the top-left pixel (i.e. pixel (0, 0) has
67/// its center at `(0.0, 0.0)`). Both `x` and `y` are subpixel values
68/// in pixels — no normalization or scaling is applied by the refiner
69/// itself.
70///
71/// Check [`status`](RefineResult::status) before using `x` and `y`:
72/// only [`RefineStatus::Accepted`] guarantees that the refiner moved
73/// the position. For all other statuses the coordinates are copied
74/// unchanged from the input seed.
75///
76/// The [`score`](RefineResult::score) field measures how well the local
77/// image structure supports the refined position. Its meaning depends on
78/// the active refiner:
79///
80/// - **CenterOfMass**: `score` is the sum of positive response weights
81///   in the patch (`Σ w` where `w = response.clamp(0, ∞)`). Higher is
82///   stronger; units match the ChESS response scale. A score of `0.0`
83///   means no positive response was found.
84/// - **Förstner**: `score` is `det(T) / (trace(T)² + ε)` where `T` is
85///   the structure tensor. Ranges roughly in `(0, 0.25]`; higher means
86///   the patch has balanced gradient energy in both directions (closer to
87///   a true corner). The value `0.0` is returned on all failure paths.
88/// - **SaddlePoint**: `score` is `sqrt(|det(H)|)` where `H` is the
89///   fitted quadratic Hessian. Larger magnitude indicates a sharper
90///   saddle (steeper curvature); no absolute scale is defined.
91#[derive(Copy, Clone, Debug)]
92#[non_exhaustive]
93pub struct RefineResult {
94    /// Refined subpixel x coordinate in input-image pixels. Valid only
95    /// when [`status`](RefineResult::status) is
96    /// [`RefineStatus::Accepted`]; otherwise equals the input seed x.
97    pub x: f32,
98    /// Refined subpixel y coordinate in input-image pixels. Valid only
99    /// when [`status`](RefineResult::status) is
100    /// [`RefineStatus::Accepted`]; otherwise equals the input seed y.
101    pub y: f32,
102    /// Refiner-specific quality score. Higher values indicate stronger
103    /// evidence for the refined position. See the [`RefineResult`] docs
104    /// for per-refiner definitions. Always `0.0` on `OutOfBounds` and
105    /// may be `0.0` on other non-`Accepted` statuses.
106    pub score: f32,
107    /// Whether the refiner accepted, rejected, or could not process this
108    /// candidate. See [`RefineStatus`].
109    pub status: RefineStatus,
110}
111
112impl RefineResult {
113    /// Build a [`RefineStatus::Accepted`] result at `xy` with the given `score`.
114    #[inline]
115    pub fn accepted(xy: [f32; 2], score: f32) -> Self {
116        Self {
117            x: xy[0],
118            y: xy[1],
119            score,
120            status: RefineStatus::Accepted,
121        }
122    }
123}
124
125/// Inputs shared by refinement methods.
126///
127/// Callers pass whichever sources the active refiner requires:
128/// - [`CenterOfMassRefiner`] reads `response`; it ignores `image`.
129/// - [`ForstnerRefiner`] and [`SaddlePointRefiner`] read `image`;
130///   they ignore `response`.
131///
132/// Passing `None` for a required source causes the refiner to return
133/// [`RefineStatus::Rejected`] without moving the seed.
134#[derive(Copy, Clone, Debug, Default)]
135#[non_exhaustive]
136pub struct RefineContext<'a> {
137    /// Grayscale image view. Required by image-patch refiners
138    /// (Förstner, SaddlePoint).
139    pub image: Option<ImageView<'a>>,
140    /// Dense ChESS response map. Required by the CenterOfMass refiner.
141    pub response: Option<&'a ResponseMap>,
142}
143
144impl<'a> RefineContext<'a> {
145    /// Construct a [`RefineContext`] with the given image and response.
146    #[inline]
147    pub fn new(image: Option<ImageView<'a>>, response: Option<&'a ResponseMap>) -> Self {
148        Self { image, response }
149    }
150}
151
152/// Trait implemented by the built-in subpixel refinement backends.
153///
154/// # Stability
155///
156/// This trait is **sealed** via a private supertrait bound and cannot
157/// be implemented outside this crate. The built-in implementors are
158/// [`CenterOfMassRefiner`], [`ForstnerRefiner`], [`SaddlePointRefiner`],
159/// and the [`Refiner`] dispatcher. It is not a public extension point:
160/// select a backend through [`RefinerKind`] rather than implementing
161/// this trait.
162pub trait CornerRefiner: private::Sealed {
163    /// Half-width of the patch the refiner needs around the seed,
164    /// in input-image pixels. The caller must ensure the seed is at
165    /// least this many pixels away from every image border before
166    /// calling [`refine`](CornerRefiner::refine); violating this
167    /// contract yields [`RefineStatus::OutOfBounds`].
168    fn radius(&self) -> i32;
169    /// Attempt to refine the subpixel position of a corner candidate.
170    ///
171    /// `seed_xy` is the initial `[x, y]` position in input-image pixels
172    /// (origin at the top-left corner of the top-left pixel). Returns a
173    /// [`RefineResult`] whose `status` indicates whether the position was
174    /// updated.
175    fn refine(&mut self, seed_xy: [f32; 2], ctx: RefineContext<'_>) -> RefineResult;
176}
177
178/// User-facing enum selecting a refinement backend.
179///
180/// Each variant carries the configuration struct for that backend.
181/// Construct via [`RefinerKind::default`] for center-of-mass with
182/// library defaults, or use one of the variant constructors. The active
183/// backend is instantiated into a [`Refiner`] via [`Refiner::from_kind`].
184#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
185#[non_exhaustive]
186pub enum RefinerKind {
187    /// 5×5 weighted centroid on the response map.
188    CenterOfMass(CenterOfMassConfig),
189    /// Gradient structure-tensor refinement on the image patch.
190    Forstner(ForstnerConfig),
191    /// Quadratic surface fit on the image patch.
192    SaddlePoint(SaddlePointConfig),
193}
194
195impl Default for RefinerKind {
196    fn default() -> Self {
197        Self::CenterOfMass(CenterOfMassConfig::default())
198    }
199}
200
201/// Runtime refiner with reusable scratch buffers.
202///
203/// Constructed from a [`RefinerKind`] via [`Refiner::from_kind`].
204/// Implements [`CornerRefiner`] by dispatching to the selected backend.
205/// Reuse across many candidates on the same frame to amortize
206/// allocation; scratch buffers are sized at construction and are not
207/// reallocated on each call.
208#[derive(Debug)]
209#[non_exhaustive]
210pub enum Refiner {
211    /// 5×5 weighted centroid on the response map.
212    CenterOfMass(CenterOfMassRefiner),
213    /// Gradient structure-tensor refinement on the image patch.
214    Forstner(ForstnerRefiner),
215    /// Quadratic surface fit on the image patch.
216    SaddlePoint(SaddlePointRefiner),
217}
218
219impl Refiner {
220    /// Construct a [`Refiner`] from the given kind, allocating scratch
221    /// buffers at the configured size.
222    pub fn from_kind(kind: RefinerKind) -> Self {
223        match kind {
224            RefinerKind::CenterOfMass(cfg) => Refiner::CenterOfMass(CenterOfMassRefiner::new(cfg)),
225            RefinerKind::Forstner(cfg) => Refiner::Forstner(ForstnerRefiner::new(cfg)),
226            RefinerKind::SaddlePoint(cfg) => Refiner::SaddlePoint(SaddlePointRefiner::new(cfg)),
227        }
228    }
229}
230
231impl CornerRefiner for Refiner {
232    #[inline]
233    fn radius(&self) -> i32 {
234        match self {
235            Refiner::CenterOfMass(r) => r.radius(),
236            Refiner::Forstner(r) => r.radius(),
237            Refiner::SaddlePoint(r) => r.radius(),
238        }
239    }
240
241    #[inline]
242    fn refine(&mut self, seed_xy: [f32; 2], ctx: RefineContext<'_>) -> RefineResult {
243        match self {
244            Refiner::CenterOfMass(r) => r.refine(seed_xy, ctx),
245            Refiner::Forstner(r) => r.refine(seed_xy, ctx),
246            Refiner::SaddlePoint(r) => r.refine(seed_xy, ctx),
247        }
248    }
249}
250
251impl private::Sealed for CenterOfMassRefiner {}
252impl private::Sealed for ForstnerRefiner {}
253impl private::Sealed for SaddlePointRefiner {}
254impl private::Sealed for Refiner {}
255
256#[cfg(test)]
257pub(crate) mod test_fixtures {
258    /// Mildly-blurred synthetic chessboard centred at `offset`. Used
259    /// across the per-refiner test modules.
260    pub(crate) fn synthetic_checkerboard(
261        size: usize,
262        offset: (f32, f32),
263        dark: u8,
264        bright: u8,
265    ) -> Vec<u8> {
266        let mut img = vec![0u8; size * size];
267        let ox = offset.0;
268        let oy = offset.1;
269        for y in 0..size {
270            for x in 0..size {
271                let xf = x as f32 - ox;
272                let yf = y as f32 - oy;
273                let dark_quad = (xf >= 0.0 && yf >= 0.0) || (xf < 0.0 && yf < 0.0);
274                img[y * size + x] = if dark_quad { dark } else { bright };
275            }
276        }
277        let mut blurred = img.clone();
278        for y in 1..(size - 1) {
279            for x in 1..(size - 1) {
280                let mut acc = 0u32;
281                for ky in -1..=1 {
282                    for kx in -1..=1 {
283                        acc +=
284                            img[(y as i32 + ky) as usize * size + (x as i32 + kx) as usize] as u32;
285                    }
286                }
287                blurred[y * size + x] = (acc / 9) as u8;
288            }
289        }
290        blurred
291    }
292}