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#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
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 detector ring is used and the fit 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°). It has higher
73    /// per-corner cost than `RingFit`; the lazy gate keeps the full disk
74    /// fit off clean orthogonal corners.
75    ///
76    /// Output axes use the same canonical convention as [`Self::RingFit`]
77    /// — see the type-level doc comment above and
78    /// [`crate::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 detector rings sample
94/// farther from the candidate center. On very small extreme-skew corners
95/// that outer trace can cross the wrong sectors. If the outer fit already
96/// looks suspicious, retry the canonical radius-5 trace and use it as a
97/// cheap safety fallback.
98pub(crate) fn ring_fit_for_image(
99    view: ImageView<'_>,
100    cx: f32,
101    cy: f32,
102    radius: u32,
103    samples: &[f32; 16],
104    ring_phi: &[f32; 16],
105) -> TwoAxisFit {
106    let outer = ring_fit::fit_ring(samples, ring_phi);
107    if radius != 10 || !ring_fit::fit_is_suspicious(&outer) {
108        return outer;
109    }
110
111    let inner_ring = ring_offsets(5);
112    let inner_phi = ring_angles(inner_ring);
113    let inner_samples = sample_ring(view.data, view.width, view.height, cx, cy, inner_ring);
114    let inner = ring_fit::fit_ring(&inner_samples, &inner_phi);
115
116    if inner.amp >= 1.0 && inner.rms.is_finite() {
117        inner
118    } else {
119        outer
120    }
121}
122
123/// Crate-internal full-disk estimator entry, used by the descriptor's
124/// `OrientationMethod` dispatcher.
125#[inline]
126pub(crate) fn disk_fit_for_descriptor(
127    view: ImageView<'_>,
128    cx: f32,
129    cy: f32,
130    radius: u32,
131    samples: &[f32; 16],
132    ring_phi: &[f32; 16],
133) -> TwoAxisFit {
134    disk_sector::fit(view, cx, cy, radius, samples, ring_phi)
135}
136
137/// Crate-internal hook that exposes `canonicalize` only to the
138/// descriptor module's own test suite.
139#[cfg(test)]
140#[inline]
141pub(crate) fn ring_fit_canonicalize_for_test(
142    theta1: f32,
143    theta2: f32,
144    amp: f32,
145) -> (f32, f32, f32) {
146    ring_fit::canonicalize(theta1, theta2, amp)
147}