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}