Skip to main content

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}