1use super::descriptor::{ring_angles, sample_ring};
13use super::{disk_sector, ring_fit, ring_fit_for_image, OrientationMethod};
14use crate::detect::chess::ring::ring_offsets;
15use crate::imageview::ImageView;
16
17#[derive(Clone, Copy, Debug)]
21#[non_exhaustive]
22pub struct AxisFitResult {
23 pub amp: f32,
26 pub theta1: f32,
29 pub theta2: f32,
31 pub sigma_theta1: f32,
33 pub sigma_theta2: f32,
35 pub rms: f32,
37}
38
39impl From<ring_fit::TwoAxisFit> for AxisFitResult {
40 #[inline]
41 fn from(v: ring_fit::TwoAxisFit) -> Self {
42 Self {
43 amp: v.amp,
44 theta1: v.theta1,
45 theta2: v.theta2,
46 sigma_theta1: v.sigma_theta1,
47 sigma_theta2: v.sigma_theta2,
48 rms: v.rms,
49 }
50 }
51}
52
53pub fn fit_axes_at_point(
62 view: ImageView<'_>,
63 cx: f32,
64 cy: f32,
65 radius: u32,
66 method: OrientationMethod,
67) -> AxisFitResult {
68 let [ox, oy] = view.origin();
71 let cx = cx + ox as f32;
72 let cy = cy + oy as f32;
73 let view = ImageView {
74 origin: [0, 0],
75 ..view
76 };
77 let ring = ring_offsets(radius);
78 let ring_phi = ring_angles(ring);
79 let samples = sample_ring(view.data, view.width, view.height, cx, cy, ring);
80 match method {
81 OrientationMethod::RingFit => {
82 ring_fit_for_image(view, cx, cy, radius, &samples, &ring_phi).into()
83 }
84 OrientationMethod::DiskFit => {
85 disk_sector::fit(view, cx, cy, radius, &samples, &ring_phi).into()
86 }
87 }
88}
89
90#[cfg(test)]
97pub(crate) fn fit_axes_from_samples(
98 samples: &[f32; 16],
99 ring_phi: &[f32; 16],
100 method: OrientationMethod,
101) -> AxisFitResult {
102 match method {
103 OrientationMethod::RingFit => ring_fit::fit_ring(samples, ring_phi).into(),
104 OrientationMethod::DiskFit => ring_fit::fit_ring(samples, ring_phi).into(),
106 }
107}
108
109#[cfg(test)]
114mod tests {
115 use super::{fit_axes_at_point, fit_axes_from_samples};
116 use crate::detect::chess::ring::ring_offsets;
117 use crate::imageview::ImageView;
118 use crate::OrientationMethod;
119 use core::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI};
120
121 const TANH_BETA: f32 = 4.0;
122
123 fn eval_model(phi: f32, mu: f32, amp: f32, theta1: f32, theta2: f32) -> f32 {
124 let h1 = (TANH_BETA * (phi - theta1).sin()).tanh();
125 let h2 = (TANH_BETA * (phi - theta2).sin()).tanh();
126 mu + amp * h1 * h2
127 }
128
129 fn ring_angles(ring: &[(i32, i32); 16]) -> [f32; 16] {
130 let mut out = [0.0f32; 16];
131 for (i, &(dx, dy)) in ring.iter().enumerate() {
132 out[i] = (dy as f32).atan2(dx as f32);
133 }
134 out
135 }
136
137 fn synthetic_ring(mu: f32, amp: f32, theta1: f32, theta2: f32) -> ([f32; 16], [f32; 16]) {
138 let ring = ring_offsets(5);
139 let phi = ring_angles(ring);
140 let mut samples = [0.0f32; 16];
141 for i in 0..16 {
142 samples[i] = eval_model(phi[i], mu, amp, theta1, theta2);
143 }
144 (samples, phi)
145 }
146
147 fn angle_err_mod_pi(a: f32, b: f32) -> f32 {
149 let d = (a - b).rem_euclid(PI);
150 d.min(PI - d)
151 }
152
153 fn axis_pair_err(fit_t1: f32, fit_t2: f32, gt_t1: f32, gt_t2: f32) -> f32 {
156 let opt_a = angle_err_mod_pi(fit_t1, gt_t1).max(angle_err_mod_pi(fit_t2, gt_t2));
157 let opt_b = angle_err_mod_pi(fit_t1, gt_t2).max(angle_err_mod_pi(fit_t2, gt_t1));
158 opt_a.min(opt_b)
159 }
160
161 const PARITY_TOL: f32 = 5e-3;
163
164 #[test]
169 fn ring_fit_axis_aligned_corner() {
170 let (samples, phi) = synthetic_ring(128.0, 80.0, PI * 0.25, PI * 0.75);
171 let fit = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
172
173 assert!(fit.theta1 >= 0.0 && fit.theta1 < PI);
174 assert!(fit.theta2 > fit.theta1 && fit.theta2 < fit.theta1 + PI);
175
176 let err1 = ((fit.theta1 - PI * 0.25).abs()).min((fit.theta1 - (PI * 0.25 + PI)).abs());
177 let err2 = ((fit.theta2 - PI * 0.75).abs()).min((fit.theta2 - (PI * 0.75 + PI)).abs());
178 assert!(err1 < 1e-2, "theta1 err {err1}");
179 assert!(err2 < 1e-2, "theta2 err {err2}");
180 assert!(fit.amp > 60.0, "amp {}", fit.amp);
181 assert!(fit.rms < 1e-2, "rms {}", fit.rms);
182 assert!(fit.sigma_theta1 < 5e-2);
183 assert!(fit.sigma_theta2 < 5e-2);
184 }
185
186 #[test]
187 fn ring_fit_non_orthogonal_corner() {
188 let t1 = 30f32.to_radians();
189 let t2 = 100f32.to_radians();
190 let (samples, phi) = synthetic_ring(120.0, 60.0, t1, t2);
191 let fit = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
192
193 let fold = |x: f32, target: f32| -> f32 {
194 let d = (x - target).abs();
195 d.min(PI - d).min((x - target - PI).abs())
196 };
197 let err1 = fold(fit.theta1, t1);
198 let err2 = fold(fit.theta2, t2);
199 assert!(err1 < 0.05, "theta1 {} vs {t1}, err {err1}", fit.theta1);
200 assert!(err2 < 0.05, "theta2 {} vs {t2}, err {err2}", fit.theta2);
201 }
202
203 #[test]
204 fn ring_fit_robust_seed_recovers_extreme_skew_trace() {
205 let ring = ring_offsets(5);
212 let phi = ring_angles(ring);
213 let samples = [
214 0.485009_f32,
215 0.230703,
216 0.0,
217 0.203177,
218 11.4078,
219 139.206,
220 230.952,
221 128.866,
222 14.6169,
223 0.32334,
224 1.58913,
225 0.713731,
226 10.256,
227 129.307,
228 107.935,
229 8.76894,
230 ];
231 let gt_t1 = 20.765984_f32.to_radians();
232 let gt_t2 = 57.899868_f32.to_radians();
233
234 let fit = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
235
236 let err = axis_pair_err(fit.theta1, fit.theta2, gt_t1, gt_t2);
237 assert!(
238 err < 8.0_f32.to_radians(),
239 "axis err {} deg for fit {:?}",
240 err.to_degrees(),
241 fit
242 );
243 assert!(fit.rms < 35.0, "rms {}", fit.rms);
244 }
245
246 #[test]
247 fn ring_fit_polarity_swap_on_sign_flip() {
248 let t1 = 0.3f32;
249 let t2 = 0.3 + FRAC_PI_2;
250 let (s_pos, phi) = synthetic_ring(128.0, 80.0, t1, t2);
251 let (s_neg, _) = synthetic_ring(128.0, -80.0, t1, t2);
252
253 let fit_pos = fit_axes_from_samples(&s_pos, &phi, OrientationMethod::RingFit);
254 let fit_neg = fit_axes_from_samples(&s_neg, &phi, OrientationMethod::RingFit);
255
256 let mod_pi = |x: f32| x.rem_euclid(PI);
257 let pos_lines = [mod_pi(fit_pos.theta1), mod_pi(fit_pos.theta2)];
258 let neg_lines = [mod_pi(fit_neg.theta1), mod_pi(fit_neg.theta2)];
259 let pair_err = |a: &[f32; 2], b: &[f32; 2]| -> f32 {
260 let d = |x: f32, y: f32| {
261 let e = (x - y).abs();
262 e.min(PI - e)
263 };
264 let opt1 = d(a[0], b[0]).max(d(a[1], b[1]));
265 let opt2 = d(a[0], b[1]).max(d(a[1], b[0]));
266 opt1.min(opt2)
267 };
268 assert!(
269 pair_err(&pos_lines, &neg_lines) < 0.02,
270 "lines mismatch: pos={pos_lines:?}, neg={neg_lines:?}"
271 );
272 assert!(fit_pos.amp > 0.0 && fit_neg.amp > 0.0);
273 }
274
275 #[test]
276 fn ring_fit_flat_ring_returns_degenerate() {
277 let ring = ring_offsets(5);
278 let phi = ring_angles(ring);
279 let samples = [77.0f32; 16];
280 let fit = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
281 assert_eq!(fit.amp, 0.0);
282 assert!(fit.sigma_theta1 >= PI - 1e-3);
283 assert!(fit.sigma_theta2 >= PI - 1e-3);
284 assert!(fit.theta1 >= 0.0 && fit.theta1 < PI);
285 assert!(fit.theta2 > fit.theta1 && fit.theta2 < fit.theta1 + PI);
286 }
287
288 #[test]
289 fn ring_fit_canonicalization_invariants() {
290 let cases: &[(f32, f32, f32)] = &[
291 (10.0, FRAC_PI_2, 0.1),
292 (10.0, FRAC_PI_2, 0.1 + 3.0 * PI),
293 (-10.0, FRAC_PI_2, 0.1),
294 (10.0, FRAC_PI_2 + PI, 0.1),
295 (5.0, -FRAC_PI_4, 0.1),
296 ];
297 for &(amp, skew, offset) in cases {
298 let (samples, phi) = synthetic_ring(128.0, amp, offset, offset + skew);
299 let fit = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
300 assert!(
301 (0.0..PI + 1e-6).contains(&fit.theta1),
302 "theta1 {} out of [0, π)",
303 fit.theta1
304 );
305 assert!(
306 fit.theta2 > fit.theta1 && fit.theta2 < fit.theta1 + PI + 1e-6,
307 "theta2 {} not in (theta1={}, theta1+π)",
308 fit.theta2,
309 fit.theta1
310 );
311 assert!(fit.amp >= 0.0, "amp {} negative", fit.amp);
312 }
313 }
314
315 #[test]
316 fn ring_fit_image_input_matches_sample_input() {
317 let mu = 128.0f32;
318 let amp = 80.0f32;
319 let t1 = PI * 0.25;
320 let t2 = PI * 0.75;
321 let (samples, phi) = synthetic_ring(mu, amp, t1, t2);
322
323 let w = 41usize;
324 let h = 41usize;
325 let cx = 20i32;
326 let cy = 20i32;
327 let mut img = vec![0u8; w * h];
328 let ring = ring_offsets(5);
329 for (i, &(dx, dy)) in ring.iter().enumerate() {
330 let px = (cx + dx) as usize;
331 let py = (cy + dy) as usize;
332 let q = samples[i].round().clamp(0.0, 255.0) as u8;
333 img[py * w + px] = q;
334 }
335
336 let from_samples = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
337 let view = ImageView::from_u8_slice(w, h, &img).expect("view dims match buffer");
338 let from_image =
339 fit_axes_at_point(view, cx as f32, cy as f32, 5, OrientationMethod::RingFit);
340
341 assert!((from_samples.theta1 - from_image.theta1).abs() < 1e-2);
342 assert!((from_samples.theta2 - from_image.theta2).abs() < 1e-2);
343 assert!(from_image.amp > 0.0);
344 }
345
346 #[test]
347 fn ring_fit_radius10_uses_radius5_safety_when_outer_ring_is_suspicious() {
348 let w = 41usize;
349 let h = 41usize;
350 let cx = 20i32;
351 let cy = 20i32;
352 let mut img = vec![128u8; w * h];
353
354 let outer_ring = ring_offsets(10);
359 let outer_phi = ring_angles(outer_ring);
360 for (i, &(dx, dy)) in outer_ring.iter().enumerate() {
361 let q = eval_model(
362 outer_phi[i],
363 128.0,
364 80.0,
365 0.0_f32.to_radians(),
366 25.0_f32.to_radians(),
367 )
368 .round()
369 .clamp(0.0, 255.0) as u8;
370 img[(cy + dy) as usize * w + (cx + dx) as usize] = q;
371 }
372
373 let inner_t1 = 25.0_f32.to_radians();
374 let inner_t2 = 115.0_f32.to_radians();
375 let inner_ring = ring_offsets(5);
376 let inner_phi = ring_angles(inner_ring);
377 for (i, &(dx, dy)) in inner_ring.iter().enumerate() {
378 let q = eval_model(inner_phi[i], 128.0, 80.0, inner_t1, inner_t2)
379 .round()
380 .clamp(0.0, 255.0) as u8;
381 img[(cy + dy) as usize * w + (cx + dx) as usize] = q;
382 }
383
384 let view = ImageView::from_u8_slice(w, h, &img).expect("view dims match buffer");
385 let fit = fit_axes_at_point(view, cx as f32, cy as f32, 10, OrientationMethod::RingFit);
386 let err = axis_pair_err(fit.theta1, fit.theta2, inner_t1, inner_t2);
387 assert!(
388 err < 2.0_f32.to_radians(),
389 "radius-10 safety err {} deg for fit {:?}",
390 err.to_degrees(),
391 fit
392 );
393 }
394
395 #[test]
400 fn parity_clean_orthogonal_corner() {
401 let (mu, amp) = (128.0_f32, 80.0_f32);
402 let t1 = PI * 0.25;
403 let t2 = PI * 0.75;
404 let (samples, phi) = synthetic_ring(mu, amp, t1, t2);
405 let fit = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
406
407 let err = axis_pair_err(fit.theta1, fit.theta2, t1, t2);
408 assert!(err < PARITY_TOL, "axis err {err}");
409 assert!(
410 (fit.amp - amp).abs() < PARITY_TOL,
411 "amp {} expected {}",
412 fit.amp,
413 amp
414 );
415 assert!(fit.rms < PARITY_TOL, "rms {}", fit.rms);
416 assert!(fit.sigma_theta1 < PARITY_TOL);
417 assert!(fit.sigma_theta2 < PARITY_TOL);
418 }
419
420 #[test]
421 fn parity_projective_skew_30_100() {
422 let t1 = 30f32.to_radians();
423 let t2 = 100f32.to_radians();
424 let (mu, amp) = (120.0_f32, 60.0_f32);
425 let (samples, phi) = synthetic_ring(mu, amp, t1, t2);
426 let fit = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
427
428 let err = axis_pair_err(fit.theta1, fit.theta2, t1, t2);
429 assert!(err < PARITY_TOL, "axis err {err}");
430 assert!(
431 (fit.amp - amp).abs() < PARITY_TOL,
432 "amp {} expected {}",
433 fit.amp,
434 amp
435 );
436 assert!(fit.rms < PARITY_TOL, "rms {}", fit.rms);
437 assert!(fit.sigma_theta1 < PARITY_TOL);
438 assert!(fit.sigma_theta2 < PARITY_TOL);
439 }
440
441 #[test]
442 fn parity_noisy_sharp_corner() {
443 let (mu, amp) = (128.0_f32, 80.0_f32);
444 let t1 = PI * 0.25;
445 let t2 = PI * 0.75;
446 let (mut samples, phi) = synthetic_ring(mu, amp, t1, t2);
447 let noise = [
448 1.5_f32, -1.7, 2.1, -0.9, 0.4, -1.1, 1.8, -2.0, 0.7, 1.2, -0.8, -1.6, 0.3, 1.9, -0.5,
449 -1.3,
450 ];
451 for i in 0..16 {
452 samples[i] += noise[i];
453 }
454
455 let fit = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
456
457 let err = axis_pair_err(fit.theta1, fit.theta2, t1, t2);
458 assert!(err < PARITY_TOL, "axis err {err}");
459 assert!(
460 (fit.amp - amp).abs() < 2.0,
461 "amp {} expected {} ± 2",
462 fit.amp,
463 amp
464 );
465 assert!(fit.sigma_theta1.is_finite() && fit.sigma_theta1 < 0.05);
466 assert!(fit.sigma_theta2.is_finite() && fit.sigma_theta2 < 0.05);
467 assert!(fit.rms.is_finite() && fit.rms > 0.0);
468 }
469
470 #[test]
471 fn parity_low_contrast_corner() {
472 let (mu, amp) = (96.0_f32, 5.0_f32);
473 let t1 = 0.4_f32;
474 let t2 = 0.4 + FRAC_PI_2;
475 let (samples, phi) = synthetic_ring(mu, amp, t1, t2);
476 let fit = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
477
478 let err = axis_pair_err(fit.theta1, fit.theta2, t1, t2);
479 assert!(err < PARITY_TOL, "axis err {err}");
480 assert!(
481 (fit.amp - amp).abs() < PARITY_TOL,
482 "amp {} expected {}",
483 fit.amp,
484 amp
485 );
486 assert!(fit.rms < PARITY_TOL, "rms {}", fit.rms);
487 assert!(fit.sigma_theta1 < PARITY_TOL);
488 assert!(fit.sigma_theta2 < PARITY_TOL);
489 }
490
491 #[test]
492 fn parity_degenerate_flat_input() {
493 let ring = ring_offsets(5);
494 let phi = ring_angles(ring);
495 let samples = [77.0_f32; 16];
496 let fit = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
497
498 assert!(fit.amp.abs() < PARITY_TOL, "amp {} should be 0", fit.amp);
499 assert!(
500 (fit.theta1 - 0.0).abs() < PARITY_TOL,
501 "theta1 {} should be 0",
502 fit.theta1
503 );
504 assert!(
505 (fit.theta2 - FRAC_PI_2).abs() < PARITY_TOL,
506 "theta2 {} should be π/2",
507 fit.theta2
508 );
509 assert!(
510 (fit.sigma_theta1 - PI).abs() < PARITY_TOL,
511 "sigma1 {} should be π",
512 fit.sigma_theta1
513 );
514 assert!(
515 (fit.sigma_theta2 - PI).abs() < PARITY_TOL,
516 "sigma2 {} should be π",
517 fit.sigma_theta2
518 );
519 assert!(fit.rms < PARITY_TOL, "rms {} should be ~0", fit.rms);
520 }
521
522 #[test]
527 fn lut_applies_to_sigmas_only() {
528 let (samples, phi) = synthetic_ring(128.0, 80.0, PI * 0.25, PI * 0.75);
532 let fit = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
533
534 assert!(
536 fit.rms < 0.05,
537 "baseline rms {} not in clean regime",
538 fit.rms
539 );
540 assert!(fit.sigma_theta1 > 0.0);
543 assert!(fit.sigma_theta2 > 0.0);
544 assert!(fit.sigma_theta1 < 0.1);
546 assert!(fit.sigma_theta2 < 0.1);
547 }
548
549 #[test]
550 fn degenerate_fit_remains_degenerate() {
551 let ring = ring_offsets(5);
552 let phi = ring_angles(ring);
553 let samples = [77.0f32; 16];
554 let fit = fit_axes_from_samples(&samples, &phi, OrientationMethod::RingFit);
555
556 assert!((fit.sigma_theta1 - PI).abs() < 1e-6);
558 assert!((fit.sigma_theta2 - PI).abs() < 1e-6);
559 }
560}