Part V: Subpixel refiners
A corner detector (ChESS or Radon) returns integer pixel positions
plus a response value. Most downstream consumers — camera calibration,
pose estimation, homography fitting — need positions to better than a
pixel. A refiner takes one seed (x₀, y₀) plus a local view into
either the image or the response map and returns a refined
(x, y) in subpixel coordinates.
The library ships five refiners with one trait and one configuration enum, so swapping between them is a one-line change. The ChESS detector always runs one of these refiners to reach subpixel accuracy. The Radon detector handles subpixel accuracy through its own built-in 3-point Gaussian peak fit (see Part IV §4.4); it does not use a pluggable refiner.
5.1 The refiner trait
#![allow(unused)]
fn main() {
pub trait CornerRefiner {
/// Half-width of the patch the refiner reads around the seed.
fn radius(&self) -> i32;
/// Refine one seed. `ctx` exposes the image and/or response map.
fn refine(&mut self, seed_xy: [f32; 2], ctx: RefineContext<'_>) -> RefineResult;
}
}
A RefineResult carries the refined (x, y), a refiner-specific
score, and a RefineStatus:
Accepted— refined position is insideradius-sized support and below the refiner’s rejection thresholds.OutOfBounds— the patch would read past the image border.IllConditioned— the refiner’s local system was singular or too eccentric (edge rather than corner).Rejected— the refined offset exceeded the refiner’smax_offset, or a per-refiner score threshold fired.
The user-facing selector for ChESS is ChessRefiner (carrying
CenterOfMass, Forstner, SaddlePoint, and optionally Ml),
nested inside ChessConfig. Each enum variant carries its tuning
struct as a payload, so a refiner kind change cannot leave a stale
per-refiner config field behind. At runtime the facade stores an owned
Refiner enum (one allocated scratch buffer per concrete refiner) so
the same instance is reused across seeds — no per-corner allocation.
The Radon detector does not use a pluggable refiner. Its subpixel step
is the 3-point Gaussian peak fit built into the Radon pipeline,
configured via RadonConfig.peak_fit (PeakFitMode::Parabolic or
Gaussian).
5.2 CenterOfMass
Operates on the ChESS response map. Computes the response-weighted
centroid of a (2r + 1)² window around the seed:
\[ x_r = \frac{\sum_{p \in W} x_p \cdot R_p}{\sum_p R_p}, \qquad y_r = \frac{\sum_{p \in W} y_p \cdot R_p}{\sum_p R_p} \]
R_p = max(R(x, y), 0) clips negative responses so one strong
negative pixel can’t push the centroid outside the window.
| Property | Value |
|---|---|
| Input | Response map |
Default radius | 2 (5×5 window) |
| Typical cost | ~20 ns per corner |
| Strengths | Cheapest option in the benchmark; closed-form |
| Weaknesses | Centroid bias when the response is asymmetric; |
fails when radius crosses neighboring corners |
Use when throughput matters more than sub-0.1 px accuracy, or when the ChESS response is the only signal you want the refinement to see.
5.3 Förstner
Gradient-based. Solves a weighted least-squares system for the point closest (in a Mahalanobis sense) to all image-gradient lines in a local window. The structure tensor
\[ M = \sum_p w_p \begin{bmatrix} g_x^2 & g_x g_y \ g_x g_y & g_y^2 \end{bmatrix} \]
is assembled with 3×3 central-difference gradients and radial
weights w_p = 1 / (1 + 0.5·‖p − seed‖²). The refined position is
\[ u = M^{-1} \sum_p w_p , (p - \mathrm{seed}) , [g_x\ g_y]^{\mathsf T} \]
Rejections:
trace(M) < min_trace— low-gradient region.det(M) < min_det— singular structure tensor.λ_max / λ_min > max_condition_number— one dominant direction (an edge, not a corner).‖u‖ > max_offset— extrapolating beyond a trusted neighborhood.
| Property | Value |
|---|---|
| Input | Image |
Default radius | 2 (5×5 gradient window + 1 px halo) |
| Typical cost | ~60 ns per corner |
| Strengths | Principled on sharp, high-SNR images |
| Weaknesses | Relies on sharp gradients — blur flattens M |
Good pick for clean calibration frames where image edges are sharp. In the synthetic blur sweep in Part VIII, Gaussian blur flattens the gradient magnitudes and this refiner’s error increases with σ.
Reference: Förstner & Gülch, 1987, “A fast operator for detection and precise location of distinct points, corners and centres of circular features.”
5.4 SaddlePoint
Fits a 2D quadratic f(x, y) = a·x² + b·x·y + c·y² + d·x + e·y + g
to a (2r + 1)² image patch and returns the saddle point (the
critical point where ∇f = 0). The six coefficients come from a
6×6 normal-equation solve with partial pivoting.
The determinant of the Hessian is 4·a·c − b². A true X‑junction is a
saddle, so the Hessian should be indefinite (det < 0). The refiner
rejects:
|det| < min_abs_det— flat patch.det > -det_margin— the quadratic is a bowl or ridge, not a saddle.‖offset‖ > max_offset— refined point outside the patch.
| Property | Value |
|---|---|
| Input | Image |
Default radius | 2 (5×5 patch) |
| Typical cost | ~120 ns per corner |
| Strengths | No gradient required; low error in the blur sweep |
| Weaknesses | Parabolic model is approximate on sharp edges |
A reasonable default when you do not know in advance whether frames will be sharp or blurred. In Part VIII’s synthetic blur sweep it stays inside the same error band as the other non-Förstner geometric refiners.
5.5 ML (ONNX model)
A learned refiner. Feeds a 21×21 normalized grayscale patch into a
small CNN and takes [dx, dy, conf_logit] back out. The ChESS path
extracts the patch, stages a batch, runs ONNX inference via
tract-onnx, and adds [dx, dy] to each seed.
Available behind the ml-refiner feature. The default model
chess_refiner_v4.onnx is embedded in the
chess-corners-ml crate at
crates/chess-corners-ml/assets/ml/.
5.5.1 Architecture
The shipped model is CornerRefinerNet, a CoordConv CNN with a
flatten + MLP head. About 180 K parameters:
| Layer | Shape | Notes |
|---|---|---|
| Input | 1 × 21 × 21 | Grayscale patch, normalized u8/255. |
| CoordConv prepend | 3 × 21 × 21 | Two extra channels with per-pixel x, y in [-1, 1]. |
| Conv3×3, ReLU | 16 × 21 × 21 | |
| Conv3×3, ReLU | 16 × 21 × 21 | |
| Conv3×3 stride 2, ReLU | 32 × 11 × 11 | |
| Conv3×3, ReLU | 32 × 11 × 11 | |
| Conv3×3 stride 2, ReLU | 64 × 6 × 6 | |
| Flatten | 2304 | |
| Linear, ReLU | 64 | |
| Linear (no activation) | 3 | [dx, dy, conf_logit]. |
CoordConv (Liu et al., 2018) concatenates explicit x, y coordinate
channels to the input. Standard convolutions are translation-equivariant
and cannot reliably regress to an absolute pixel offset from a patch
center; CoordConv restores the center reference that pure convolutions
discard.
The head outputs three scalars: an offset (dx, dy) in patch-pixel
units (valid range about [-0.6, 0.6] px, matching the training
distribution) and a confidence logit. The current Rust consumer
applies the offset and ignores the confidence.
The PyTorch source in tools/ml_refiner/model.py also defines a
wider variant (CornerRefinerNetLarge, ~730 K params, GroupNorm
between convs) and a spatial-softargmax head
(CornerRefinerNetSoftArgmax). Both match the 1-channel in, 3-scalar
out contract so they are drop-in replacements for the inference
path. The shipped model is the small variant — the larger and
softargmax variants did not move the held-out error meaningfully in
our sweeps.
5.5.2 Training data and loss
The training pipeline lives in tools/ml_refiner/; the shipped model’s
synthetic dataset is generated by the config at
tools/ml_refiner/configs/synth_v6.yaml. It renders 200 000 patches
with a 50/50 mix of two rendering modes:
- Hard cells. An anti-aliased periodic checkerboard, rendered at
a random cell size in
[4, 12]px, then blurred by a Gaussian PSF inσ ∈ [0.3, 2.0]px. This matches real camera output: ink/paper step edges, softened by the optical system’s PSF, sampled by the sensor. The benchmark fixture in Part VIII uses the same renderer. - Smooth saddle. A
tanh(x)·tanh(y)corner model, included at 50 % weight so the model stays accurate on the smooth synthetic patches some callers still feed it.
Augmentations per sample: additive Gaussian noise σ ∈ [0, 10] gray
levels, photometric jitter (contrast, brightness, gamma), optional
projective warp for perspective robustness, and 20 % negative
samples (flat, edge, stripe, blob, pure noise, near-corner) with
is_pos = 0.
The true subpixel offset is sampled from [-0.6, 0.6] px.
Loss: Huber on (dx, dy) for positives, binary cross-entropy on
confidence for all samples. The regression loss is weighted up on
positives only via is_pos (negatives have no valid target).
5.5.3 Designing the training distribution
A subpixel ML refiner is only as good as the patches it trains on, and synthetic training data has two failure modes that pull in opposite directions:
- Smooth-only data. Training exclusively on smooth
tanh(x)·tanh(y)corners teaches the model transitions that span the whole patch. Real sensor images look nothing like that — hard ink/paper step edges softened by a small optical PSF — so a model that has only seentanhcorners localizes real ones poorly (mean error ~0.5 px on the hard-cell benchmark fixture). - Hard-only data. Swapping to hard cells alone flips the problem:
the model then mislocalizes the smooth synthetic corners some callers
still feed it (~0.6 px on
tanhinputs). Neither distribution alone spans the range of edge profiles a refiner meets in practice.
The shipped dataset avoids both by mixing the two modes 50/50 (§5.5.2)
and augmenting with sensor noise. Two lessons carry over into the
Part VIII benchmark. Noise augmentation is what lets the model take the
heaviest-noise row (σ = 10 gray levels), where it posts the lowest
mean error; and capacity is not the bottleneck — neither a wider CNN
nor a spatial-softargmax head lowered held-out error. On clean and
mildly blurred data the geometric refiners still win (Förstner for
clean frames, SaddlePoint for any blur), so ML earns its cost only
when noise dominates.
5.5.4 ONNX export and inference
The export step (tools/ml_refiner/export_onnx.py) writes an ONNX
graph at opset 17 (falling back to 18 if a conversion is unsupported).
The graph contract is:
- Input
patches:float32 [N, 1, 21, 21],u8 / 255in[0, 1]. - Output
pred:float32 [N, 3]with[dx, dy, conf_logit].
Rust inference is chess_corners_ml::MlModel::infer_batch. It wraps
tract-onnx, sizes the input to the runtime batch, and returns
Vec<[f32; 3]>. Dynamic batch sizing is supported via a tract
SymbolScope, so a single loaded model can handle variable-batch
calls without re-optimization.
5.6 Picking a refiner
The measurement-driven comparison lives in Part VIII. In short:
- Budget matters more than anything else: the structure-tensor refiners are orders of magnitude faster than ML per corner.
Förstnerhas the lowest mean error on clean frames;SaddlePointis more robust to blur and is a conservative all-round default.- ML has the lowest mean error on the heaviest synthetic noise row.
- SaddlePoint is a good default for image-patch refinement when frames may vary in sharpness.
The ChESS refiner is selected through ChessConfig.refiner:
DetectorConfig::chess().with_chess(|c| c.refiner = ChessRefiner::forstner()).
The Radon detector’s subpixel step is the built-in 3-point peak fit;
configure it via RadonConfig.peak_fit (e.g. PeakFitMode::Gaussian).
Switching is a single-line change, and the comparison numbers
in Part VIII come from running all four ChESS refiners on the same
fixture at a single build.
Next, Part VI covers the orientation
methods that produce the axes and sigma_theta* descriptor fields,
shared by both the ChESS and Radon pipelines.