Skip to main content

chess_corners_core/orientation/
api.rs

1//! Public API for sampling and fitting axes at a single point.
2//!
3//! [`fit_axes_at_point`] is the public entry point: it samples the
4//! 16-point ChESS ring around an image point and dispatches to the
5//! chosen [`OrientationMethod`]. This is the workhorse used by the
6//! descriptor pipeline and by the orientation benchmark.
7//!
8//! A crate-private `fit_axes_from_samples` helper runs the same
9//! dispatch directly on pre-sampled ring values, for use by this
10//! module's own unit tests.
11
12use 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/// Result of a two-axis orientation fit at a single corner.
18///
19/// All [`OrientationMethod`] variants populate the same fields.
20#[derive(Clone, Copy, Debug)]
21#[non_exhaustive]
22pub struct AxisFitResult {
23    /// Bright/dark amplitude `|A|` (≥ 0) recovered by the fit. Units
24    /// are gray levels.
25    pub amp: f32,
26    /// First axis direction, radians in `[0, π)` (line-direction
27    /// representative — see [`crate::CornerDescriptor`]).
28    pub theta1: f32,
29    /// Second axis direction, radians in `(theta1, theta1 + π) ⊂ [0, 2π)`.
30    pub theta2: f32,
31    /// 1σ angular uncertainty for `theta1` (radians).
32    pub sigma_theta1: f32,
33    /// 1σ angular uncertainty for `theta2` (radians).
34    pub sigma_theta2: f32,
35    /// RMS fit residual of the two-axis intensity model (gray levels).
36    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
53/// Sample the 16-point ChESS ring at `(cx, cy)` with `radius` and run
54/// the chosen orientation method.
55///
56/// `(cx, cy)` are in the view's external frame — the view's origin is
57/// applied before sampling, matching [`ImageView::sample_bilinear`].
58///
59/// Useful for orientation estimates at externally-chosen points without
60/// running detection.
61pub fn fit_axes_at_point(
62    view: ImageView<'_>,
63    cx: f32,
64    cy: f32,
65    radius: u32,
66    method: OrientationMethod,
67) -> AxisFitResult {
68    // Fold the origin into the center and continue on a zero-origin
69    // view: the samplers below index the backing slice directly.
70    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/// Run the chosen orientation method on pre-sampled ring values.
91///
92/// Use this when you already have the 16 ring samples in hand and only
93/// need the fit. Image-dependent variants will fall back to the
94/// ring-only [`OrientationMethod::RingFit`] result when invoked through
95/// this helper.
96#[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        // DiskFit needs image data; fall back to RingFit when not available.
105        OrientationMethod::DiskFit => ring_fit::fit_ring(samples, ring_phi).into(),
106    }
107}
108
109// White-box ring-fit parity / accuracy tests. These build synthetic ring
110// samples from the crate-private ring-offset geometry, so they live in
111// the crate rather than in `tests/`. Cases that exercise only the public
112// API stay in `tests/orientation_ring_fit.rs`.
113#[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    /// Smallest signed angular distance between `a` and `b` modulo π.
148    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    /// Max angular error between two unordered axis pairs (accounts for
154    /// the canonicaliser potentially swapping which axis lands in theta1).
155    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    /// Per-axis tolerance for parity assertions (radians / gray levels).
162    const PARITY_TOL: f32 = 5e-3;
163
164    // -----------------------------------------------------------------------
165    // Parity / accuracy tests
166    // -----------------------------------------------------------------------
167
168    #[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        // Deterministic synthetic fixture from orientation_bench:
206        // bench_default axis_skew=30°, seed=1, cell sample 8, sampled at the
207        // detected image-frame center (21.815319, 20.875401). The legacy
208        // 2nd-harmonic-only seed converged to the wrong basin by ~62° on
209        // this trace even though the 16 bilinear samples contain the correct
210        // two-axis model.
211        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        // Deliberately make the radius-10 trace a high-contrast, nearly
355        // parallel-axis pattern. That outer trace is a valid local model but
356        // suspicious as a chess-grid orientation. The canonical radius-5
357        // trace carries the intended local axes and should be used instead.
358        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    // -----------------------------------------------------------------------
396    // Perf-parity assertions (tight 5 mrad tolerance)
397    // -----------------------------------------------------------------------
398
399    #[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    // -----------------------------------------------------------------------
523    // σ-LUT specific tests
524    // -----------------------------------------------------------------------
525
526    #[test]
527    fn lut_applies_to_sigmas_only() {
528        // Clean fit → fit_rms ≈ 0 → LUT multiplier 1.25 (first breakpoint).
529        // Angles, amp, rms are not exposed before the LUT, so we verify the
530        // clean-fit regime and that finite sigmas were scaled.
531        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        // On a clean noiseless input the GN solve converges tightly.
535        assert!(
536            fit.rms < 0.05,
537            "baseline rms {} not in clean regime",
538            fit.rms
539        );
540        // The LUT must have inflated the sigmas above the raw GN value
541        // (which would be extremely small on a perfect input).
542        assert!(fit.sigma_theta1 > 0.0);
543        assert!(fit.sigma_theta2 > 0.0);
544        // But they must still be well below π.
545        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        // Flat ring → degenerate sentinel → σ pinned at π even after LUT.
557        assert!((fit.sigma_theta1 - PI).abs() < 1e-6);
558        assert!((fit.sigma_theta2 - PI).abs() < 1e-6);
559    }
560}