Skip to main content

chess_corners_core/refine/
saddle_point.rs

1//! Saddle-point quadratic-surface refiner.
2//!
3//! Fits a 2nd-order surface `I(x, y) = a x² + b x y + c y² + d x + e y + f`
4//! to the image patch around the seed and locates the unique
5//! stationary point of the resulting quadratic. The Hessian
6//! `[2a b; b 2c]` must have negative determinant (a saddle) for the
7//! corner to be accepted.
8
9use super::{CornerRefiner, RefineContext, RefineResult, RefineStatus};
10use serde::{Deserialize, Serialize};
11
12/// Configuration for the [`SaddlePointRefiner`].
13///
14/// All thresholds below are advanced tuning knobs. The defaults are
15/// appropriate for most scenes; adjust only if you observe excessive
16/// rejection or acceptance of clearly-wrong refinements.
17#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
18#[serde(default)]
19#[non_exhaustive]
20pub struct SaddlePointConfig {
21    /// Half-size of the image patch used for the surface fit
22    /// (full patch is `2·radius+1` × `2·radius+1`). Default is `2`
23    /// (5×5 patch).
24    ///
25    /// Advanced tuning.
26    pub radius: i32,
27    /// The Hessian determinant of the fitted quadratic must be more
28    /// negative than `-det_margin` (i.e. `det(H) < -det_margin`) for
29    /// a saddle to be confirmed. Increase to require a sharper saddle
30    /// before accepting.
31    ///
32    /// Advanced tuning.
33    pub det_margin: f32,
34    /// Maximum displacement (pixels) from the seed to the fitted
35    /// stationary point. Refinements with a larger displacement are
36    /// rejected. Mirrors `ForstnerConfig::max_offset`.
37    ///
38    /// Advanced tuning.
39    pub max_offset: f32,
40    /// Minimum absolute value of `det(H)`. Rejects near-flat surfaces
41    /// where the determinant is too close to zero to be meaningful.
42    ///
43    /// Advanced tuning.
44    pub min_abs_det: f32,
45}
46
47impl Default for SaddlePointConfig {
48    fn default() -> Self {
49        Self {
50            radius: 2,
51            det_margin: 1e-3,
52            max_offset: 1.5,
53            min_abs_det: 1e-4,
54        }
55    }
56}
57
58/// Saddle-point quadratic-surface subpixel refiner.
59///
60/// Fits a 2nd-order surface to the image patch and locates the saddle
61/// (the unique stationary point where the Hessian is indefinite). The
62/// refiner requires the image intensity patch (passed via
63/// [`RefineContext::image`]); it ignores the response map.
64///
65/// Reuses a fixed-size `6×6` scratch matrix across calls so there is no
66/// per-corner allocation.
67#[derive(Debug)]
68pub struct SaddlePointRefiner {
69    cfg: SaddlePointConfig,
70    m: [f32; 36],
71    rhs: [f32; 6],
72}
73
74impl SaddlePointRefiner {
75    /// Construct a refiner with the given configuration.
76    pub fn new(cfg: SaddlePointConfig) -> Self {
77        Self {
78            cfg,
79            m: [0.0; 36],
80            rhs: [0.0; 6],
81        }
82    }
83
84    fn solve_6x6(&mut self) -> Option<[f32; 6]> {
85        // Simple Gauss-Jordan elimination with partial pivoting on the stack.
86        for i in 0..6 {
87            let mut pivot = i;
88            let mut pivot_val = self.m[i * 6 + i].abs();
89            for r in (i + 1)..6 {
90                let v = self.m[r * 6 + i].abs();
91                if v > pivot_val {
92                    pivot = r;
93                    pivot_val = v;
94                }
95            }
96
97            if pivot_val < 1e-9 {
98                return None;
99            }
100
101            if pivot != i {
102                for c in i..6 {
103                    self.m.swap(i * 6 + c, pivot * 6 + c);
104                }
105                self.rhs.swap(i, pivot);
106            }
107
108            let diag = self.m[i * 6 + i];
109            let inv_diag = 1.0 / diag;
110
111            for c in i..6 {
112                self.m[i * 6 + c] *= inv_diag;
113            }
114            self.rhs[i] *= inv_diag;
115
116            for r in 0..6 {
117                if r == i {
118                    continue;
119                }
120                let factor = self.m[r * 6 + i];
121                if factor == 0.0 {
122                    continue;
123                }
124                for c in i..6 {
125                    self.m[r * 6 + c] -= factor * self.m[i * 6 + c];
126                }
127                self.rhs[r] -= factor * self.rhs[i];
128            }
129        }
130
131        let mut out = [0.0f32; 6];
132        out.copy_from_slice(&self.rhs);
133        Some(out)
134    }
135}
136
137impl CornerRefiner for SaddlePointRefiner {
138    #[inline]
139    fn radius(&self) -> i32 {
140        self.cfg.radius
141    }
142
143    fn refine(&mut self, seed_xy: [f32; 2], ctx: RefineContext<'_>) -> RefineResult {
144        let img = match ctx.image {
145            Some(view) => view,
146            None => {
147                return RefineResult {
148                    x: seed_xy[0],
149                    y: seed_xy[1],
150                    score: 0.0,
151                    status: RefineStatus::Rejected,
152                }
153            }
154        };
155
156        let cx = seed_xy[0].round() as i32;
157        let cy = seed_xy[1].round() as i32;
158        let r = self.cfg.radius;
159
160        if !img.supports_patch(cx, cy, r) {
161            return RefineResult {
162                x: seed_xy[0],
163                y: seed_xy[1],
164                score: 0.0,
165                status: RefineStatus::OutOfBounds,
166            };
167        }
168
169        let mut sum = 0.0f32;
170        let mut count = 0.0f32;
171        for dy in -r..=r {
172            let gy = cy + dy;
173            for dx in -r..=r {
174                let gx = cx + dx;
175                sum += img.sample(gx, gy);
176                count += 1.0;
177            }
178        }
179
180        let mean = if count > 0.0 { sum / count } else { 0.0 };
181
182        self.m.fill(0.0);
183        self.rhs.fill(0.0);
184
185        for dy in -r..=r {
186            let gy = cy + dy;
187            for dx in -r..=r {
188                let gx = cx + dx;
189                let i = img.sample(gx, gy) - mean;
190
191                let x = gx as f32 - seed_xy[0];
192                let y = gy as f32 - seed_xy[1];
193                let phi = [x * x, x * y, y * y, x, y, 1.0];
194
195                for row in 0..6 {
196                    self.rhs[row] += phi[row] * i;
197                    for col in row..6 {
198                        self.m[row * 6 + col] += phi[row] * phi[col];
199                    }
200                }
201            }
202        }
203
204        // Fill the lower triangle to make elimination logic simpler.
205        for row in 0..6 {
206            for col in 0..row {
207                self.m[row * 6 + col] = self.m[col * 6 + row];
208            }
209        }
210
211        let coeffs = match self.solve_6x6() {
212            Some(c) => c,
213            None => {
214                return RefineResult {
215                    x: seed_xy[0],
216                    y: seed_xy[1],
217                    score: 0.0,
218                    status: RefineStatus::IllConditioned,
219                }
220            }
221        };
222
223        let a = coeffs[0];
224        let b = coeffs[1];
225        let c = coeffs[2];
226        let d = coeffs[3];
227        let e = coeffs[4];
228
229        let det_h = 4.0 * a * c - b * b;
230        if det_h > -self.cfg.det_margin || det_h.abs() < self.cfg.min_abs_det {
231            return RefineResult {
232                x: seed_xy[0],
233                y: seed_xy[1],
234                score: det_h,
235                status: RefineStatus::IllConditioned,
236            };
237        }
238
239        let inv_det = 1.0 / det_h;
240        let ux = -(2.0 * c * d - b * e) * inv_det;
241        let uy = (b * d - 2.0 * a * e) * inv_det;
242
243        let max_off = self.cfg.max_offset.min(r as f32 + 0.5);
244        if ux.abs() > max_off || uy.abs() > max_off {
245            return RefineResult {
246                x: seed_xy[0],
247                y: seed_xy[1],
248                score: det_h,
249                status: RefineStatus::Rejected,
250            };
251        }
252
253        let score = (-det_h).sqrt();
254        RefineResult::accepted([seed_xy[0] + ux, seed_xy[1] + uy], score)
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::super::test_fixtures::synthetic_checkerboard;
261    use super::*;
262    use crate::imageview::ImageView;
263
264    #[test]
265    fn saddle_point_recovers_stationary_point_and_rejects_flat() {
266        let img = synthetic_checkerboard(17, (8.2, 8.6), 30, 230);
267        let view = ImageView::from_u8_slice(17, 17, &img).unwrap();
268        let ctx = RefineContext {
269            image: Some(view),
270            response: None,
271        };
272        let mut refiner = SaddlePointRefiner::new(SaddlePointConfig::default());
273        let res = refiner.refine([8.0, 9.0], ctx);
274        assert_eq!(res.status, RefineStatus::Accepted);
275        let true_xy = [8.2f32, 8.6f32];
276        let seed_err = ((8.0 - true_xy[0]).powi(2) + (9.0 - true_xy[1]).powi(2)).sqrt();
277        let refined_err = ((res.x - true_xy[0]).powi(2) + (res.y - true_xy[1]).powi(2)).sqrt();
278        assert!(
279            refined_err <= seed_err * 1.6 && refined_err < 1.0,
280            "refined_err {refined_err} seed_err {seed_err} res {:?}",
281            (res.x, res.y)
282        );
283
284        let flat = vec![128u8; 25];
285        let flat_view = ImageView::from_u8_slice(5, 5, &flat).unwrap();
286        let flat_ctx = RefineContext {
287            image: Some(flat_view),
288            response: None,
289        };
290        let flat_res = refiner.refine([2.0, 2.0], flat_ctx);
291        assert_ne!(flat_res.status, RefineStatus::Accepted);
292    }
293}