chess_corners_core/refine/
forstner.rs1use super::{CornerRefiner, RefineContext, RefineResult, RefineStatus};
14use serde::{Deserialize, Serialize};
15
16#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
18#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
19#[serde(default)]
20#[non_exhaustive]
21pub struct ForstnerConfig {
22 #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
27 #[cfg_attr(feature = "schemars", schemars(extend("x-unit" = "px")))]
28 pub radius: i32,
29 #[cfg_attr(feature = "schemars", schemars(range(min = 0.0)))]
34 pub min_trace: f32,
35 #[cfg_attr(feature = "schemars", schemars(range(min = 0.0)))]
40 pub min_det: f32,
41 #[cfg_attr(feature = "schemars", schemars(range(min = 1.0)))]
46 pub max_condition_number: f32,
47 #[cfg_attr(feature = "schemars", schemars(range(min = 0.0)))]
51 #[cfg_attr(feature = "schemars", schemars(extend("x-unit" = "px")))]
52 pub max_offset: f32,
53}
54
55impl Default for ForstnerConfig {
56 fn default() -> Self {
57 Self {
58 radius: 2,
59 min_trace: 25.0,
60 min_det: 1e-3,
61 max_condition_number: 50.0,
62 max_offset: 1.5,
63 }
64 }
65}
66
67#[derive(Debug)]
73pub struct ForstnerRefiner {
74 cfg: ForstnerConfig,
75}
76
77impl ForstnerRefiner {
78 pub fn new(cfg: ForstnerConfig) -> Self {
80 Self { cfg }
81 }
82}
83
84impl CornerRefiner for ForstnerRefiner {
85 #[inline]
86 fn radius(&self) -> i32 {
87 self.cfg.radius + 1
89 }
90
91 fn refine(&mut self, seed_xy: [f32; 2], ctx: RefineContext<'_>) -> RefineResult {
92 let img = match ctx.image {
93 Some(view) => view,
94 None => {
95 return RefineResult {
96 x: seed_xy[0],
97 y: seed_xy[1],
98 score: 0.0,
99 status: RefineStatus::Rejected,
100 }
101 }
102 };
103
104 let cx = seed_xy[0].round() as i32;
105 let cy = seed_xy[1].round() as i32;
106 let patch_r = self.cfg.radius;
107
108 if !img.supports_patch(cx, cy, patch_r + 1) {
109 return RefineResult {
110 x: seed_xy[0],
111 y: seed_xy[1],
112 score: 0.0,
113 status: RefineStatus::OutOfBounds,
114 };
115 }
116
117 let mut a00 = 0.0;
118 let mut a01 = 0.0;
119 let mut a11 = 0.0;
120 let mut bx = 0.0;
121 let mut by = 0.0;
122
123 for dy in -patch_r..=patch_r {
124 let gy = cy + dy;
125 for dx in -patch_r..=patch_r {
126 let gx = cx + dx;
127
128 let ix_plus = img.sample(gx + 1, gy);
129 let ix_minus = img.sample(gx - 1, gy);
130 let iy_plus = img.sample(gx, gy + 1);
131 let iy_minus = img.sample(gx, gy - 1);
132
133 let gx_f = 0.5 * (ix_plus - ix_minus);
134 let gy_f = 0.5 * (iy_plus - iy_minus);
135
136 let px = gx as f32 - seed_xy[0];
137 let py = gy as f32 - seed_xy[1];
138 let gxgx = gx_f * gx_f;
139 let gxgy = gx_f * gy_f;
140 let gygy = gy_f * gy_f;
141 let dist2 = px * px + py * py;
142 let w = 1.0 / (1.0 + 0.5 * dist2);
143
144 a00 += w * gxgx;
145 a01 += w * gxgy;
146 a11 += w * gygy;
147
148 bx += w * (gxgx * px + gxgy * py);
150 by += w * (gxgy * px + gygy * py);
151 }
152 }
153
154 let trace = a00 + a11;
155 let det = a00 * a11 - a01 * a01;
156 if trace < self.cfg.min_trace || det <= self.cfg.min_det {
157 return RefineResult {
158 x: seed_xy[0],
159 y: seed_xy[1],
160 score: det,
161 status: RefineStatus::IllConditioned,
162 };
163 }
164
165 let discr = (trace * trace - 4.0 * det).max(0.0).sqrt();
166 let lambda_min = 0.5 * (trace - discr);
167 let lambda_max = 0.5 * (trace + discr);
168
169 if lambda_min <= 0.0 {
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 cond = lambda_max / lambda_min;
179 if !cond.is_finite() || cond > self.cfg.max_condition_number {
180 return RefineResult {
181 x: seed_xy[0],
182 y: seed_xy[1],
183 score: det,
184 status: RefineStatus::IllConditioned,
185 };
186 }
187
188 let inv_det = 1.0 / det;
189 let ux = (a11 * bx - a01 * by) * inv_det;
190 let uy = (-a01 * bx + a00 * by) * inv_det;
191
192 let max_off = self.cfg.max_offset.min(self.cfg.radius as f32 + 0.5);
193 if ux.abs() > max_off || uy.abs() > max_off {
194 return RefineResult {
195 x: seed_xy[0],
196 y: seed_xy[1],
197 score: det,
198 status: RefineStatus::Rejected,
199 };
200 }
201
202 let score = det / (trace * trace + 1e-6);
203 RefineResult::accepted([seed_xy[0] + ux, seed_xy[1] + uy], score)
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::super::test_fixtures::synthetic_checkerboard;
210 use super::*;
211 use crate::imageview::ImageView;
212
213 #[test]
214 fn forstner_refines_toward_true_offset() {
215 let img = synthetic_checkerboard(15, (7.35, 7.8), 40, 220);
216 let view = ImageView::from_u8_slice(15, 15, &img).unwrap();
217 let ctx = RefineContext {
218 image: Some(view),
219 response: None,
220 };
221 let mut refiner = ForstnerRefiner::new(ForstnerConfig::default());
222 let res = refiner.refine([7.0, 8.0], ctx);
223 assert_eq!(res.status, RefineStatus::Accepted);
224 let true_xy = [7.35f32, 7.8f32];
225 let seed_err = ((7.0 - true_xy[0]).powi(2) + (8.0 - true_xy[1]).powi(2)).sqrt();
226 let refined_err = ((res.x - true_xy[0]).powi(2) + (res.y - true_xy[1]).powi(2)).sqrt();
227 assert!(
228 refined_err <= seed_err * 1.6 && refined_err < 1.0,
229 "refined_err {refined_err} seed_err {seed_err} res {:?}",
230 (res.x, res.y)
231 );
232 }
233}