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