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