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#[serde(default)]
19pub struct ForstnerConfig {
20    /// Half-size of the local gradient window (full window is
21    /// `2*radius+1`). A radius of 2 gives a 5×5 patch — large enough to
22    /// capture the gradient structure around a corner while staying
23    /// local.
24    pub radius: i32,
25    /// Minimum trace of the structure tensor (sum of eigenvalues).
26    /// Rejects flat regions where gradient energy is too low. The
27    /// value 25.0 corresponds roughly to an average gradient magnitude
28    /// of ~5 per pixel in a 5×5 window (5² = 25), filtering out
29    /// textureless areas.
30    pub min_trace: f32,
31    /// Minimum determinant of the structure tensor (product of
32    /// eigenvalues). Guards against singular or near-singular systems
33    /// where the least-squares solution is numerically unstable. 1e-3
34    /// is a conservative floor that rejects only truly degenerate
35    /// cases.
36    pub min_det: f32,
37    /// Maximum ratio of the larger to the smaller eigenvalue. A high
38    /// condition number indicates an edge rather than a corner (one
39    /// dominant gradient direction). The threshold 50.0 is permissive
40    /// — standard Harris/Förstner literature suggests values in the
41    /// 10–100 range depending on noise level and corner sharpness.
42    pub max_condition_number: f32,
43    /// Maximum displacement (in pixels) from the initial integer seed
44    /// to the refined subpixel location. Offsets larger than ~1.5 px
45    /// suggest the seed was mislocated and the refinement is
46    /// extrapolating rather than interpolating; such results are
47    /// rejected.
48    pub max_offset: f32,
49}
50
51impl Default for ForstnerConfig {
52    fn default() -> Self {
53        Self {
54            radius: 2,
55            min_trace: 25.0,
56            min_det: 1e-3,
57            max_condition_number: 50.0,
58            max_offset: 1.5,
59        }
60    }
61}
62
63#[derive(Debug)]
64pub struct ForstnerRefiner {
65    cfg: ForstnerConfig,
66}
67
68impl ForstnerRefiner {
69    pub fn new(cfg: ForstnerConfig) -> Self {
70        Self { cfg }
71    }
72}
73
74impl CornerRefiner for ForstnerRefiner {
75    #[inline]
76    fn radius(&self) -> i32 {
77        // Gradients sample one pixel beyond the interior, so reserve an extra pixel.
78        self.cfg.radius + 1
79    }
80
81    fn refine(&mut self, seed_xy: [f32; 2], ctx: RefineContext<'_>) -> RefineResult {
82        let img = match ctx.image {
83            Some(view) => view,
84            None => {
85                return RefineResult {
86                    x: seed_xy[0],
87                    y: seed_xy[1],
88                    score: 0.0,
89                    status: RefineStatus::Rejected,
90                }
91            }
92        };
93
94        let cx = seed_xy[0].round() as i32;
95        let cy = seed_xy[1].round() as i32;
96        let patch_r = self.cfg.radius;
97
98        if !img.supports_patch(cx, cy, patch_r + 1) {
99            return RefineResult {
100                x: seed_xy[0],
101                y: seed_xy[1],
102                score: 0.0,
103                status: RefineStatus::OutOfBounds,
104            };
105        }
106
107        let mut a00 = 0.0;
108        let mut a01 = 0.0;
109        let mut a11 = 0.0;
110        let mut bx = 0.0;
111        let mut by = 0.0;
112
113        for dy in -patch_r..=patch_r {
114            let gy = cy + dy;
115            for dx in -patch_r..=patch_r {
116                let gx = cx + dx;
117
118                let ix_plus = img.sample(gx + 1, gy);
119                let ix_minus = img.sample(gx - 1, gy);
120                let iy_plus = img.sample(gx, gy + 1);
121                let iy_minus = img.sample(gx, gy - 1);
122
123                let gx_f = 0.5 * (ix_plus - ix_minus);
124                let gy_f = 0.5 * (iy_plus - iy_minus);
125
126                let px = gx as f32 - seed_xy[0];
127                let py = gy as f32 - seed_xy[1];
128                let gxgx = gx_f * gx_f;
129                let gxgy = gx_f * gy_f;
130                let gygy = gy_f * gy_f;
131                let dist2 = px * px + py * py;
132                let w = 1.0 / (1.0 + 0.5 * dist2);
133
134                a00 += w * gxgx;
135                a01 += w * gxgy;
136                a11 += w * gygy;
137
138                // b = Σ w g gᵀ p  (derivation from minimizing first-moment error)
139                bx += w * (gxgx * px + gxgy * py);
140                by += w * (gxgy * px + gygy * py);
141            }
142        }
143
144        let trace = a00 + a11;
145        let det = a00 * a11 - a01 * a01;
146        if trace < self.cfg.min_trace || det <= self.cfg.min_det {
147            return RefineResult {
148                x: seed_xy[0],
149                y: seed_xy[1],
150                score: det,
151                status: RefineStatus::IllConditioned,
152            };
153        }
154
155        let discr = (trace * trace - 4.0 * det).max(0.0).sqrt();
156        let lambda_min = 0.5 * (trace - discr);
157        let lambda_max = 0.5 * (trace + discr);
158
159        if lambda_min <= 0.0 {
160            return RefineResult {
161                x: seed_xy[0],
162                y: seed_xy[1],
163                score: det,
164                status: RefineStatus::IllConditioned,
165            };
166        }
167
168        let cond = lambda_max / lambda_min;
169        if !cond.is_finite() || cond > self.cfg.max_condition_number {
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 inv_det = 1.0 / det;
179        let ux = (a11 * bx - a01 * by) * inv_det;
180        let uy = (-a01 * bx + a00 * by) * inv_det;
181
182        let max_off = self.cfg.max_offset.min(self.cfg.radius as f32 + 0.5);
183        if ux.abs() > max_off || uy.abs() > max_off {
184            return RefineResult {
185                x: seed_xy[0],
186                y: seed_xy[1],
187                score: det,
188                status: RefineStatus::Rejected,
189            };
190        }
191
192        let score = det / (trace * trace + 1e-6);
193        RefineResult::accepted([seed_xy[0] + ux, seed_xy[1] + uy], score)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::super::test_fixtures::synthetic_checkerboard;
200    use super::*;
201    use crate::imageview::ImageView;
202
203    #[test]
204    fn forstner_refines_toward_true_offset() {
205        let img = synthetic_checkerboard(15, (7.35, 7.8), 40, 220);
206        let view = ImageView::from_u8_slice(15, 15, &img).unwrap();
207        let ctx = RefineContext {
208            image: Some(view),
209            response: None,
210        };
211        let mut refiner = ForstnerRefiner::new(ForstnerConfig::default());
212        let res = refiner.refine([7.0, 8.0], ctx);
213        assert_eq!(res.status, RefineStatus::Accepted);
214        let true_xy = [7.35f32, 7.8f32];
215        let seed_err = ((7.0 - true_xy[0]).powi(2) + (8.0 - true_xy[1]).powi(2)).sqrt();
216        let refined_err = ((res.x - true_xy[0]).powi(2) + (res.y - true_xy[1]).powi(2)).sqrt();
217        assert!(
218            refined_err <= seed_err * 1.6 && refined_err < 1.0,
219            "refined_err {refined_err} seed_err {seed_err} res {:?}",
220            (res.x, res.y)
221        );
222    }
223}