Skip to main content

chess_corners_core/refine/
forstner.rs

1//! Förstner-style gradient-based subpixel refinement.
2//!
3//! The Förstner operator fits a subpixel corner location by solving a
4//! weighted least-squares system on the image gradient structure tensor
5//! within a local window. The thresholds in [`ForstnerConfig`] control
6//! when the system is well-conditioned enough to yield a reliable
7//! estimate.
8//!
9//! Reference: Förstner, W. & Gülch, E. (1987). "A fast operator for
10//! detection and precise location of distinct points, corners and
11//! centres of circular features."
12
13use super::{CornerRefiner, RefineContext, RefineResult, RefineStatus};
14use serde::{Deserialize, Serialize};
15
16/// Configuration for the [`ForstnerRefiner`].
17#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
18#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
19#[serde(default)]
20#[non_exhaustive]
21pub struct ForstnerConfig {
22    /// Half-size of the local gradient window, in image pixels (integer
23    /// `>= 1`; the window is `(2·radius+1)²`). Default `2` gives a 5×5
24    /// patch, large enough to capture the gradient structure around a
25    /// corner while staying local.
26    #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
27    #[cfg_attr(feature = "schemars", schemars(extend("x-unit" = "px")))]
28    pub radius: i32,
29    /// Minimum trace of the structure tensor (sum of eigenvalues; `>= 0`,
30    /// in squared gradient units). Rejects flat regions with too little
31    /// gradient energy; the default `25.0` is roughly a mean gradient
32    /// magnitude of 5 per pixel in a 5×5 window.
33    #[cfg_attr(feature = "schemars", schemars(range(min = 0.0)))]
34    pub min_trace: f32,
35    /// Minimum determinant of the structure tensor (product of
36    /// eigenvalues; `>= 0`). Guards against singular systems where the
37    /// least-squares solution is numerically unstable; the default `1e-3`
38    /// rejects only truly degenerate cases.
39    #[cfg_attr(feature = "schemars", schemars(range(min = 0.0)))]
40    pub min_det: f32,
41    /// Maximum ratio of the larger to the smaller structure-tensor
42    /// eigenvalue (unitless, `>= 1`). A high ratio indicates an edge
43    /// rather than a corner; the default `50.0` is permissive (literature
44    /// values span roughly 10–100).
45    #[cfg_attr(feature = "schemars", schemars(range(min = 1.0)))]
46    pub max_condition_number: f32,
47    /// Maximum displacement from the initial integer seed to the refined
48    /// location, in pixels (`>= 0`). Larger offsets suggest a mislocated
49    /// seed and the result is rejected; the default is `1.5`.
50    #[cfg_attr(feature = "schemars", schemars(range(min = 0.0)))]
51    #[cfg_attr(feature = "schemars", schemars(extend("x-unit" = "px")))]
52    pub max_offset: f32,
53}
54
55impl Default for ForstnerConfig {
56    fn default() -> Self {
57        Self {
58            radius: 2,
59            min_trace: 25.0,
60            min_det: 1e-3,
61            max_condition_number: 50.0,
62            max_offset: 1.5,
63        }
64    }
65}
66
67/// Förstner structure-tensor subpixel refiner.
68///
69/// Solves a weighted least-squares system on the image gradient
70/// structure tensor within a local window. Requires the image intensity
71/// patch (passed via [`RefineContext::image`]); ignores the response map.
72#[derive(Debug)]
73pub struct ForstnerRefiner {
74    cfg: ForstnerConfig,
75}
76
77impl ForstnerRefiner {
78    /// Construct a refiner with the given configuration.
79    pub fn new(cfg: ForstnerConfig) -> Self {
80        Self { cfg }
81    }
82}
83
84impl CornerRefiner for ForstnerRefiner {
85    #[inline]
86    fn radius(&self) -> i32 {
87        // Gradients sample one pixel beyond the interior, so reserve an extra pixel.
88        self.cfg.radius + 1
89    }
90
91    fn refine(&mut self, seed_xy: [f32; 2], ctx: RefineContext<'_>) -> RefineResult {
92        let img = match ctx.image {
93            Some(view) => view,
94            None => {
95                return RefineResult {
96                    x: seed_xy[0],
97                    y: seed_xy[1],
98                    score: 0.0,
99                    status: RefineStatus::Rejected,
100                }
101            }
102        };
103
104        let cx = seed_xy[0].round() as i32;
105        let cy = seed_xy[1].round() as i32;
106        let patch_r = self.cfg.radius;
107
108        if !img.supports_patch(cx, cy, patch_r + 1) {
109            return RefineResult {
110                x: seed_xy[0],
111                y: seed_xy[1],
112                score: 0.0,
113                status: RefineStatus::OutOfBounds,
114            };
115        }
116
117        let mut a00 = 0.0;
118        let mut a01 = 0.0;
119        let mut a11 = 0.0;
120        let mut bx = 0.0;
121        let mut by = 0.0;
122
123        for dy in -patch_r..=patch_r {
124            let gy = cy + dy;
125            for dx in -patch_r..=patch_r {
126                let gx = cx + dx;
127
128                let ix_plus = img.sample(gx + 1, gy);
129                let ix_minus = img.sample(gx - 1, gy);
130                let iy_plus = img.sample(gx, gy + 1);
131                let iy_minus = img.sample(gx, gy - 1);
132
133                let gx_f = 0.5 * (ix_plus - ix_minus);
134                let gy_f = 0.5 * (iy_plus - iy_minus);
135
136                let px = gx as f32 - seed_xy[0];
137                let py = gy as f32 - seed_xy[1];
138                let gxgx = gx_f * gx_f;
139                let gxgy = gx_f * gy_f;
140                let gygy = gy_f * gy_f;
141                let dist2 = px * px + py * py;
142                let w = 1.0 / (1.0 + 0.5 * dist2);
143
144                a00 += w * gxgx;
145                a01 += w * gxgy;
146                a11 += w * gygy;
147
148                // b = Σ w g gᵀ p  (derivation from minimizing first-moment error)
149                bx += w * (gxgx * px + gxgy * py);
150                by += w * (gxgy * px + gygy * py);
151            }
152        }
153
154        let trace = a00 + a11;
155        let det = a00 * a11 - a01 * a01;
156        if trace < self.cfg.min_trace || det <= self.cfg.min_det {
157            return RefineResult {
158                x: seed_xy[0],
159                y: seed_xy[1],
160                score: det,
161                status: RefineStatus::IllConditioned,
162            };
163        }
164
165        let discr = (trace * trace - 4.0 * det).max(0.0).sqrt();
166        let lambda_min = 0.5 * (trace - discr);
167        let lambda_max = 0.5 * (trace + discr);
168
169        if lambda_min <= 0.0 {
170            return RefineResult {
171                x: seed_xy[0],
172                y: seed_xy[1],
173                score: det,
174                status: RefineStatus::IllConditioned,
175            };
176        }
177
178        let cond = lambda_max / lambda_min;
179        if !cond.is_finite() || cond > self.cfg.max_condition_number {
180            return RefineResult {
181                x: seed_xy[0],
182                y: seed_xy[1],
183                score: det,
184                status: RefineStatus::IllConditioned,
185            };
186        }
187
188        let inv_det = 1.0 / det;
189        let ux = (a11 * bx - a01 * by) * inv_det;
190        let uy = (-a01 * bx + a00 * by) * inv_det;
191
192        let max_off = self.cfg.max_offset.min(self.cfg.radius as f32 + 0.5);
193        if ux.abs() > max_off || uy.abs() > max_off {
194            return RefineResult {
195                x: seed_xy[0],
196                y: seed_xy[1],
197                score: det,
198                status: RefineStatus::Rejected,
199            };
200        }
201
202        let score = det / (trace * trace + 1e-6);
203        RefineResult::accepted([seed_xy[0] + ux, seed_xy[1] + uy], score)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::super::test_fixtures::synthetic_checkerboard;
210    use super::*;
211    use crate::imageview::ImageView;
212
213    #[test]
214    fn forstner_refines_toward_true_offset() {
215        let img = synthetic_checkerboard(15, (7.35, 7.8), 40, 220);
216        let view = ImageView::from_u8_slice(15, 15, &img).unwrap();
217        let ctx = RefineContext {
218            image: Some(view),
219            response: None,
220        };
221        let mut refiner = ForstnerRefiner::new(ForstnerConfig::default());
222        let res = refiner.refine([7.0, 8.0], ctx);
223        assert_eq!(res.status, RefineStatus::Accepted);
224        let true_xy = [7.35f32, 7.8f32];
225        let seed_err = ((7.0 - true_xy[0]).powi(2) + (8.0 - true_xy[1]).powi(2)).sqrt();
226        let refined_err = ((res.x - true_xy[0]).powi(2) + (res.y - true_xy[1]).powi(2)).sqrt();
227        assert!(
228            refined_err <= seed_err * 1.6 && refined_err < 1.0,
229            "refined_err {refined_err} seed_err {seed_err} res {:?}",
230            (res.x, res.y)
231        );
232    }
233}