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