chess_corners_core/orientation/mod.rs
1//! Two-axis projective orientation fit at a chessboard corner.
2//!
3//! The detector lifts each subpixel corner to an [`AxisFitResult`] that
4//! carries the two local grid-axis directions, their per-axis 1σ angular
5//! uncertainties, and a residual RMS. The actual fit is pluggable via
6//! [`OrientationMethod`].
7//!
8//! Two public entry points are provided:
9//! - [`fit_axes_at_point`] (image input) — samples the ring at
10//! `(cx, cy)` and dispatches to the chosen method.
11//! - [`fit_axes_from_samples`] (presampled input) — accepts the 16
12//! ring samples directly; convenient for unit tests and for the
13//! orientation benchmark when ring sampling is decoupled.
14//!
15//! New algorithm variants plug into the [`OrientationMethod`] enum without
16//! breaking SemVer thanks to the `#[non_exhaustive]` attribute.
17
18mod api;
19pub mod descriptor;
20mod disk_sector;
21mod ring_fit;
22
23use crate::detect::chess::ring::ring_offsets;
24use descriptor::{ring_angles, sample_ring};
25
26pub use api::{fit_axes_at_point, fit_axes_from_samples, AxisFitResult};
27pub use descriptor::describe_corners;
28
29/// Method used to fit the two grid axes at a detected corner.
30///
31/// The default [`Self::RingFit`] covers the vast majority of use cases. Use
32/// [`Self::DiskFit`] when you need improved axis accuracy on corners with
33/// strong projective skew (axis separation far from 90°).
34///
35/// All variants emit `axes[0]` and `axes[1]` under the same canonical
36/// convention documented on [`crate::detect::CornerDescriptor`]:
37/// `axes[0].angle ∈ [0, π)`, `axes[1].angle ∈ (axes[0].angle,
38/// axes[0].angle + π)`, and the CCW arc `(axes[0], axes[1])` is a *dark*
39/// sector of the corner. Downstream consumers can therefore compare
40/// `axes[0]` slot parity between corners regardless of which method
41/// produced them.
42//
43// `f32` payloads disqualify the enum from `Eq` derive; we keep
44// `PartialEq` only and rely on the manual variants for matching.
45#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
46#[non_exhaustive]
47#[serde(rename_all = "snake_case")]
48pub enum OrientationMethod {
49 /// Fit the parametric two-axis chessboard intensity model
50 /// `I(φ) = μ + A·tanh(β·sin(φ−θ₁))·tanh(β·sin(φ−θ₂))` to 16
51 /// ring samples via Gauss-Newton seeded from the 2nd-harmonic
52 /// orientation. Suspicious local minima are retried from a small
53 /// deterministic seed grid over the same 16 samples. When a
54 /// radius-10 descriptor ring is requested and still looks
55 /// suspicious, the canonical radius-5 ring is sampled as a cheap
56 /// safety check and used when it produces a valid contrast/residual
57 /// fit. Per-axis 1σ uncertainties are calibrated by a
58 /// piecewise-linear lookup table keyed on the contrast-relative
59 /// residual, bringing reported sigmas closer to the empirical RMSE.
60 ///
61 /// This is the **default** method. Suitable for the full range of
62 /// standard chessboard images.
63 #[default]
64 RingFit,
65 /// Full-disk crossing-line estimator. Samples all image pixels in a
66 /// disk around the corner center and fits two possibly non-orthogonal
67 /// axes from the resulting gradient field. When local evidence is
68 /// weak or the ring fit already indicates a clean orthogonal corner,
69 /// falls back to [`Self::RingFit`] output transparently.
70 ///
71 /// Use this when standard chessboards are imaged under strong
72 /// projective warp (axis separation far from 90°). Higher per-corner
73 /// cost than `RingFit` (~5–10× on typical hardware), but the lazy-gate
74 /// short-circuits on clean inputs so the average cost is much lower.
75 ///
76 /// Output axes use the same canonical convention as [`Self::RingFit`]
77 /// — see the type-level doc comment above and
78 /// [`crate::detect::CornerDescriptor`].
79 DiskFit,
80}
81
82// Re-exported to crate-internal callers (e.g. the descriptor module) so
83// they can still use `TwoAxisFit` after the body moved.
84pub(crate) use ring_fit::TwoAxisFit;
85
86/// Crate-internal entry into the ring fit, used by the descriptor module.
87#[cfg(test)]
88#[inline]
89pub(crate) fn ring_fit_for_descriptor(samples: &[f32; 16], ring_phi: &[f32; 16]) -> TwoAxisFit {
90 ring_fit::fit_ring(samples, ring_phi)
91}
92
93/// Image-side RingFit entry point. Radius-10 descriptor rings are useful
94/// for broad, blurred detections, but on very small extreme-skew
95/// corners that outer trace can cross the wrong sectors. If the outer
96/// fit already looks suspicious, retry the canonical radius-5 trace and
97/// use it as a cheap safety fallback.
98#[allow(clippy::too_many_arguments)]
99pub(crate) fn ring_fit_for_image(
100 img: &[u8],
101 w: usize,
102 h: usize,
103 cx: f32,
104 cy: f32,
105 radius: u32,
106 samples: &[f32; 16],
107 ring_phi: &[f32; 16],
108) -> TwoAxisFit {
109 let outer = ring_fit::fit_ring(samples, ring_phi);
110 if radius != 10 || !ring_fit::fit_is_suspicious(&outer) {
111 return outer;
112 }
113
114 let inner_ring = ring_offsets(5);
115 let inner_phi = ring_angles(inner_ring);
116 let inner_samples = sample_ring(img, w, h, cx, cy, inner_ring);
117 let inner = ring_fit::fit_ring(&inner_samples, &inner_phi);
118
119 if inner.amp >= 1.0 && inner.rms.is_finite() {
120 inner
121 } else {
122 outer
123 }
124}
125
126/// Crate-internal full-disk estimator entry, used by the descriptor's
127/// `OrientationMethod` dispatcher.
128#[allow(clippy::too_many_arguments)]
129#[inline]
130pub(crate) fn disk_fit_for_descriptor(
131 img: &[u8],
132 w: usize,
133 h: usize,
134 cx: f32,
135 cy: f32,
136 radius: u32,
137 samples: &[f32; 16],
138 ring_phi: &[f32; 16],
139) -> TwoAxisFit {
140 disk_sector::fit(img, w, h, cx, cy, radius, samples, ring_phi)
141}
142
143/// Crate-internal hook that exposes `canonicalize` only to the
144/// descriptor module's own test suite.
145#[cfg(test)]
146#[inline]
147pub(crate) fn ring_fit_canonicalize_for_test(
148 theta1: f32,
149 theta2: f32,
150 amp: f32,
151) -> (f32, f32, f32) {
152 ring_fit::canonicalize(theta1, theta2, amp)
153}