chess_corners_core/refine/
saddle_point.rs1use super::{CornerRefiner, RefineContext, RefineResult, RefineStatus};
10use serde::{Deserialize, Serialize};
11
12#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
18#[serde(default)]
19#[non_exhaustive]
20pub struct SaddlePointConfig {
21 pub radius: i32,
27 pub det_margin: f32,
34 pub max_offset: f32,
40 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#[derive(Debug)]
68pub struct SaddlePointRefiner {
69 cfg: SaddlePointConfig,
70 m: [f32; 36],
71 rhs: [f32; 6],
72}
73
74impl SaddlePointRefiner {
75 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 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 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}