Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Part III: The ChESS response detector

ChESS (Chess-board Extraction by Subtraction and Summation, Bennett & Lasenby, 2014; CVIU 118:197–210) is a ring-based corner detector specialized for chessboard X-junctions. Given an 8-bit grayscale image, it produces a dense response map; positive values mark chessboard-like corners, while edges, blobs, and flat regions should have lower response on the ideal checkerboard model. The benchmark chapter reports single-digit millisecond timings for the measured camera-sized test images with the simd or rayon features enabled.

For a self-contained overview of the algorithm, see the chess-corners atlas page on vitavision.dev.

This part covers the ChESS pipeline end-to-end: the ring geometry, the response formula, the dense response computation over the image, the detection pipeline (threshold + NMS + cluster filter + subpixel refinement), and the corner descriptor that both detectors (ChESS here and Radon in Part IV) feed into.

The core ChESS code lives under crates/chess-corners-core/src/detect/chess/ and the descriptor code under crates/chess-corners-core/src/orientation/. Feature flags (std, rayon, simd, tracing) only affect performance and observability, not the numerical output.


3.1 Rings and sampling geometry

The ChESS response is built around a fixed 16‑sample ring at a given radius. The core crate encodes these rings in crates/chess-corners-core/src/ring.rs.

3.1.1 Canonical rings

The main types are:

  • RingOffsets – an enum representing the supported ring radii (R5 and R10).
  • RING5 / RING10 – the actual offset tables for radius 5 and 10.
  • ring_offsets(radius: u32) – helper returning the offset table for a given radius (anything other than 10 maps to 5).

The 16 offsets are ordered clockwise starting at the top, and are derived from the FAST‑16 pattern:

  • RING5 is the canonical r = 5 ring used in the original ChESS paper.
  • RING10 is a scaled variant (r = 10) with the same angles, which improves robustness under heavier blur at the cost of a larger footprint and border margin.

The exact offsets are stored as integer (dx, dy) pairs, so sampling around a pixel (x, y) means accessing (x + dx, y + dy) for each ring point.

3.1.2 From parameters to rings

ChessParams (re-exported from the chess-corners-core crate root) controls which ring to use:

  • use_radius10 – when true, ring_radius() returns 10 instead of 5.

Convenience methods:

  • ring_radius() returns the numeric radius.
  • ring() returns the RingOffsets value, which can be turned into an offset table via offsets().

Both the response computation and descriptor estimation use ring(). Descriptors always sample at the detector ring radius.


3.2 Dense response computation

The main entry point in response.rs is:

#![allow(unused)]
fn main() {
pub fn chess_response_u8(img: &[u8], w: usize, h: usize, params: &ChessParams) -> ResponseMap
}

This function computes the ChESS response at each pixel center whose full ring fits entirely inside the image. Pixels that cannot support a full ring (near the border) get response zero.

3.2.1 ChESS formula

For each pixel center c, we gather 16 ring samples s[0..16) using the offsets described in §3.1, and a small 5‑pixel cross at the center:

  • center c,
  • north/south/east/west neighbors.

From these values we compute:

  • SR – a “square” term that compares opposite quadrants on the ring:

    \[ SR = \sum_{k=0}^{3} \left| (s_k + s_{k+8}) - (s_{k+4} + s_{k+12}) \right| \]

  • DR – a “difference” term encouraging edge‑like structure:

    \[ DR = \sum_{k=0}^{7} \left| s_k - s_{k+8} \right| \]

  • μₙ – the mean of all 16 ring samples.

  • μₗ – the local mean of the 5‑pixel cross.

The final ChESS response is:

\[ R = SR - DR - 16 \left| \mu_n - \mu_l \right| \]

Intuitively:

  • SR is large when opposite quadrants have contrasting intensities (as in an X‑junction).
  • DR is large for simple edges, and subtracting it de‑emphasizes edge‑like structures.
  • |μₙ - μₗ| penalizes isolated blobs or local illumination changes.

High positive values of R correspond to chessboard‑like corners.

3.2.2 Implementation paths and borders

chess_response_u8 is implemented in a few interchangeable ways:

  • Scalar sequential path (compute_response_sequential / compute_row_range_scalar) – a straightforward nested loop over rows and columns.
  • Parallel path (compute_response_parallel) – when the rayon feature is enabled, the outer loop is split across threads using par_chunks_mut over rows.
  • SIMD path (compute_row_range_simd) – when the simd feature is enabled, the inner loop vectorizes over LANES pixels at a time, using portable SIMD to gather ring samples and accumulate SR, DR, and μₙ in vector registers.

Regardless of the path, the function:

  • respects a border margin equal to the ring radius so that all ring accesses are in bounds,
  • writes responses into a ResponseMap { w, h, data } in row‑major order,
  • keeps the scalar, parallel, and SIMD variants within the documented deterministic-output contract.

3.2.3 ROI support with Roi

For multiscale refinement, we rarely need the response over the entire image. Instead we compute it inside small regions of interest around coarse corner predictions.

The Roi struct:

#![allow(unused)]
fn main() {
pub struct Roi {
    pub x0: usize,
    pub y0: usize,
    pub x1: usize,
    pub y1: usize,
}
}

describes an axis‑aligned rectangle in image coordinates. A specialized function:

#![allow(unused)]
fn main() {
pub fn chess_response_u8_patch(
    img: &[u8],
    w: usize,
    h: usize,
    params: &ChessParams,
    roi: Roi,
) -> ResponseMap
}

computes a response map only inside that ROI, treating the ROI as a small image with its own (0,0) origin. This is used in the multiscale pipeline to refine coarse corners without paying the cost of full‑frame response computation at the base resolution.


3.3 Detection pipeline

The response map is only the first half of the detector. The second half—implemented in detect.rs—turns responses into subpixel corner candidates.

3.3.1 Thresholding and NMS

The main stages are:

  1. Thresholding – we reject responses too small to be a real corner. ChESS reads the top-level threshold as an absolute floor on the raw response R: a candidate survives only if R > threshold. Why the floor is not zero is the failure mode it guards against. Every textured patch — paper grain, fabric weave, JPEG ringing — produces small positive R, so a floor near 0 accepts thousands of those weak peaks and blankets a textured image in spurious corners. The default threshold = 30 sits above that noise floor while staying well below the response of a well-formed X-junction, which is far larger. Useful values run roughly 30300, scaling with image contrast: raise it on high-contrast boards, lower it toward 30 on faint or low-light captures. (Radon reads the same field differently — as a fraction of the per-frame maximum; see Part IV §4.4.)
  2. Non‑maximum suppression (NMS) – in a window of radius nms_radius around each pixel, we keep only local maxima and suppress weaker neighbors. (nms_radius is set via DetectorConfig.detection, shared with the Radon detector.)
  3. Cluster filtering – we require that each surviving corner have at least min_cluster_size positive‑response neighbors in its NMS window. This discards isolated noisy peaks that don’t belong to a structured corner. (min_cluster_size is likewise on DetectorConfig.detection.)

The result of this stage is a set of raw corner candidates, each carrying:

  • integer‑like peak position,
  • response strength (before refinement).

3.3.2 Subpixel refinement

Each candidate is refined from its integer peak to a subpixel position by one of the refiners in Part V. The ChESS detector is selected via DetectorConfig.refiner.kind; the default is CenterOfMass, which operates directly on the response map, but any of the five refiners can be used. Refinement is a per-candidate call and adds at most a few tens of nanoseconds for the fastest options.

The internal type representing a refined candidate is descriptor::Corner:

#![allow(unused)]
fn main() {
pub struct Corner {
    /// Subpixel location in image coordinates (x, y).
    pub xy: [f32; 2],
    /// Raw ChESS response at the integer peak (before refinement).
    pub strength: f32,
}
}

The refinement step preserves the detector’s noise robustness and adds subpixel precision. Measured accuracy and throughput for each refiner are in Part VIII.


3.4 Corner descriptors

Raw corners (position + strength) are enough for many applications, but the core crate also offers a richer CornerDescriptor that fits a parametric intensity model to the 16-sample ring around each corner. The fit yields both local grid axes independently and their per-axis 1σ angular uncertainty — all in one pass.

Both the ChESS detector and the Radon detector produce CornerDescriptor values via the same describe_corners function, so everything in this section applies to both pipelines.

3.4.1 CornerDescriptor

Defined in descriptor.rs:

#![allow(unused)]
fn main() {
pub struct CornerDescriptor {
    pub x: f32,
    pub y: f32,
    pub response: f32,
    pub axes: Option<[AxisEstimate; 2]>,
}

pub struct AxisEstimate {
    pub angle: f32,
    pub sigma: f32,
}
}

Fields:

  • x, y – subpixel coordinates in full‑resolution image pixels.
  • response – raw, unnormalized ChESS response R = SR − DR − 16·MR at the detected peak. Units are 8‑bit pixel sums; a real corner clears the acceptance floor (threshold, default 30) from §3.3.1.
  • axesSome([axis0, axis1]) carries the two local grid axis directions and their 1σ uncertainties, or None when the orientation fit was disabled (§3.4.6).

The axis convention:

  • axes[0].angle ∈ [0, π) — the “line direction” of axis 1.
  • axes[1].angle ∈ (axes[0].angle, axes[0].angle + π).
  • Rotating CCW from axes[0].angle toward axes[1].angle traverses a dark sector; the second half‑turn crosses the other dark sector, and the remaining two sectors are bright.
  • The two axes are not assumed orthogonal — a projective warp (or strong lens distortion) tilts the two sectors independently.

3.4.2 Two‑axis intensity model

The ring samples s₀, …, s₁₅ at angles φ₀, …, φ₁₅ = atan2(dy, dx) are fitted to

\[ I(\varphi) = \mu + A \cdot \tanh\bigl(\beta \sin(\varphi - \theta_1)\bigr) \cdot \tanh\bigl(\beta \sin(\varphi - \theta_2)\bigr) \]

with fixed β = 4.0. The four free parameters are:

  • μ – ring‑level mean intensity,
  • A – bright/dark amplitude (signed during optimization, canonicalized to non‑negative on exit),
  • θ₁, θ₂ – the two grid axis directions.

Intuition: each tanh(β · sin(φ − θᵢ)) is a smooth approximation of sign(sin(φ − θᵢ)), i.e. +1 on one side of the axis line and −1 on the other. Their product is +1 in two antipodal “bright” sectors and −1 in the two “dark” sectors, matching a chessboard X‑junction. The fixed β reflects the effective ring‑integration blur at the sampled radius and is not a fit parameter.

3.4.3 Gauss–Newton solver

fit_two_axes runs a small Gauss–Newton iteration (up to 6 steps):

  1. Seed θ₁, θ₂ from the 2nd-harmonic Fourier coefficient of the centred ring samples, placed at the sector midpoint ± π/4. Seed A from the harmonic magnitude. The initial 90° spacing is only a seed — the two angles are independent free parameters during optimisation.
  2. At each step, evaluate the residuals and the 16×4 Jacobian of I(φᵢ) with respect to [μ, A, θ₁, θ₂] and solve the normal equations JᵀJ · Δ = Jᵀ r with partial pivoting.
  3. Clamp angular updates to ±0.5 rad per step to prevent runaway.
  4. Stop once the update falls below ‖Δθ‖ < 10⁻⁴ or the iteration cap is reached.
  5. Canonicalize (θ₁, θ₂, A) so that A ≥ 0, θ₁ ∈ [0, π) and the CCW arc from θ₁ to θ₂ spans a dark sector.

Flat or near‑flat rings (ring variance below 10⁻⁶, or 2nd‑harmonic magnitude below 10⁻⁴) short‑circuit to a degenerate fit: A = 0, θ₁ = 0, θ₂ = π/2, and σ = π on both axes so downstream consumers can detect the “no signal” case via the uncertainty field.

3.4.4 Per‑axis 1σ uncertainty

The sigma field on each AxisEstimate is the standard 1σ angular uncertainty from the linearised Gauss–Newton covariance at the optimum:

  1. The sum of squared residuals is SSR = Σᵢ (sᵢ − I(φᵢ))².
  2. The unbiased residual variance is σ̂² = SSR / (N − p) = SSR / (16 − 4) = SSR / 12.
  3. The parameter covariance is Σ = σ̂² · (JᵀJ)⁻¹, where JᵀJ is the final Gauss–Newton normal matrix.
  4. The angle sigmas are the relevant diagonal entries: σθ₁ = √Σ[2,2], σθ₂ = √Σ[3,3] (clamped to ≥ 0, capped at π).

This is the textbook Cramér–Rao‑style uncertainty for nonlinear least squares — it assumes residuals are approximately iid Gaussian and the linearisation around the optimum is adequate. It does not account for model mismatch (e.g. a corner that is not well described by a separable two‑axis tanh product), but it scales correctly with SNR: noisier rings produce proportionally larger sigma.

Practically, sigma is useful for:

  • Weighting corners in downstream grid fitting (inverse‑variance weights, or rejecting corners whose axes are too uncertain).
  • Flagging degenerate fits: sigma ≈ π means the fit did not lock onto a well‑defined grid.

3.4.5 From corners to descriptors

The function:

#![allow(unused)]
fn main() {
pub fn describe_corners(
    img: &[u8],
    w: usize,
    h: usize,
    radius: u32,
    corners: Vec<Corner>,
    method: OrientationMethod,
) -> Vec<CornerDescriptor>
}

turns raw Corner values into full descriptors by:

  1. Sampling the 16‑point ring around each corner with bilinear interpolation (sample_ring).
  2. Running fit_two_axes to obtain (μ, A, θ₁, θ₂), the Gauss–Newton covariance, and the residual RMS.
  3. Canonicalising the axes and packaging everything into a CornerDescriptor.

The pass is deterministic and purely local — there is no global optimisation or topology reasoning at this stage.

3.4.6 When to use descriptors

You get CornerDescriptor values when you use the high‑level APIs:

  • chess-corners-core users can run the response and detector stages manually and then call chess_corners_core::describe_corners.
  • chess-corners users get Vec<CornerDescriptor> directly from the Detector struct’s detect, detect_u8, or detect_view methods.

For many tasks, x, y, and response are enough. When you need more insight into local structure — grid fitting, lens‑distortion modelling, calibration with per‑corner weights, or outlier rejection before bundle adjustment — the two axes and their per‑axis 1σ uncertainty are the extra handles the orientation fit attaches to each detection.

That fit is the dominant per‑corner cost, and it is optional. If x, y, and response are all you need — the common case when a downstream stage recovers board geometry from corner positions (grid topology first, then a global homography) and never reads the per‑corner axes — disable it with DetectorConfig::without_orientation(). Skipping the fit is the single largest per‑corner saving once detection itself is settled; every descriptor’s axes field then comes back None.

The axes and their per‑axis sigma values are produced by an orientation method shared with the Radon detector. See Part VI: Orientation methods for the API surface, the two available algorithms (RingFit and DiskFit), and step-by-step descriptions of each.


Next: Part IV covers the alternative Radon response detector, which shares the descriptor pipeline above but uses a ray-based kernel instead of a ring.