Introduction
calib-targets-rs is a workspace of Rust crates for detecting and modeling planar calibration targets from corner clouds (for example, ChESS corners). The focus is geometry-first: target modeling, grid fitting, and rectification live here, while image I/O and corner detection are intentionally out of scope.
ChArUco detection overlay on a small board.
What it is:
- A small, composable set of crates for chessboard, ChArUco, PuzzleBoard, and marker-style targets.
- A set of geometric primitives (homographies, rectified views, grid coords).
- Practical examples and tests based on the
chess-cornerscrate.
What it is not:
- A replacement for your corner detector or image pipeline.
- A full calibration stack (no camera calibration or PnP here).
Recommended reading order:
- Project Overview and Conventions
- Algorithms — the building blocks every detector shares
- Pipelines — each target’s end-to-end detection flow
- Crate chapters, starting with calib-targets-core and calib-targets-chessboard
Quickstart
Install the facade crate (the image feature is enabled by default):
cargo add calib-targets image
Minimal chessboard detection:
use calib_targets::detect;
use calib_targets::chessboard::DetectorParams;
use image::ImageReader;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let img = ImageReader::open("board.png")?.decode()?.to_luma8();
let params = DetectorParams::default();
let result = detect::detect_chessboard(&img, ¶ms);
println!("detected: {}", result.is_some());
Ok(())
}
Python bindings
Python bindings are built with maturin:
pip install maturin
maturin develop
python crates/calib-targets-py/examples/detect_chessboard.py path/to/image.png
The calib_targets module exposes detect_chessboard, detect_charuco,
detect_puzzleboard, and detect_marker_board. The public API is
dataclass-first: config inputs are typed models and detector results are typed
dataclasses with to_dict()/from_dict(...) helpers for JSON interoperability.
detect_charuco requires params and the board lives in params.board.
For marker boards, target_position is populated only when
params.layout.cell_size is set and alignment succeeds.
MSRV: Rust 1.88 (stable).
Interactive Playground
The same detectors shipped in the Rust facade — chessboard, ChArUco,
PuzzleBoard, and marker board — also run directly in the browser via
WebAssembly. The npm package is
@vitavision/calib-targets;
the playground below is a thin React UI on top of it. No data leaves your
machine: detection happens in the WASM module loaded into this page.
What it does
| Surface | Description |
|---|---|
| Image input | Drop or browse a file; or pick a bundled public sample (chessboard, ChArUco, marker board, PuzzleBoard); or generate a synthetic target on-the-fly in WASM (Generate tab). |
| Target family | Switch between corner detection and the four target detectors (Chessboard, ChArUco, Marker board, PuzzleBoard). |
| Board geometry | For ChArUco, marker, and PuzzleBoard targets: configure rows, cols, and (ChArUco) ArUco dictionary directly in the panel. |
| Core params | Override min_corner_strength, min_labeled_corners, and max_components — the three params shared across all detector families. |
| Multi-config sweep | Toggle detect_*_best to run the built-in 3-config preset and keep the best result. |
| Overlays | Red corners, light-blue grid edges, yellow origin ring, and green far-corner ring drawn in image pixel coordinates. Toggled per-layer. |
| Zoom / pan | Scroll to zoom (up to 32×, pixel-crisp above 4×), drag to pan, double-click to fit. Hover a corner for an (i, j) / id / score tooltip. |
| Synthetic generation | render_*_png WASM functions produce a full-resolution target PNG; loading it auto-configures the matching detector. |
Running locally
If the embedded iframe fails to load (older browsers without
WebAssembly or ES modules support), build and run the demo standalone:
scripts/build-wasm.sh # populates demo/pkg/
cd demo && bun install && bun run dev # http://localhost:5173
To use the same WASM module from your own web app:
npm install @vitavision/calib-targets
import init, {
default_chess_config,
default_chessboard_params,
detect_chessboard,
rgba_to_gray,
} from "@vitavision/calib-targets";
await init();
const gray = rgba_to_gray(rgba, width, height);
const result = detect_chessboard(
width, height, gray,
default_chess_config(),
default_chessboard_params(),
);
The full TypeScript surface — default_*_params(...),
*_sweep_*(...), render_*_png(...), and list_aruco_dictionaries() —
is documented in the package README and ships as .d.ts declarations
alongside the WASM module.
Getting Started: From Target to Calibration Data
This tutorial walks you through the complete workflow:
- Choose the right calibration target for your use case.
- Generate a printable target file.
- Print it correctly.
- Write detection code in Python or Rust.
No prior knowledge of the library is assumed.
Step 1: Choose your target type
| Target | Best for | Requires |
|---|---|---|
| Chessboard | Quick start, simple intrinsic calibration | Nothing — no markers |
| ChArUco | Robust calibration, partial visibility OK, absolute corner IDs | ArUco dictionary |
| Marker board | Scenes where a full chessboard is impractical | Custom layout |
If you are unsure, start with ChArUco. It combines the subpixel accuracy of chessboard corners with the robustness of ArUco markers. Each detected corner carries a unique ID and a real-world position in millimeters, so partial views of the board are useful and board orientation is unambiguous.
If you want the absolute simplest path and only need basic intrinsic calibration, use the plain chessboard.
Step 2: Generate a printable target
Pick the language you are most comfortable with. All paths produce the same three output
files: <stem>.json, <stem>.svg, <stem>.png.
Python
pip install calib-targets
import calib_targets as ct
# ChArUco: 5 rows × 7 cols, 20 mm squares, DICT_4X4_50 markers
doc = ct.PrintableTargetDocument(
target=ct.CharucoTargetSpec(
rows=5,
cols=7,
square_size_mm=20.0,
marker_size_rel=0.75,
dictionary="DICT_4X4_50",
)
)
written = ct.write_target_bundle(doc, "my_board/charuco_a4")
print(written.png_path) # open this to preview
For a plain chessboard instead:
doc = ct.PrintableTargetDocument(
target=ct.ChessboardTargetSpec(
inner_rows=6,
inner_cols=8,
square_size_mm=25.0,
)
)
ct.write_target_bundle(doc, "my_board/chessboard_a4")
CLI
cargo install calib-targets installs the Rust binary;
pip install calib-targets installs the same command as a Python console
script. One-step generation:
calib-targets gen charuco \
--out-stem my_board/charuco_a4 \
--rows 5 --cols 7 --square-size-mm 20 \
--marker-size-rel 0.75 --dictionary DICT_4X4_50
# Or the reviewable three-step flow:
calib-targets list-dictionaries
calib-targets init charuco \
--out my_board/charuco_a4.json \
--rows 5 --cols 7 --square-size-mm 20 \
--marker-size-rel 0.75 --dictionary DICT_4X4_50
calib-targets validate --spec my_board/charuco_a4.json
calib-targets generate --spec my_board/charuco_a4.json \
--out-stem my_board/charuco_a4
Rust
use calib_targets::printable::{write_target_bundle, PrintableTargetDocument};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = PrintableTargetDocument::load_json("my_board/charuco_a4.json")?;
let written = write_target_bundle(&doc, "my_board/charuco_a4")?;
println!("{}", written.png_path.display());
Ok(())
}
See calib-targets-print for the full JSON schema and more options.
Step 3: Print it
Open my_board/charuco_a4.svg (or the .png at the generated DPI) in your
printer dialog:
- Set scale to 100% / “actual size”. Disable “fit to page”, “shrink to fit”, or any equivalent driver option.
- After printing, measure one square width with a ruler or caliper and confirm it
matches
square_size_mm(20 mm in the example above). - If the size is wrong, fix the print dialog and reprint — do not compensate in code.
- Mount or tape the target flat to a rigid surface. Warping or bowing degrades calibration accuracy significantly.
- Prefer the SVG for professional print workflows; use the PNG for quick office printing (check the DPI matches your printer resolution).
Step 4: Detect corners in Python
The board spec used for detection must match the one used for generation exactly.
import numpy as np
from PIL import Image
import calib_targets as ct
def load_gray(path: str) -> np.ndarray:
return np.asarray(Image.open(path).convert("L"), dtype=np.uint8)
# Board spec — must match the generated target
board = ct.CharucoBoardSpec(
rows=5,
cols=7,
cell_size=20.0, # mm; gives target_position in mm
marker_size_rel=0.75,
dictionary="DICT_4X4_50",
marker_layout=ct.MarkerLayout.OPENCV_CHARUCO,
)
params = ct.CharucoParams(board=board)
image = load_gray("frame.png")
try:
result = ct.detect_charuco(image, params=params)
except RuntimeError as exc:
print(f"Detection failed: {exc}")
raise SystemExit(1)
corners = result.corners
print(f"Detected {len(corners)} corners, {len(result.markers)} markers")
# Collect point pairs for solvePnP / calibrateCamera
obj_pts = [] # 3-D object points (Z = 0 for planar board)
img_pts = [] # 2-D image points
for c in corners:
if c.target_position is not None:
x_mm, y_mm = c.target_position
obj_pts.append([x_mm, y_mm, 0.0])
img_pts.append(c.position)
print(f"Point pairs ready for calibration: {len(obj_pts)}")
For a plain chessboard:
result = ct.detect_chessboard(image)
if result is None:
raise SystemExit("No chessboard detected")
corners = result.corners
print(f"Detected {len(corners)} corners")
# target_position is None for chessboard — assign object points by grid index
for c in corners:
i, j = c.grid # (col, row), origin top-left
obj_pts.append([i * square_size_mm, j * square_size_mm, 0.0])
img_pts.append(c.position)
Step 5: Detect corners in Rust
# Cargo.toml
[dependencies]
calib-targets = "0.10"
image = "0.25"
use calib_targets::charuco::{CharucoBoardSpec, CharucoParams, MarkerLayout};
use calib_targets::detect;
use image::ImageReader;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let img = ImageReader::open("frame.png")?.decode()?.to_luma8();
let board = CharucoBoardSpec::new(
5,
7,
20.0, // mm
0.75,
"DICT_4X4_50".parse()?,
)
.with_marker_layout(MarkerLayout::OpenCvCharuco);
let params = CharucoParams::for_board(&board);
let result = detect::detect_charuco(&img, ¶ms)?;
println!(
"corners: {}, markers: {}",
result.corners.len(),
result.markers.len()
);
// Collect point pairs
for c in &result.corners {
if let Some(tp) = c.target_position {
let obj = [tp[0], tp[1], 0.0_f32];
let img = c.position;
// pass (obj, img) to your calibration solver
let _ = (obj, img);
}
}
Ok(())
}
Next steps
| Topic | Where |
|---|---|
| How detection works underneath | The Grid Model |
| Detection parameters explained | Tuning the Detector |
| Detection fails or gives errors | Troubleshooting |
| What every output field means | Understanding Results |
| Full printable-target reference | calib-targets-print |
| ChArUco pipeline internals | ChArUco crate |
The Grid Model
Code:
projective-grid.
This is the foundational model every detector in the workspace shares.
Before you tune a chessboard, ChArUco, or PuzzleBoard detector it pays to
understand the layer beneath them all — the projective-grid crate. Given a
cloud of 2D feature points — optionally carrying one, two, or three local axis
directions per point — it recovers an (i, j) → point labelling: which integer
grid cell each feature occupies under perspective, together with a fitted
projective transform from model-plane coordinates to image pixels.
The input-feature kinds introduced here (plain points versus oriented features) and the recovery algorithm below are exactly the vocabulary the Tuning the Detector chapter’s parameters act on — read this first, and the tuning knobs stop being a flat list of names.
The crate is deliberately small and image-free. There are no
image, pixel-buffer, or camera types anywhere in its public surface,
and no target-specific identifiers (marker IDs, ring IDs, calibration
metadata). It is target-agnostic: the same lattice recovery serves
a chessboard detector, a laser-dot cloud, a scanned form, or a
photographed board game. The detection surface is single-precision
(f32); the standalone projective geometry kernel stays generic over
f32 / f64 via the Float trait. The other workspace detectors sit above this
crate — they run a corner detector, convert its output into generic
point or oriented features, and call in here for the labelling.
The crate ships independently on crates.io and is used directly for non-calibration tasks: rectifying a photograph of a board game, fitting a locally-planar lattice to a laser-dot cloud, extracting a grid from a scanned document, or building a new detector for a pattern the workspace doesn’t yet ship.
The model
Three small pieces define the public surface.
Two lattice families (LatticeKind). Square is the orthogonal
(i, j) grid and is detected by the topological algorithm. Hex (axial (q, r))
is also detected on the topological path: its triangles are the unit
cells directly, so there is no diagonal/quad-merge stage.
Two tasks.
- Detection —
detect_grid/detect_grid_all: recover labels from raw evidence when you do not know which feature is which cell. - Consistency —
check_consistency: you already have a proposed(i, j)label per feature (e.g. from a marker decode) and want to know whether those labels are geometrically consistent under a single projective fit. This is a separate entry point with its own request and report types; it does not go through theEvidenceenum.
Explicit evidence shapes (Evidence). Detection input is wrapped
in an enum that names exactly how much orientation the caller can
supply. The less-oriented square kinds synthesize the missing axes from
neighbour geometry up front and then run the same strategy:
| Variant | Payload | Square | Hex |
|---|---|---|---|
Positions | &[PointFeature] | ✅ synthesize 2 axes | ✅ synthesize 3 axes (topological) |
Oriented1 | &[OrientedFeature<1>] | ✅ synthesize 2nd axis | ❌ UnsupportedCombination |
Oriented2 | &[OrientedFeature<2>] | ✅ native (topological) | ❌ UnsupportedCombination |
Oriented3 | &[OrientedFeature<3>] | ❌ UnsupportedCombination | ✅ native (topological) |
CoordinateHypotheses | features + hypotheses | use check_consistency instead | — |
Each feature carries a PointFeature (position + caller-owned
source_index) plus N undirected LocalAxis directions (N = 0 for
Positions). Any unsupported (lattice, evidence) combination — for
example (Square, Oriented3), (Hex, Oriented1/Oriented2), or
CoordinateHypotheses for detection — returns a typed
GridError::UnsupportedCombination { task, lattice, evidence }, never a
guessed answer.
Worked example
A fully self-contained, image-free example: synthesize a small 3×3
grid with a mild perspective shear, wrap the features as evidence,
detect, and read the recovered labels. This is the body of
examples/hello_grid.rs — run it with
cargo run -p projective-grid --example hello_grid.
use nalgebra::Point2;
use projective_grid::{
detect_grid, DetectionParams, DetectionRequest, Evidence, LatticeKind, LocalAxis,
OrientedFeature, PointFeature,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Build a 3x3 grid of oriented features. The `+ j * 6.0` term adds a
// mild perspective-style shear, so this is a genuine projective grid,
// not a perfectly axis-aligned one.
let mut features: Vec<OrientedFeature<2>> = Vec::new();
for j in 0..3 {
for i in 0..3 {
// Image-frame position: origin top-left, x right, y down.
let x = 60.0 + i as f32 * 40.0 + j as f32 * 6.0;
let y = 50.0 + j as f32 * 40.0;
// `source_index` is a stable caller-owned handle; the solution
// reports it back so you can map a label to the input feature.
let point = PointFeature::new(features.len(), Point2::new(x, y));
// Two roughly-orthogonal local axes: horizontal (0 rad) and
// vertical (pi/2 rad), each with a small angular sigma.
let axes = [
LocalAxis::new(0.0, Some(0.02)),
LocalAxis::new(std::f32::consts::FRAC_PI_2, Some(0.02)),
];
features.push(OrientedFeature::new(point, axes));
}
}
// Wrap as Oriented2 evidence and ask for a square lattice. Grid
// dimensions are unknown (`None`); the detector infers the extent.
let request = DetectionRequest::new(
LatticeKind::Square,
Evidence::Oriented2(&features),
None,
DetectionParams::default(),
);
// `detect_grid` returns the largest recovered component.
let solution = detect_grid(request)?;
for entry in &solution.grid.entries {
// coord.u = i, coord.v = j; source_index maps back to the input.
println!(
"(i={}, j={}) <- feature {}",
entry.coord.u, entry.coord.v, entry.source_index
);
}
Ok(())
}
Running it labels all nine features (0,0) through (2,2) with a
sub-pixel fit residual. Two sibling examples under
crates/projective-grid/examples/ round out the surface:
detect_square_oriented2 (a larger detection run) and
check_square_consistency (the consistency task on pre-labelled
features).
The square algorithm: Topological
Detection of (Square, Oriented2) uses the topological grid finder — the
sole grid builder for all square targets. It is the Shu / Brunton / Fiala 2009
axis-driven grid finder: a Delaunay triangulation over the corner cloud whose
edges are classified by per-corner axis match, with triangle pairs merged into
cells and integer coordinates flooded across the mesh. Image-free; recovers
dense grids and copes well with distortion. May produce several components —
see detect_grid_all below.
There is only one square grid builder, so the request carries no algorithm
choice: what to detect is selected by the LatticeKind (Square / Hex)
and the Evidence shape on the DetectionRequest, not by an algorithm enum.
The historical single-variant SquareAlgorithm / GraphBuildAlgorithm seams
were removed once seed-and-grow was retired.
The algorithm shares the post-detection validation and projective fit across
all target types, recovering the full pattern with zero wrong labels. The
deep-dive — the axis-classification test, the triangle-to-cell merge, and the
line between the generic machinery here and the chessboard-specific wrapper —
is on the Topological grid finder algorithm page,
with the full stage-by-stage reference in
docs/algorithms/topological-grid-detection.md.
Hex also uses the topological algorithm. On a hex point lattice the
Delaunay triangles are the unit cells, so the diagonal/quad-merge stage is
bypassed; the axial (q, r) walk and the projective fit back-half are
otherwise shared with the square topological path. The fit residual is the
precision gate.
Single vs. multi-component results
detect_grid returns the largest recovered component as one
GridSolution. When the lattice is split into islands (for example by
occlusion) and the secondary components matter, call detect_grid_all:
it returns a DetectionReport whose solutions vector holds one
GridSolution per qualifying component, ordered by labelled-count
descending. The topological path may yield several components.
Inputs
Detection input is the Evidence enum (see The model above). For the
native Oriented2 shape each element is an OrientedFeature<2>:
point: PointFeature—position(image-frame pixel center) and a stable, caller-ownedsource_index. The solution reports thesource_indexback so a recovered label maps to the exact input.axes: [LocalAxis; 2]— two undirected local lattice directions, each anangle_radplus an optionalsigma_rad(angular uncertainty). Axes are undirected:θandθ + πdenote the same direction.
DetectionRequest::new(lattice, evidence, dimensions, params) bundles
the lattice family, the evidence, optional known GridDimensions, and
a DetectionParams. DetectionParams carries max_residual_px (the
fit residual gate) and the algorithm selector (always Topological),
with a topological sub-config and a shared validate sub-config;
Default covers all the tuning knobs and the builder-style with_*
methods override individual fields.
Outputs
A successful detection is a GridSolution:
| Field | Meaning |
|---|---|
grid: LabelledGrid | The labelled component: entries (one per placed feature), the lattice family, an inclusive coordinate bbox, and the optional caller-supplied dimensions. |
fit: Option<LatticeFit> | The fitted model-plane-to-image projective transform plus a residuals: ResidualSummary (count, mean_px, max_px). |
rejected: Vec<RejectedFeature> | Features this component could not place. |
Each GridEntry carries:
coord: Coord— the(i, j)label ascoord.u/coord.v, rebased so the labelled bounding box starts at(0, 0).source_index: usize— back into the caller’s input slice.image_position: Point2<f32>— the feature’s image-frame pixel-center position.residual_px: Option<f32>— reprojection residual in pixels, present when a fit was computed.
Each RejectedFeature carries the source_index, an optional
coord, an optional residual_px, and a RejectionReason:
Unlabelled (never placed — e.g. noise outside the recovered lattice),
ValidationDropped (placed by the topological pass but dropped by
post-detection validation: line collinearity, local-homography residual,
or edge-length band), or ResidualTooHigh (reprojection residual
exceeded max_residual_px).
For multi-component runs, detect_grid_all returns a DetectionReport
with the per-component solutions vector plus a top-level rejected
slot.
Checking caller-supplied labels
When labels already exist — for instance after decoding marker IDs into
(i, j) coordinates — check_consistency scores them against a single
projective fit instead of recovering them from scratch. Build a
ConsistencyRequest::new(lattice, features, hypotheses, dimensions, params) from position-only PointFeatures and a parallel slice of
CoordinateHypothesis (each pairing a source_index with a proposed
Coord), with a ConsistencyParams whose max_residual_px sets the
acceptance threshold. The returned ConsistencyReport has passed
(true when every residual clears the threshold), the full solution
(labels, fit, and any over-residual rejected entries), and a
max_residual_px() convenience accessor. check_square_consistency in
the examples directory is the runnable version.
This is also the one entry point that consumes coordinate hypotheses;
Evidence::CoordinateHypotheses exists for symmetry in the detection
enum but detect_grid does not yet act on it.
Conventions
- Coordinates. Image pixels: origin top-left, x right, y down. Grid
i(coord.u) increases right,j(coord.v) increases down. - Undirected axes. A
LocalAxisangle is undirected —θandθ + πare the same direction. Any circular mean over axis angles must therefore accumulate(cos 2θ, sin 2θ)and halve the resultingatan2; naive(cos θ, sin θ)averaging breaks at the 0°/180° seam. - Non-negative, top-left-origin labels. Output
(i, j)is rebased so the labelled bounding-box minimum is(0, 0). - Single precision. The detection surface is pinned to
f32. Only the standalone projective geometry kernel stays generic overF: Float, for a futuref64calibration consumer.
Out of scope
- 3D grids. Coordinates are 2D (
nalgebra::Point2); there is no 3D support. - Non-planar surfaces. The fit assumes a single planar homography maps the labelled set; severely curved surfaces are not modelled here.
- Feature detection. This crate does lattice recovery and projective
consistency, not corner finding. Bring your own points; if you have an
image, run a corner detector first and convert its output into
PointFeature/OrientedFeaturevalues before calling in. - Dense, unstructured point clouds. Pure noise without any local axis structure does not yield a usable Delaunay classification.
Learn more
API reference: projective-grid on docs.rs.
The topological grid finder has an in-repo deep-dive at
docs/algorithms/topological-grid-detection.md.
Tuning the Detector
This chapter answers the question: “My detection fails or gives poor results — what do I change?”
Background first. Every parameter below acts on the grid-recovery pipeline — its input-feature kinds, the topological grid builder, and the per-stage contract. If a knob’s name reads as jargon, read The Grid Model first; the tuning reference assumes that vocabulary.
Start here: use the built-in defaults
Before tuning anything, confirm you are starting from the library defaults:
#![allow(unused)]
fn main() {
use calib_targets::detect::detect_chessboard;
use calib_targets::chessboard::DetectorParams;
let params = DetectorParams::default();
}
For ChArUco:
#![allow(unused)]
fn main() {
use calib_targets::charuco::CharucoParams;
let board = todo!();
let params = CharucoParams::for_board(&board);
}
The chessboard detector’s ChESS corner config is not carried inside
DetectorParams — it’s a separate argument via
calib_targets::detect::default_chess_config() (used automatically by
the detect_chessboard* facade helpers). If you need to override it,
call calib_targets::detect::detect_corners(&img, &custom_chess_config)
directly and pass the resulting corner cloud into
calib_targets::chessboard::Detector::new(params).detect(&corners).
For ChArUco, CharucoParams.chessboard is a DetectorParams: a stable
core of four fields plus an opt-in advanced block (see the per-parameter
reference below). Board sampling scale is controlled separately by
CharucoParams::for_board, which starts with px_per_square = 60.
If marker decoding is the problem and the board appears at a very
different pixel scale, adjust px_per_square before touching other
parameters.
Challenging images: multi-config sweep
For images with uneven lighting, Scheimpflug optics, or narrow focus strips, a single threshold may miss corners in some regions. Use the multi-config sweep to try several parameter variants and keep the best result:
#![allow(unused)]
fn main() {
use calib_targets::detect::{detect_chessboard_best, detect_charuco_best};
use calib_targets::chessboard::DetectorParams;
use calib_targets::charuco::CharucoParams;
let img: image::GrayImage = todo!();
let board = todo!();
let chess_configs = DetectorParams::sweep_default();
let chess_result = detect_chessboard_best(&img, &chess_configs);
let charuco_configs = CharucoParams::sweep_for_board(&board);
let charuco_result = detect_charuco_best(&img, &charuco_configs);
}
DetectorParams::sweep_default() returns three configs: default +
tighter + looser on cluster_tol_deg, attach_axis_tol_deg, and
related tolerances. All three preserve the detector’s precision-
by-construction invariants; only recall-affecting tolerances are
varied.
For PuzzleBoard, use PuzzleBoardParams::sweep_for_board(&spec).
Multi-component detection (via Detector::detect_all / the facade
detect_chessboard_all) recovers fragmented grids where markers break
contiguity — each disconnected piece comes back as its own
Detection with its own locally-rebased (i, j) labels. Capped by
DetectorParams::max_components (default 3).
Symptom → parameter table
min_corner_strength, min_labeled_corners, and max_components are
stable top-level fields; every other chessboard knob below is an
advanced knob set via DetectorParams::with_advanced(...). ChArUco /
PuzzleBoard decode.* knobs sit on their own config structs.
| Symptom | Parameter to adjust |
|---|---|
detect_chessboard returns None | min_corner_strength ↓, cluster_tol_deg ↑, min_peak_weight_fraction ↓, or try detect_chessboard_best |
| Partial board, many holes | attach_search_rel ↑, attach_axis_tol_deg ↑ |
| Scene has multiple chessboard components | use detect_chessboard_all (cap with max_components) |
| Fast perspective / wide-angle lens | edge_axis_tol_deg ↑, geometry_check_local_h_tol_rel ↑ |
Corners falsely labelled (wrong (i, j)) | Do not tune — file a bug. precision contract forbids this. |
NoMarkers on blurry ChArUco | min_border_score ↓, multi_threshold: true |
AlignmentFailed (low inlier count) | min_marker_inliers ↓ |
DecodeFailed on PuzzleBoard | decode.min_bit_confidence ↓, decode.max_bit_error_rate ↑ |
Per-parameter reference: chessboard::DetectorParams
DetectorParams is a #[non_exhaustive] struct split into two surfaces:
- a stable core of four fields covered by semver —
graph_build_algorithm(single-variant,Topological; retained as a reserved config seam),min_labeled_corners,max_components, andmin_corner_strength(see Output gates and Stage 1 below); - an opt-in
advancedsub-struct (Option<Box<AdvancedTuning>>) holding the ~40 per-stage knobs.AdvancedTuningis NOT covered by semver — leave it unset unless a specific input fails and you have evidence for the change.
Attach overrides with DetectorParams::with_advanced(tuning) and read the
effective tuning with effective_tuning(). AdvancedTuning is
#[non_exhaustive], so build it from AdvancedTuning::default() and
mutate the knobs you need:
#![allow(unused)]
fn main() {
use calib_targets::chessboard::{AdvancedTuning, DetectorParams};
let mut advanced = AdvancedTuning::default();
advanced.cluster_tol_deg = 16.0;
advanced.attach_search_rel = 0.5;
let params = DetectorParams::default().with_advanced(advanced);
}
All knobs in the Stage 2-8 tables below are advanced knobs set on the
advanced block; min_corner_strength (Stage 1) and the output gates are
stable top-level fields. See the chessboard chapter for
the full invariant-to-parameter mapping and
crates/calib-targets-chessboard/src/params/ for defaults.
Stage 1 — pre-filter
min_corner_strength is a stable top-level field;
max_fit_rms_ratio is an advanced knob.
| Field | Default | Guidance |
|---|---|---|
min_corner_strength | 0.0 | Raise to 0.3–0.5 on noisy scenes with many spurious saddles. Drops weak corners before clustering. |
max_fit_rms_ratio | 0.5 | ChESS fit_rms must be ≤ ratio × contrast. Raise to 0.8 when accepting softer corners; lower tightens the pre-filter. |
Stages 2-3 — grid-direction clustering
| Field | Default | Guidance |
|---|---|---|
num_bins | 90 | Histogram resolution (π / n per bin). Rarely adjusted. |
cluster_tol_deg | 12.0 | Per-axis absolute tolerance vs cluster centre for a corner to be labelled. Raise to 16 on noisy axes; tighter risks unclustering legitimate corners. |
peak_min_separation_deg | 60.0 | Minimum angle between the two returned peaks. Guards against twin-peak collisions. |
min_peak_weight_fraction | 0.02 | Fraction of total axis-vote weight a peak must carry. Lower on dense boards where each real peak only carries a few percent; higher rejects spurious noise peaks. |
Stage 5 — seed
Seed-finding tolerances are internal to the topological grid builder and
are not exposed as public tuning knobs. If seeding consistently fails, use
detect_chessboard_best with DetectorParams::sweep_default() which
varies the upstream clustering and attachment tolerances.
Stage 6 — grow
| Field | Default | Guidance |
|---|---|---|
attach_search_rel | 0.35 | KD-tree search radius around each prediction (fraction of cell_size). Raise to 0.45–0.55 on images with noticeable perspective; tighter rejects more holes. |
attach_axis_tol_deg | 15.0 | Candidate’s axes must match both cluster centres within this tolerance. |
attach_ambiguity_factor | 1.5 | If the second-nearest candidate is within factor × nearest, attachment is skipped (the position is marked ambiguous). |
step_tol | 0.25 | Edge-length window at attachment ([1 − step_tol, 1 + step_tol] × s). |
edge_axis_tol_deg | 15.0 | Induced-edge axis alignment at attachment. |
Stage 7 — validate
| Field | Default | Guidance |
|---|---|---|
geometry_check_local_h_tol_rel | 0.20 | Local 4-point homography residual tolerance for the final geometry check. |
line_min_members | 3 | Minimum row/column length for a line fit to be attempted. |
Stage 8 — recall boosters
Per-stage toggle (an advanced knob): enable_weak_cluster_rescue
(default true). Leave it on unless the weak-cluster booster is producing
false positives for you. Line extrapolation, gap fill, and component merge
run unconditionally and are no longer configurable.
Output gates
min_labeled_corners and max_components are stable top-level fields.
| Field | Default | Guidance |
|---|---|---|
min_labeled_corners | 8 | Detection rejected below this labelled count. Raise for validation boards with an expected floor. |
max_components | 3 | Cap for detect_all. Raise if a scene legitimately fragments into more pieces of the same board (rare). |
Per-parameter reference: ScanDecodeConfig / ChArUco
These parameters live inside CharucoParams.
min_border_score
Default: 0.75 for ChArUco.
Guidance: Minimum contrast score for the black border ring around a marker. Lower
cautiously to 0.65 for very blurry images. Values below 0.60 risk accepting
non-marker regions.
multi_threshold
Default: true.
Guidance: When enabled, the decoder tries several Otsu-style binarization thresholds until a dictionary match is found. This handles uneven lighting and motion blur at the cost of a small speed penalty. Disable only when speed is critical and lighting is controlled.
inset_frac
Default: 0.06 for ChArUco.
Guidance: Fraction of the cell size inset from the cell boundary before sampling
the marker interior. Raise to 0.10–0.12 when the cell boundary visibly bleeds into
the bit area (common with thick printed borders or strong blur).
marker_size_rel
Source: Board specification — must match the printed board exactly.
Guidance: Ratio of the ArUco marker side to the chessboard square side. A mismatch here causes systematic decoding failures even when all other parameters are correct. Verify against the printed board or the JSON spec used to generate it.
Quick checklist
- Start with defaults; run with
RUST_LOG=debugto see corner counts and per-stage counters. - If no corners are found: loosen
min_corner_strength, check image resolution and contrast. - If corners found but no grid (
detect_chessboardreturnsNone): runcalib_targets_chessboard::trace_topological— fewusablecorners means the prefilter / clustering is too tight (try loweringmin_corner_strengthor the advancedmin_peak_weight_fraction),Err(NoComponents)means the topological builder assembled no grid (trydetect_chessboard_best), and components found but a refused detection points at the final geometry check (inspectGeometryCheckTrace.dropped_*; try a wider config). See Troubleshooting for the full chain. - If grid found but no ChArUco markers: enable
multi_threshold, lowermin_border_score. - If alignment fails: verify board spec (rows, cols, dictionary,
marker_size_rel). - If you observe wrong
(i, j)labels, that’s a precision- contract bug — file an issue rather than tuning around it. The detector is engineered to drop corners before it labels them wrong.
See also: Troubleshooting for per-error checklists and the Chessboard Detector chapter for the full invariant stack.
Troubleshooting
This chapter maps each error variant to a diagnostic checklist. For parameter descriptions, see Tuning the Detector.
Reading the debug log
Enable debug logging before anything else:
RUST_LOG=debug cargo run --example detect_charuco -- testdata/small2.png
Or from code:
#![allow(unused)]
fn main() {
tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init();
}
Key log lines and what they tell you:
| Log line | Meaning |
|---|---|
input_corners=N | N ChESS corners passed the strength filter |
chessboard stage failed: ... | Grid assembly error; reason follows |
marker scan produced N detections | N cells decoded a valid marker ID |
alignment result: inliers=N | N markers matched the board spec |
cell (x,y) failed decode | That cell did not match any dictionary entry |
cell (x,y) passed threshold but no dict match | Binarization ok, wrong dictionary |
If you do not see these lines, confirm RUST_LOG=debug is set in the shell that runs
the binary, not in a parent process.
detect_chessboard returns None
The detector has no single error variant — a None return means some
stage failed. To diagnose, run the chessboard crate’s serializable
topological trace, calib_targets_chessboard::trace_topological,
which is layered over the production path (so it reflects what detect()
actually does) and reports per-corner usability plus the labelled
components:
use calib_targets::detect::{default_chess_config, detect_corners};
use calib_targets_chessboard::{trace_topological, DetectorParams};
let img: image::GrayImage = todo!();
let corners = detect_corners(&img, &default_chess_config());
match trace_topological(&corners, &DetectorParams::default()) {
Ok(trace) => {
let usable = trace.corners.iter().filter(|c| c.usable).count();
println!("corners_in: {}", trace.diagnostics.corners_in);
println!("corners_used: {usable}");
println!("components: {}", trace.components.len());
println!("total labels: {}", trace.diagnostics.labels);
}
Err(e) => println!("topological stage produced no grid: {e}"),
}
Checklist:
-
No ChESS corners found?
corners.is_empty()(andtrace.diagnostics.corners_in == 0). The ChESS detector saw nothing — check image resolution / contrast; overridecalib_targets::detect::default_chess_config()with a customDetectorConfigif necessary — e.g.DetectorConfig::chess().with_threshold(Threshold::Absolute(8.0))to drop the noise floor, or.with_threshold(Threshold::Relative(0.05))for a fraction of the per-frame peak response. The chess-corners 0.10 release replaced the legacy(threshold_mode, threshold_value)pair with the tagged-enumThresholdshown above. -
Corners found but few
usable? The strength / fit prefilter or the axis-usability gate is rejecting most corners. Lowermin_corner_strength, raisemax_fit_rms_ratio, and check the axis clustering tolerances (cluster_tol_degdefault12.0→ try16.0;min_peak_weight_fractiondefault0.02→ try0.01). A perfectly rectilinear board with axes on the π-wrap boundary is handled by plateau-aware peak picking. -
Usable corners but
Err(NoComponents)? The topological builder assembled no quad mesh. Trydetect_chessboard_bestwithDetectorParams::sweep_default()(widens the clustering and attachment tolerances). -
Components found but
detect_chessboardstillNone? The final geometry check refused the detection (survivors belowmin_labeled_corners) or only tiny components survived. Try a wider config viadetect_chessboard_best; if the scene legitimately holds multiple boards, usedetect_chessboard_alland handle each component separately. -
Multiple same-board components in the scene (ChArUco markers break contiguity): this is expected. Use
detect_chessboard_all; each piece comes back with its own locally-rebased(i, j).
NoMarkers
All ChESS corners were found and the chessboard grid was assembled, but no ArUco/AprilTag marker was decoded inside any cell.
Checklist:
-
Correct dictionary? The
dictionaryfield in the board spec must match the one used when printing. A mismatch producescell (x,y) passed threshold but no dict matchin the log for every cell. -
Correct
marker_size_rel? If the sampled region is the wrong fraction of the cell, the bit cells will be misaligned. Verify against the board spec. -
Blurry image?
- Enable
multi_threshold: true(already the default for ChArUco). - Lower
min_border_scoreto0.65–0.70.
- Enable
-
Uneven lighting?
multi_thresholdhandles this automatically. If already enabled, check whether the board surface has specular reflections — these cannot be corrected by thresholding alone. -
Wrong scale? If
px_per_squareis far from the actual pixel size, the projective warp used for cell sampling will produce a very small or very large patch. AdjustCharucoParams.px_per_square.
AlignmentFailed { inliers: N }
Markers were decoded, but fewer than min_marker_inliers of them matched the board
specification in a geometrically consistent way.
Checklist:
-
inliers = 0: No decoded marker ID appears in the board layout at all.- Board spec mismatch: wrong
rows,cols,dictionary, ormarker_layout. - Marker IDs may be correct but the layout offset is wrong (e.g. the board was
generated with a non-zero
first_markerid).
- Board spec mismatch: wrong
-
inlierssmall but non-zero:- Board is partially visible — lower
min_marker_inliersto the number of markers you reliably expect to see. - Strong perspective distortion — raise the chessboard-side attachment
tolerances (
attach_axis_tol_deg,edge_axis_tol_deg) so more corners enter the grid, or usedetect_charuco_bestwith a sweep.
- Board is partially visible — lower
-
inliersnear threshold:- One or two spurious decodings are pulling the fit off. Raise
min_border_scoreslightly to reject low-confidence markers.
- One or two spurious decodings are pulling the fit off. Raise
Common image problems
| Problem | Recommended fix |
|---|---|
| Strong blur | Lower min_border_score to 0.65, enable multi_threshold |
| Uneven / gradient lighting | multi_threshold (already default) |
| Strong perspective / wide-angle | Raise edge_axis_tol_deg / attach_axis_tol_deg / geometry_check_local_h_tol_rel on the chessboard side |
| Partial occlusion | Use detect_chessboard_all; for ChArUco, lower min_marker_inliers |
| Multiple same-board components | detect_chessboard_all; cap via max_components |
| Very small ChArUco board in frame | Raise CharucoParams.px_per_square to match actual square size |
| Specular reflections on board | Pre-process with local contrast normalisation (CLAHE); if pre-processing is off the table, lower min_peak_weight_fraction so clustering can cope with the reduced corner count |
Grid components found but detection None | Use detect_chessboard_best; inspect the trace_topological components and the final-check GeometryCheckTrace.dropped_* counters |
Getting more help
- Open an issue on GitHub
and attach the debug log (with
RUST_LOG=debug), image, and board spec. - See Tuning the Detector for full parameter reference.
- See Understanding Results for field meanings and score thresholds.
Understanding Detection Results
This chapter describes the shared corner vocabulary used by the result
types, explains when optional fields are populated, and gives guidance on
interpreting score values.
Shared TargetDetection
#![allow(unused)]
fn main() {
pub struct TargetDetection {
pub kind: TargetKind,
pub corners: Vec<LabeledCorner>,
}
}
TargetDetection is the shared carrier used by serialization, FFI, and
adapter methods such as target_detection(). The primary Rust, Python,
and WASM APIs return typed result objects with a top-level corners
field.
kind identifies the target type:
| Variant | Produced by |
|---|---|
TargetKind::Chessboard | ChessboardDetection::target_detection() |
TargetKind::Charuco | CharucoDetectionResult::target_detection() |
TargetKind::PuzzleBoard | PuzzleBoardDetectionResult::target_detection() |
TargetKind::CheckerboardMarker | MarkerBoardDetectionResult::target_detection() |
In the shared carrier, corners is a Vec<LabeledCorner> ordered
differently per target type:
- Chessboard: row-major order (left-to-right, top-to-bottom by grid coordinates).
- ChArUco: ordered by ascending
id. - PuzzleBoard: ordered by detected grid traversal, with absolute master-grid
coordinates in
grid. - Marker board: ordered by grid coordinates
(i, j).
LabeledCorner fields
#![allow(unused)]
fn main() {
pub struct LabeledCorner {
pub position: [f32; 2],
pub grid: Option<[i32; 2]>,
pub id: Option<u32>,
pub target_position: Option<[f32; 2]>,
pub score: f32,
}
}
position
Pixel coordinates of the detected corner in the input image.
- Origin: top-left.
- X axis: right; Y axis: down.
- Sub-pixel accuracy; values are not rounded to integer pixels.
grid — (i, j)
Integer corner coordinates within the detected grid.
i= column index (increases right).j= row index (increases downward).- Origin: top-left corner of the detected region (not necessarily the top-left of the physical board — the detector does not know board orientation).
Always populated for chessboard and marker board detections. Populated for ChArUco when a board spec is provided (i.e., when alignment succeeds).
id
Logical marker corner ID. ChArUco only.
Each inner corner of a ChArUco board is shared by two squares and is assigned a unique
integer ID by the board specification. This ID is identical to the one used by OpenCV’s
aruco::CharucoBoard. For chessboard and marker board detections, id is always
None.
target_position
Real-world position of the corner in board units (typically millimeters when cell_size
is given in mm).
| Target type | When populated |
|---|---|
| Chessboard | Never (no physical size in ChessboardParams) |
| ChArUco | Always when board.cell_size > 0 and alignment succeeds |
| PuzzleBoard | Always when decode succeeds |
| Marker board | Only when layout.cell_size > 0 and alignment succeeds |
Use target_position directly as the object-space point for camera calibration (pass
alongside the corresponding position as the image-space point).
score
A 0..1 quality score for this corner’s associated marker decode. Higher is better.
The score blends the border contrast of the surrounding marker border ring and a
Hamming penalty based on the number of bit errors when matching to the dictionary.
For chessboard corners (no marker), score reflects the ChESS corner response
strength, normalised to 0..1.
Interpretation:
| Score range | Meaning |
|---|---|
| ≥ 0.90 | High-confidence detection — use with confidence |
| 0.75–0.90 | Acceptable — watch for occasional false matches |
| < 0.75 | Treat with caution; upstream sampling may be poor |
Corners with score < min_border_score are filtered out before being returned, so
scores below that threshold will not appear in the output.
ChArUco-Specific: CharucoDetectionResult
detect_charuco returns CharucoDetectionResult rather than a bare TargetDetection:
#![allow(unused)]
fn main() {
pub struct CharucoDetectionResult {
pub corners: Vec<CharucoCorner>,
pub markers: Vec<MarkerDetection>,
pub alignment: GridAlignment,
}
}
markers
Raw list of decoded ArUco markers, one per cell that passed decoding. Each
MarkerDetection carries:
id: decoded dictionary ID.border_score: the contrast score for the border ring (maps toscoreinLabeledCornerfor marker-anchored corners).code: the raw decoded bit pattern (before dictionary lookup).rotation: 0/1/2/3 clockwise 90° rotations applied to normalise the marker.
alignment
When not None, carries the affine/homographic mapping between board-grid coordinates
and image-pixel coordinates. Populated when at least min_marker_inliers markers
agree on a consistent geometric transformation. Use this to project additional board
points into the image without re-running detection.
FAQ
Q: Why are grid coordinates not always the same as the printed board coordinates?
The detector builds the grid from scratch without knowing which corner is the
board’s physical origin. For plain chessboards and marker boards, the (0, 0)
origin is the top-left of the detected region in the image, not necessarily
the physical board corner. Use id (ChArUco or PuzzleBoard) or
target_position to obtain board-canonical positions.
Q: Can I use target_position directly for solvePnP?
Yes. Pair each LabeledCorner.position (image point) with the corresponding
LabeledCorner.target_position (object point) and pass them to solvePnP or your
calibration solver. Filter to corners where target_position.is_some() first.
Q: What is a normal score for a well-printed board under good lighting?
Typical values are 0.88–0.97. Scores consistently below 0.80 suggest image
blur, poor print quality, or an incorrect inset_frac.
Migration Guide — 0.10.0
This guide collects every breaking change in the 0.10.0 release for
upstream consumers of the calib-targets workspace, with before/after
snippets for Rust, the JSON config wire format, and the Python bindings.
The changes fall into six themes:
- Chessboard tuning is now opt-in (
advanced) min_corner_strengthis a stable top-level field- Diagnostics moved behind a
diagnosticscargo feature cell_sizeis back onChessboardDetection#[non_exhaustive]+ named constructors everywhere- Chessboard algorithm renamed to
SeedAndGrow
If you only read corners off a detection result and never construct config or result structs by literal, most of this does not affect you — the serialized JSON for a default detection is unchanged and detection behaviour is byte-identical.
1. Chessboard tuning is now opt-in
The ~40 per-stage chessboard tuning knobs that previously lived flat on
DetectorParams (via a ChessboardTuning sub-struct flattened into the
wire format) have moved behind an opt-in, semver-exempt advanced block.
-
ChessboardTuningwas renamedAdvancedTuning. It is re-exported from the chessboard crate root and thecalib_targets::chessboardfacade. It is documented but explicitly unstable: its fields are NOT covered by semver and may be renamed, retyped, or removed between minor versions. It is#[non_exhaustive], so build it fromAdvancedTuning::default()and mutate the knobs you need. -
DetectorParamsnow carries four stable fields —graph_build_algorithm,min_labeled_corners,max_components,min_corner_strength— plus an opt-inadvanced: Option<Box<AdvancedTuning>>. Attach overrides withwith_advanced(...); read the effective tuning (configured or default) witheffective_tuning(). Withadvancedunset, detection is byte-identical to the previous defaults.
Rust
#![allow(unused)]
fn main() {
// before
let mut params = DetectorParams::default();
params.tuning.cluster_tol_deg = 9.0;
params.tuning.seed_edge_tol = 0.18;
// after
use calib_targets::chessboard::{AdvancedTuning, DetectorParams};
let mut advanced = AdvancedTuning::default(); // #[non_exhaustive]: start from default
advanced.cluster_tol_deg = 9.0;
advanced.seed_edge_tol = 0.18;
let params = DetectorParams::default().with_advanced(advanced);
// read the knobs the detector will actually use (configured or default):
let tol = params.effective_tuning().cluster_tol_deg;
}
JSON config wire format
Every tuning knob except min_corner_strength now lives under a nested
"advanced" object instead of at the top level. Old flat configs that
set advanced knobs at the top level silently fall back to the defaults
for those knobs — serde ignores the unknown top-level keys. Move them
into an "advanced" block to carry them forward. The nested block is
omitted entirely when no advanced tuning is set.
// before (flat)
{
"graph_build_algorithm": "chessboard_v2",
"min_labeled_corners": 8,
"max_components": 3,
"cluster_tol_deg": 9.0,
"seed_edge_tol": 0.18
}
// after (advanced knobs nested)
{
"graph_build_algorithm": "seed_and_grow",
"min_labeled_corners": 8,
"max_components": 3,
"min_corner_strength": 0.0,
"advanced": {
"cluster_tol_deg": 9.0,
"seed_edge_tol": 0.18
}
}
Python
The Python ChessboardParams dataclass keeps the knobs flat for
ergonomics, but to_dict() nests the advanced knobs under "advanced"
to match the Rust wire format (and from_dict() reads them back from
there). No code change is required to set a knob — but note:
- The unused
projective_line_tol_relknob was removed. It was serialized into the advanced block but never read by the Rust detector, so it was a no-op. Drop the keyword from anyChessboardParams(...)call that set it. Serialized configs that still carry the key continue to deserialize (the extra key is ignored).
# before — projective_line_tol_rel had no effect and is now removed
params = ChessboardParams(cluster_tol_deg=9.0, projective_line_tol_rel=0.05)
# after
params = ChessboardParams(cluster_tol_deg=9.0)
FFI
The C ABI ct_chessboard_params_t keeps the stable fields directly and
gates the advanced knobs behind a has_advanced flag plus a nested
ct_chessboard_advanced_t. Initialise from
ct_chessboard_params_default_values before flipping has_advanced so
the advanced fields start from valid defaults, then regenerate against the
updated header.
2. min_corner_strength is a stable top-level field
min_corner_strength (the Stage-1 ChESS-response pre-filter) was promoted
out of the advanced knobs into the stable DetectorParams core. Its
serialized key stays top-level "min_corner_strength", so that one key is
wire-compatible with the previous flat layout — no migration needed for
configs that set it. Setting it on a nested params.chessboard (ChArUco /
PuzzleBoard / marker) keeps working.
#![allow(unused)]
fn main() {
// works the same before and after — now a documented stable field
let params = DetectorParams {
min_corner_strength: 0.5,
..DetectorParams::default()
};
}
3. Diagnostics moved behind a diagnostics cargo feature
The chessboard detector previously assembled a full per-stage DebugFrame
on every detect() / detect_all() call and then discarded it. That work
is now skipped on the hot path, and the diagnostics surface is opt-in.
-
calib-targets-chessboardgains adiagnosticscargo feature (OFF by default). It gates thediagnosticsmodule (DebugFrame,IterationTrace,StageCounts, the per-stage trace types,DEBUG_FRAME_SCHEMA) and theDetector::detect_with_diagnostics/detect_all_with_diagnosticsentry points. Without the feature these names are absent from the public API. -
The
calib_targetsfacade gains a matchingdiagnosticsfeature (OFF by default) that forwards tocalib-targets-chessboard/diagnosticsand gatesdetect_chessboard_with_diagnostics. -
The
datasetfeature impliesdiagnostics. The language bindings (Python, WASM, FFI) enablediagnosticsunconditionally, so their diagnostic entry points are unchanged.
Behaviour on the detect() path is byte-identical — the same labelled
ChessboardDetection.
Renamed entry point. The old
detect_chessboard_debug/detect_chessboard_debug_with_confighelpers are now the singledetect_chessboard_with_diagnostics, which takes the ChESSDetectorConfigexplicitly.
Rust
#![allow(unused)]
fn main() {
// before
let frame = detect_chessboard_debug(&img, ¶ms);
// after — requires the `diagnostics` feature on `calib-targets`
use calib_targets::detect::{default_chess_config, detect_chessboard_with_diagnostics};
let frame = detect_chessboard_with_diagnostics(&img, &default_chess_config(), ¶ms);
}
# Cargo.toml — turn the surface back on
calib-targets = { version = "...", features = ["diagnostics"] }
4. cell_size is back on ChessboardDetection
ChessboardDetection regained a stable cell_size: Option<f32> field,
populated on the normal detect() path with the seed-derived grid pitch.
The type stays #[non_exhaustive], so reading code is unaffected; code
constructing it by literal across crates must route through the
constructor + builder:
#![allow(unused)]
fn main() {
// constructing across crates (e.g. test fixtures)
let det = ChessboardDetection::new(corners).with_cell_size(31.4);
// reading
if let Some(pitch) = det.cell_size {
// grid pitch in pixels
}
}
The field is mirrored across the bindings:
- Python —
ChessboardDetectionResult.cell_size(float | None). - WASM —
cell_size: number | nullon the chessboard result. - FFI —
ct_chessboard_result_t.cell_sizeis now act_optional_f32_t(has_value == CT_TRUEcarries the pitch). The generated header was regenerated; recompile against it.
5. #[non_exhaustive] + named constructors
Workspace-wide, the public config / spec / report / result types are now
#[non_exhaustive] with named constructors. Reading fields is
unaffected. External code can no longer build these types with a struct
literal or match them exhaustively from another crate — route literal
construction through the constructor (and, where present, with_*
setters), and add .. to exhaustive patterns.
This is a pure API-surface change: detection behaviour, tuning defaults, and every serialized JSON shape are unchanged.
#![allow(unused)]
fn main() {
// before — struct literal across crates no longer compiles
let corner = LabeledCorner { position, score, grid: None, id: None, target_position: None };
// after — minimal `new` + builder setters
let corner = LabeledCorner::new(position, score).with_grid(grid);
}
Representative constructors (non-exhaustive list):
core:TargetDetection::new,LabeledCorner::new(+with_grid/with_id/with_target_position).chessboard:ChessboardDetection::new(+with_cell_size),ChessboardCorner::new.charuco:CharucoBoardSpec::new(+with_marker_layout),CharucoDetectConfig::new,CharucoDetectReport::new.puzzleboard:PuzzleBoardSpec::new(+with_origin),PuzzleBoardDetectConfig::new,PuzzleBoardDetectReport::new.marker:MarkerBoardSpec::new(+with_cell_size),MarkerCircleSpec::new,MarkerBoardDetectConfig::new.print:ChessboardTargetSpec::new,CharucoTargetSpec::new,PuzzleBoardTargetSpec::new,MarkerBoardTargetSpec::new.
When adding a field to one of these types later is a non-breaking change
because they are #[non_exhaustive] — that is the point of the policy.
6. Chessboard algorithm renamed to SeedAndGrow
The chessboard grid-build algorithm formerly called ChessboardV2 is now
SeedAndGrow — a name that describes what the pipeline does (find a
self-consistent 4-corner seed, then grow the grid outward) instead of its
development history. The companion Topological variant is unchanged, and
SeedAndGrow is still the default, so code that relies on the default is
unaffected. This is a clean break: the old chessboard_v2 spelling is
gone everywhere, with no compatibility alias.
Rust
#![allow(unused)]
fn main() {
use calib_targets::chessboard::{DetectorParams, GraphBuildAlgorithm};
// before
let params = DetectorParams {
graph_build_algorithm: GraphBuildAlgorithm::ChessboardV2,
..DetectorParams::default()
};
// after
let params = DetectorParams {
graph_build_algorithm: GraphBuildAlgorithm::SeedAndGrow,
..DetectorParams::default()
};
}
JSON config wire format
The serialized value changed from "chessboard_v2" to "seed_and_grow".
There is no alias, so a config that still sets "chessboard_v2" now fails
to parse — update it. Configs that omit the key (the common case) are
unaffected, since the default is seed_and_grow.
// before
{ "graph_build_algorithm": "chessboard_v2" }
// after
{ "graph_build_algorithm": "seed_and_grow" }
Python
# before
ChessboardParams(graph_build_algorithm="chessboard_v2")
# after
ChessboardParams(graph_build_algorithm="seed_and_grow")
FFI
The C ABI constant CT_GRAPH_BUILD_ALGORITHM_CHESSBOARD_V2 is now
CT_GRAPH_BUILD_ALGORITHM_SEED_AND_GROW (its value is unchanged, 0).
Update any references and recompile against the regenerated header.
WASM / TypeScript
The GraphBuildAlgorithm union type is now
"topological" | "seed_and_grow".
Verifying your migration
- A default detection’s serialized JSON is unchanged; if your pipeline
round-trips config or result dicts, the only shape change is the nested
"advanced"block (omitted when unset). effective_tuning()withadvancedunset is byte-identical toAdvancedTuning::default()— there is no silent behaviour drift from the split.- If a previously-tuned config stopped changing detection, you are almost
certainly hitting the flat→nested
advancedfallback (theme 1): move the knobs under"advanced".
Algorithms
This section documents the building-block algorithms the workspace’s detectors compose, one focused page per algorithm. Each page is target-independent: it describes the algorithm itself — its inputs, the math, and the invariants it guarantees — without committing to any one calibration target. The Pipelines section then shows how each target’s end-to-end detector chains these blocks together.
Read an algorithm page when you want to understand what a stage does and why it is correct; read a pipeline page when you want to understand how a particular target is detected end to end.
The blocks
| Algorithm | Crate / module | Role |
|---|---|---|
| ChESS corner detection | chess-corners (external front-end) | The feature-input contract: sub-pixel saddle position + two undirected local axes per corner. |
| Axis clustering | projective_grid::cluster | Recover the two global grid-direction centres {Θ₀, Θ₁} from per-corner dual axes. |
| Topological grid finder | projective_grid::topological | The sole grid builder: Delaunay → axis-driven edge classify → quad merge → flood-fill walk → component merge → lattice fit. |
| Recovery & validation | projective_grid::shared | Recall boosters (grow / fill / extension), the shared precision pass (drop_set), and grid-result normalization. |
| Homography & lattice fit | projective_grid::geometry | Normalized DLT projective fit; HomographyQuality as a diagnostic. |
| ArUco bit decode | calib-targets-aruco | Grid-aware bit sampling in rectified space, with explicit bit order / polarity / borderBits. |
| PuzzleBoard edge-code decode | calib-targets-puzzleboard | Decode interior edge-midpoint dots against the 501×501 master code. |
| ChArUco alignment & corner IDs | calib-targets-charuco | Grid-first + marker-anchored board alignment and absolute corner-ID assignment. |
How they relate
Every target detector starts from a cloud of ChESS corners, recovers the
two global grid directions by axis clustering, builds an integer
(i, j) lattice with the topological grid finder, repairs and proves
that lattice with recovery & validation (which uses homography &
lattice fit internally), and then — for self-identifying targets —
decodes target-specific marks (ArUco bits, PuzzleBoard edge
codes) and assigns absolute IDs (ChArUco alignment).
The first five blocks are target-agnostic and live in
projective-grid (image-free, no workspace dependencies) plus its
chessboard adapter; the last three are target-specific decoders.
Cross-cutting contracts
Two contracts hold across every block and shape the whole section:
- Precision is asymmetric. A missing
(i, j)label is acceptable; a wrong label is unrecoverable for downstream calibration. Every block that can attach a label runs an axis / parity / edge invariant, and the final precision pass can only drop, never add or relabel. - Corner orientation is axes-only. There is no single-orientation
field —
Corner::orientationwas removed workspace-wide. The only orientation signal isCorner.axes: [AxisEstimate; 2], two undirected local lattice directions. Any circular mean over axis angles MUST accumulate(cos 2θ, sin 2θ)and halve the resultingatan2; naive(cos θ, sin θ)averaging breaks at the 0°/180° seam.
ChESS corner detection
Front-end crate:
chess-corners(external). This page documents the feature-input contract the workspace builds on — not an algorithm this workspace re-implements.
Every detector in the workspace starts from a cloud of ChESS X-junction corners — the sub-pixel saddle points where four chessboard squares meet. Corner finding itself is out of scope here: the workspace consumes a corner cloud and recovers structure from it. What matters downstream is the precise shape of each corner, because the axis clustering and topological grid stages read it directly.
The per-corner contract
Each detected corner carries:
- Sub-pixel position — image-frame pixel coordinates, origin top-left, x right, y down. Not rounded to integer pixels.
- Two undirected local axes —
axes: [AxisEstimate; 2], the two orthogonal grid directions visible in the corner’s immediate neighbourhood. EachAxisEstimateis anangle ∈ [0, π)plus a 1σ angular uncertainty (sigma). - Quality scalars —
strength(the ChESS response magnitude),contrast(local light/dark separation), andfit_rms(the residual of the corner-model fit). These feed the prefilter: a corner is kept whenstrength ≥ min_corner_strengthandfit_rms ≤ max_fit_rms_ratio · contrast.
In the workspace’s shared types this corner is
calib_targets_core::AxisEstimate carried on a detector-specific input
type (e.g. calib_targets_chessboard::ChessCorner).
Orientation is axes-only
This is the load-bearing contract for everything downstream:
- There is no single-orientation field.
Corner::orientationwas removed workspace-wide and must never be reintroduced. The only orientation signal is the two-axis pair. - The two axes are undirected: an angle
θandθ + πdenote the same direction, so all axis comparisons work modulo π. - The axes are stored in fixed slots (
axes[0],axes[1]). The slot ordering encodes a local parity that adjacent chessboard corners flip — the topological grid finder and the chessboard wrapper’s parity discipline both depend on it. - A default-constructed / no-information axis carries
sigma = π(the no-info sentinel) and is filtered out before it can vote.
Circular means over axis angles must therefore accumulate
(cos 2θ, sin 2θ) and halve the resulting atan2. Doubling the angle
wraps θ and θ + π onto the same point, so the mean is stable across
the 0°/180° seam; naive (cos θ, sin θ) averaging collapses to zero
when votes straddle the wrap.
Orientation modes (DiskFit / RingFit)
The upstream detector exposes two axis-fitting modes, both still selectable through the facade / Studio / bench:
RingFitorders the two axis slots consistently by construction.DiskFitcan uniformly pick the wrong antipodal dark sector, reversing a corner’s(axes[0], axes[1])slot ordering relative to the board. The chessboard pipeline detects and repairs this globally in its axis-clustering stage (the slot-coherence repair), so aDiskFitswap never breaks the parity invariant.
Why the workspace stops at “corners in”
Corner finding is image processing; lattice recovery is geometry. Keeping the boundary here lets the geometry crates stay image-free and reusable: anything that can supply oriented point features — a different corner detector, a blob detector with a local-orientation estimate, a laser-dot extractor — can drive the same grid recovery. See The Grid Model for the generic feature shapes.
Cross-references
- Axis clustering — the first consumer of the dual-axis signal.
- The Grid Model — the generic
OrientedFeatureshapes ChESS corners are adapted into. - Conventions — the coordinate / orientation conventions in full.
Axis clustering
Code:
projective_grid::cluster(cluster_axes,AxisClusterCenters,AxisAssignment), re-exported at the crate root.
Axis clustering recovers the two global grid-direction centres
{Θ₀, Θ₁} (≈ 90° apart) from a set of features that each carry two
undirected local lattice axes (e.g. ChESS corners).
It is the orientation-prior stage every grid pipeline runs before
building a lattice: the two centres are the only global axis hint handed
to the topological grid finder, and they are
reused later for booster recovery so the clustering runs once.
The module is pure direction-clustering math — no image types, no target vocabulary. Which features are eligible to vote, and how the canonical/swapped assignment maps onto a caller’s own label type, stays caller-side.
Input / output
- Input: a slice of
AxisFeature, each carrying its twoAxisObservations(angle, sigma)and a detectorstrength. Axes whosesigmais the no-info sentinel (≥ π) or non-finite are skipped; callers pre-filter to the features they want to vote (the chessboard passes only itsStrongcorners). - Output:
AxisClusterCenters { theta0, theta1 }in[0, π)withtheta0 ≤ theta1.- A per-feature
AxisAssignment—Canonical(axes[0] matches Θ₀),Swapped(axes[0] matches Θ₁), orNoCluster(neither axis is close enough to either centre).
The algorithm
- Circular histogram. Build a smoothed histogram on
[0, π)withnum_binsbins. For every feature and every axisk ∈ {0, 1}, add a vote atwrap_pi(axes[k].angle)weighted bystrength / (1 + axes[k].sigma)— stronger, more-certain axes vote harder. - Smoothing. Convolve with a
[1, 4, 6, 4, 1] / 16circular kernel so single-bin noise does not masquerade as a peak. - Plateau-aware peak picking. Find local maxima; keep peaks whose
total weight is at least
min_peak_weight_fraction × total; pick the two strongest peaks separated by at leastpeak_min_separation_rad. “Plateau-aware” matters for a perfectly rectilinear board whose two axes land exactly on histogram-bin boundaries — a naive argmax would split one true peak into two adjacent bins. - Double-angle 2-means refinement. Refine the two peak centres with
k-means (k = 2) in double-angle space — each axis angle is mapped
to
(cos 2θ, sin 2θ)before clustering, and the cluster means are halved back into[0, π). This is the same undirected-mean discipline the whole workspace uses; it makes the refinement stable across the 0°/180° seam.
Per-feature slot assignment
Once {Θ₀, Θ₁} are fixed, each feature is scored against the two
possible slot assignments:
- Canonical — cost
d(axes[0], Θ₀) + d(axes[1], Θ₁). - Swapped — cost
d(axes[0], Θ₁) + d(axes[1], Θ₀).
The cheaper assignment wins; a feature whose worse axis exceeds the
caller’s tolerance is labelled NoCluster and excluded from voting on
edges. All distances d are angular and computed modulo π.
Why double-angle, not naive circular mean
Axes are undirected, so a histogram vote at θ is equally a vote at
θ + π. A naive circular mean over raw (cos θ, sin θ) of two votes
180° apart sums to zero — the mean is undefined exactly where it
matters most. Doubling the angle folds θ and θ + π onto the same
point on the unit circle, so the mean is well-defined; halving the result
recovers the undirected direction. This contract is mandatory anywhere
the workspace averages axis angles.
Cross-references
- ChESS corner detection — the source of the
dual-axis votes, and the
DiskFitslot-flip the chessboard repairs after clustering. - Topological grid finder — the consumer of the two centres (as an optional per-corner usability gate).
- Recovery & validation — reuses the same
(features, centres)pair for booster recovery.
Topological grid finder
Code:
projective_grid::topological(reached viadetect_grid_all— the sole grid builder, selected byLatticeKind+Evidence, with no algorithm enum). In-repo deep-dive:docs/algorithms/topological-grid-detection.md.
The topological grid finder is the sole grid builder in the
workspace. Given a cloud of oriented features (positions + two undirected
local axes each) and the two global grid directions from
axis clustering, it recovers an integer
(i, j) corner lattice without ever sampling the image again. Every
target type — chessboard, ChArUco, PuzzleBoard, marker board — routes
through this one path.
It is the Shu / Brunton / Fiala (2010) topological grid finder, with the paper’s image-color cell test replaced by an axis-alignment test so the core stays image-free and tolerant of perspective and radial distortion.
Historical note. An earlier
SeedAndGrowbuilder once coexisted with this one behind aGraphBuildAlgorithmselector. It has been removed;GraphBuildAlgorithmis now a single-variant,#[non_exhaustive]enum (Topological) retained only so the config schema stays stable if a future alternative builder is added. There is no algorithm choice to make.
Vocabulary
- Grid edge — a link between two corners that runs along the lattice (a true cell side).
- Diagonal edge — a link that crosses a cell corner-to-corner. Delaunay introduces one per cell; the pipeline identifies and removes it.
- Spurious edge — a link that is neither a cell side nor a cell diagonal (a triangulation artefact).
- Quad — four corners forming one lattice cell, bounded by four grid edges.
- Axis slot — each corner stores its two axes in fixed slots
(
axes[0],axes[1]); axes are undirected, compared modulo π.
Stages
The generic, image-free core runs these stages (full source under
crates/projective-grid/src/detect/square/topological/):
- Axis cache + usability prefilter. Precompute each feature’s two
axis angles and an informative flag per slot (an axis is informative
when its
sigmais belowmax_axis_sigma_rad). A feature is usable when at least one slot is informative — and, if the optional cluster centres are supplied, when at least one informative axis lies withincluster_axis_tol_radof one global grid direction (modulo π). - Delaunay triangulation. Triangulate only the usable features to get a cheap, well-conditioned candidate-neighbour graph without committing to a prior cell size — important because cross-cluster nearest-neighbour distances are unreliable on boards with markers.
- Edge classification (Grid / Diagonal / Spurious). For each
Delaunay half-edge
a → b, compare the edge directionatan2(b − a)(modulo π) to each endpoint’s informative axes. The edge is a Grid edge when both endpoints see it withinaxis_align_tol_radof one of their own axes; otherwise it is provisionally Spurious. Diagonals are then promoted topologically: a triangle with exactly two Grid edges meeting at a shared vertex through different axis slots has its third edge promoted to Diagonal. Crucially, diagonals are not found by a fixedaxis ± π/4rule — under a projective warp a projected diagonal is not the angle bisector in image space. - Triangle-pair → quad merge. A triangle with exactly one Diagonal edge is fused with its neighbour across that diagonal; removing the shared diagonal yields a quadrilateral whose four edges are all Grid edges — one lattice cell, ordered clockwise (image y-down) from its top-left vertex.
- Quad filtering. Three gates: a topological mesh-degree gate
(drop junction artefacts with too many incident edges), an
opposing-edge ratio gate (reject extreme parallelograms), and a
per-component cell-size band (drop quads with any edge outside
[min, max] × component-median, computed per connected component so two boards at different scales coexist). - Topological walk (flood-fill). Each connected quad-mesh component
is labelled independently: a seed quad gets
(0,0),(1,0),(1,1),(0,1)clockwise, and labels propagate across shared edges. A component is dropped if two quads ever disagree on a corner’s label. Each component’s(i, j)bbox is rebased so its minimum is(0, 0). - Per-component validation + projective fit (generic). A
pattern-agnostic geometry gate (line collinearity, local-homography
residual, edge-length band) plus a projective fit with a residual gate.
The chessboard wrapper disables this stage (pushes its tolerances
to
+∞) because it owns its own mandatory geometry check downstream; the core is asked only for labelled components. - Orchestration. Component solutions are sorted by labelled-corner
count (ties broken by smallest source index, for determinism). Every
unplaced feature is collected into a global rejected/unlabelled set.
detect_gridreturns the largest component;detect_grid_allreturns all of them.
Hex lattices
The same algorithm serves a hexagonal point lattice: on a hex lattice the
Delaunay triangles are the unit cells, so the diagonal/quad-merge stage
is bypassed and the axial (q, r) walk runs directly. The projective-fit
back-half is shared with the square path.
Why axis alignment, not pixel colour
The paper decides what counts as a lattice edge by sampling the image between two corners and checking the light/dark cell pattern. Replacing that with an axis-alignment test makes the core image-free and distortion-tolerant: a grid edge is exactly the link both endpoints agree runs along one of their own local axes, and a cell diagonal is recognised by the local “two grid edges through different slots” rule rather than by any global angle. The classifier only checks that an edge aligns with some endpoint axis, not the parity-correct one — the chessboard wrapper adds parity discipline in recovery & validation.
Known limits
- Three-corner cells are not recovered as quads. The merge needs a complete cell (two triangles sharing a diagonal); one missing corner per cell starves the surrounding flood-fill. The downstream booster recovery fills single interior holes from local geometry.
- Delaunay is not projective-invariant. Severe perspective combined with radial distortion can make a Delaunay triangle span more than one physical cell, leaving cells the diagonal-inference rule cannot resolve.
- Axis quality is load-bearing. Every classification decision rests on per-corner axis estimates; low-resolution or noisy inputs can fail before the topology has enough reliable evidence.
- Marker-internal corners can poison the per-cell axis test. Because the classifier checks alignment with some endpoint axis, a corner detected inside a marker bit whose axes happen to match the grid directions can be admitted. The marker-bearing targets defend against this with a strength floor that cuts marker-internal saddles before the grid grows — see the ChArUco pipeline.
Cross-references
docs/algorithms/topological-grid-detection.md— the generic core in full, stage by stage, with the clean line betweenprojective-gridand the chessboard adapter.- Axis clustering — supplies the two global grid directions used by the usability prefilter.
- Recovery & validation — the chessboard-specific component merge, parity alignment, recall boosters, and mandatory precision pass that run after this core.
- The Grid Model — the public detection surface
(
Evidence,detect_grid/detect_grid_all,GridSolution).
Recovery & validation
Code:
projective_grid::shared(merge_components_local, thefill/grow/extensionboosters, thevalidateprecision pass, andLabelledGrid::normalize).
The topological grid finder stops at labelled
(i, j) components. Recovery & validation is the back half that turns
those raw components into a precision-safe, normalized grid. It does three
jobs, in this order:
- Recovery boosters — recover corners the topological walk missed.
- The precision pass (
drop_set) — prove the labelled set, dropping anything that slipped through. - Normalization — rebase, canonicalize, and sort the final grid.
These live in projective-grid so any lattice consumer can reuse them;
the chessboard wrapper sequences them and supplies the parity discipline.
Recovery boosters (recall, can only add)
The boosters extend a component to recover missed corners. Each addition re-runs the same axis / parity / edge-slot-swap invariants the topological walk uses, so a booster can only add a corner that would have been admitted had the walk reached it:
- Local component merge (
merge_components_local) — reunite disconnected components in label space using local geometry only (no global homography), so it tolerates radial distortion that would break a global fit. Run before and after the fill/extension boosters. - Interior gap fill + line extrapolation (
fill_grid_holes) — fill interior holes with ≥ 3 labelled neighbours, and extend each labelled row / column one corner at a time. A per-axis directional edge scale is used because a partially-grown component can be anisotropic before its boundaries fill in. - Extension (the
extensionsubmodule) — homography-based outward extension, global or per-candidate local-H. - Weak-cluster rescue (caller-driven) — re-admit
NoClustercorners within a loosened tolerance, with the full invariant stack still enforced.
Boosters are capped by an iteration limit to prevent unbounded growth.
The precision pass: drop_set
The precision pass is mandatory and subtractive — it can only drop or refuse, never add or relabel. A corner that survives it has been proven to sit at a real intersection. It composes these checks:
- Line collinearity. For every row (
j = const) and column (i = const) with enough members, fit a line in pixel space (a projective-line fit when there are enough members, to absorb mild lens distortion) and flag members whose perpendicular residual exceeds the tolerance. - Local-H residual. For every labelled corner with ≥ 4 non-collinear labelled neighbours, fit a 4-point local homography from the grid-closest neighbours, predict the corner’s pixel position, and flag a residual over tolerance. Tolerances can be step-aware: per-corner local step from finite differences, so foreshortened cells get a tighter pixel tolerance and radially-distorted cells a looser one.
- Topological wrong-label checks. Direct structural checks that catch mislabels the line/H residuals can miss: interior skipped-corner edges, duplicate-pixel labels, and a frontier line-spacing smoothness test. The frontier test is second-order and distortion-model-agnostic: under any smooth (C²) lens distortion the edge-length sequence along a grid line is a smooth function, so a kink in that sequence at the frontier is a false attachment, not a legitimately foreshortened corner — it is flagged regardless of the absolute edge length.
- Largest-component filter. Keep only the largest cardinally-connected component, dropping isolated leaks outside the main grid.
Why not a global smooth-warp residual gate. A natural-seeming addition would be to fit a single low-order
(i, j) → pixelwarp over the whole labelled set and drop high-residual corners. This was investigated and falsified: a global low-order fit extrapolates almost exactly through a false leaf one cell past the true board edge (giving it a tiny residual), while it fits the interior so tightly that legitimately barrel-distorted periphery corners get large residuals — the gate is simultaneously too loose and too tight. The discriminating signal for that false-positive class is the local second-order spacing kink, which a global fit averages away — hence the frontier line-spacing smoothness check above rather than a global-warp gate.
The attribution logic decides which flagged corner is the outlier (e.g. a corner flagged in ≥ 2 lines is the outlier; an isolated local-H flag with no supporting line evidence is deferred rather than dropped), so the pass blames the genuine intruder rather than its innocent neighbours. After updating the drop set the caller re-runs its seed/grow/validate loop, capped to prevent infinite cycling.
Why “can only subtract” is the whole contract. Wrong
(i, j)labels are unrecoverable for downstream calibration; missing corners are acceptable. A precision pass that could add a label could add a wrong one. By construction this pass never does — it is the last gate that makes a false positive impossible.
Normalization: LabelledGrid::normalize
Grid-result normalization is owned by projective_grid::LabelledGrid::normalize
— a single source of truth that target detectors call instead of
re-implementing it at their output stage. Three steps, in order:
- Rebase the coordinate bbox minimum to
(0, 0)so every label is non-negative (the hard non-negative-label invariant for overlay / calibration consumers). - Canonicalize orientation so the first lattice axis (
u) points roughly+x(right) and the second (v) roughly+y(down) in image pixels. The grid finder assigns(u, v)from its internal axis-slot convention, which has no relation to image orientation; this step decides the permutation / sign-flip from the averaged step vectors over adjacent labelled pairs. Positions are never modified — only labels are permuted. - Sort entries by
(v, u)for a stable output order, and recompute the bbox.
Because normalize permutes labels, any LatticeFit computed against the
pre-normalization labels is invalid afterwards — normalize before
fitting, or refit.
Cross-references
- Topological grid finder — produces the raw components this stage repairs and proves.
- Homography & lattice fit — the projective fit used inside the local-H residual check and the final fit.
- Axis clustering — the
(features, centres)pair reused by the boosters. - Chessboard pipeline — how the chessboard detector sequences merge → parity-align → boost → precision pass → normalize.
Homography & lattice fit
Code:
projective_grid::geometry(Homography,estimate_projective,homography_from_4pt,apply_projective,HomographyQuality).
A planar calibration target maps to the image through a single projective
transform (a homography) — up to lens distortion. Several stages need to
fit that transform: the recovery & validation
local-H residual check fits a 4-point homography per corner, and the final
lattice fit recovers the model-plane-to-image transform reported in
GridSolution::fit. This page describes that fit.
The fit
- Normalized DLT.
estimate_projectivesolves for the homography fromN ≥ 4model→image point correspondences via the Direct Linear Transform with Hartley normalization — each point set is translated and scaled so its centroid is at the origin with unit average distance before the SVD, then the result is denormalized. Normalization is what keeps the linear system well-conditioned; an un-normalized DLT degrades badly when image coordinates are large. - Direct 4-point.
homography_from_4ptsolves the exact minimal-case transform from four correspondences — used for the local per-corner predictions where exactly four neighbours bracket a corner. - Mapping.
apply_projectivemaps a model-plane point through the fitted transform to image pixels (and is the prediction step in the validation residual checks).
The standalone geometry kernel stays generic over F: Float
(f32 / f64), so a future f64 calibration consumer can reuse it; the
detection surface itself is pinned to f32.
Residuals are the precision gate
The fit reports a ResidualSummary (count, mean_px, max_px). The
residual — the pixel distance between each labelled corner’s measured
position and its reprojection through the fit — is the precision gate:
corners whose residual exceeds max_residual_px are dropped and the
transform is refit once. A sub-pixel mean residual on a recovered grid is
the signal that the labelling is geometrically self-consistent.
HomographyQuality is diagnostic-only
HomographyQuality (returned by the *_with_quality estimators) reports
conditioning / fit-quality scalars for a fitted homography. It is a
diagnostic, not a scale-stable gate — its magnitudes are not normalized
to a scale-invariant range, so it is unsuitable as an accept/reject
threshold across images at different pixel scales. Use the per-corner
reprojection residual (which is scale-relative, measured in pixels
against the cell pitch) as the gate, and treat HomographyQuality as a
debugging aid only.
This mirrors the workspace-wide rule against first-order magnitude thresholds: prefer scale-relative or structural criteria. A raw conditioning number that “works on the data it was measured on” is exactly the kind of non-generalizable constant the precision contract avoids.
A note on distortion
A single homography assumes a planar target with no lens distortion. Real captures carry radial / tangential distortion, which a global fit cannot absorb. That is why the recovery stage prefers local geometry (per-cell local-H, local component merge) over a global fit wherever it can: local fits tolerate smooth distortion that a global homography rejects. The global fit is still computed and reported, but the distortion-tolerant precision checks are what protect the labels.
Cross-references
- Recovery & validation — the consumer of both the 4-point local-H predictions and the final fit.
- The Grid Model —
GridSolution::fitand theLatticeFit/ResidualSummaryoutput shapes. - calib-targets-core — the core
Homography/RectifiedViewrectification helpers built on the same DLT.
ArUco bit decode
Code:
calib-targets-aruco(Dictionary,Matcher,ScanDecodeConfig,scan_decode_markers,scan_decode_markers_in_cells,decode_marker_in_cell).
ArUco bit decode reads a marker’s binary code out of an already-located chessboard cell and matches it against a dictionary. It is deliberately grid-aware, not generic contour/quad detection: the grid stage has already found where every cell is, so the decoder samples the expected cell in rectified space and reads bits on a regular grid. This sidesteps the quad-finding and perspective-recovery steps a standalone ArUco detector spends most of its time on.
Inputs
The decoder works on rectified cells where each chessboard square is
approximately px_per_square pixels and cell indices align with the
board grid. Two paths supply that:
- Rectified-grid scan (
scan_decode_markers) — build a single rectified image of the board, then scan a regular grid of cells. - Per-cell scan (
scan_decode_markers_in_cells) — pass a list of per-cell image quads and decode each cell directly, with no full-image warp. This is the path the ChArUco detector drives; the work is proportional to the number of valid cells and parallelises trivially.
Bit sampling model
Inside each candidate cell:
- The marker area is
marker_size_relof the square side (ChArUco uses< 1.0), with an extrainset_fracinset to keep the bit grid off a thick or blurred border. - Bits are sampled on a regular grid spanning the marker area.
- A per-marker Otsu threshold is computed from the sampled intensities, so the decode adapts to local lighting.
- The surrounding black border ring is scored; cells whose border
score is below
min_border_scoreare rejected before a dictionary lookup is attempted.
Explicit bit conventions
These three conventions are explicit in the code and must match the printed board exactly:
- Bit order — codes are packed row-major.
- Polarity — black = 1.
border_bits— the number of whole black border cells, matching the OpenCV definition (typically 1).
Dictionary matching
Matcher brute-forces the sampled code against every dictionary entry
under the four 90° rotations, returning the best match with its
rotation ∈ 0..=3 (such that observed == rotate(dict_code, rotation))
and a Hamming distance. The rotation is what lets the decoder normalise a
marker seen at any orientation; the Hamming distance feeds the
per-corner score. dedup_by_id keeps only the best detection per
dictionary ID across cells.
Why grid-aware, not contour-based
A generic ArUco detector finds quads in the raw image, recovers each marker’s perspective, then decodes. Here the topological grid finder has already recovered the whole board’s lattice to sub-pixel precision, so the marker’s cell quad — and its rectification — come for free. Decoding becomes a local bit-read with an adaptive threshold, which is both faster and more robust to the partial / blurred markers a contour detector would miss.
Cross-references
- ChArUco alignment & corner IDs — the consumer of decoded marker IDs.
- ChArUco pipeline — the end-to-end target detector.
- calib-targets-aruco crate and ArUco Decoding Details — the crate’s API surface and sampling knobs.
PuzzleBoard edge-code decode
Code:
calib-targets-puzzleboard(edge sampling + the(D4, origin)sweep). Based on Stelldinger 2024, arXiv:2409.20127.
A PuzzleBoard is a self-identifying chessboard: every interior edge carries a midpoint dot, and the dot pattern uniquely identifies any ≥ 4×4 fragment’s position on a fixed 501×501 master code. Edge-code decode turns the visible dots into an absolute position on that master, so a partial view still produces absolute corner IDs and object-space coordinates.
The master code
The board uses two embedded cyclic maps, committed as binary blobs so the runtime detector constructs nothing:
- map A, shape
(3, 167), for horizontal interior edges. - map B, shape
(167, 3), for vertical interior edges.
Dots encode bits directly: white dot = 0, black dot = 1. Around a
corner (i, j) the four incident interior edges read:
corner (i,j) ---- A(j,i) ---- corner (i+1,j)
| |
B(j,i) B(j,i+1)
| |
corner (i,j+1) -- A(j+1,i) -- corner (i+1,j+1)
The maps are cyclic of period 501, so any sufficiently large window of edges pins a unique master origin — the paper’s uniqueness property.
Stages
- Edge sampling. For each interior edge of the detected grid, sample
a disk of radius
sample_radius_rel × edge_len(min 1 px) at the edge midpoint, derive local bright/dark references from the two adjacent cells, and classify the midpoint intobit ∈ {0, 1}with a confidence∈ [0, 1]proportional to how far the midpoint sits from the reference mid-level. - Confidence filter. Drop bits below
min_bit_confidence; a low-confidence bit becomes “unknown” rather than a guessed 0/1. - Minimum-edges gate. Require enough surviving edges for at least a
min_window × min_windowfragment (min_window² ≥ 4²is the paper’s uniqueness floor for the 501×501 code). A sparse grid fails here immediately. - Origin sweep. Find the best
(D4 rotation, master_origin_row, master_origin_col)hypothesis. This is a two-axis choice:- Search scope —
Fullenumerates all8 × 501 × 501hypotheses against the master maps;FixedBoardscans only8 × (rows+1)²hypotheses against a declaredPuzzleBoardSpec’s own bit pattern (much cheaper, and it sidesteps the per-view origin drift described below). - Scoring —
HardMajoritymajority-votes the bits and gates on a bit-error-rate threshold (max_bit_error_rate);SoftLogLikelihoodsumslog_sigmoid(κ × bit_confidence × ±1)per bit (clipped to a floor), picks the max, and tracks a(best − runner-up)margin for ambiguity gating. The soft mode is more robust on ambiguous / near-symmetric fragments.
- Search scope —
- Best-component selection. When several disconnected grid components decode, rank them by edges-matched, then BER, then soft score. Conflict detection: two well-supported components that disagree on the master origin are an unrecoverable ambiguity and are refused rather than guessed.
The partial-view guarantee
For a given printed board, any subset of its corners decodes to the same master IDs a full-view decode would produce. This holds across single-camera captures that frame only part of a large board and across multi-camera rigs where each camera sees a different fragment — in both cases overlapping corners share master IDs without further stitching.
The per-view master origin is otherwise not fixed: it shifts with which
print-corner the chessboard stage picked as local (0, 0), which depends
on what the camera saw. FixedBoard sidesteps that by scoring against the
declared board rather than the full master.
Decoder-design note
The naive hard-bit decoder + 501² × D4 exhaustive sweep + hard BER gate
already clears precision and recall at zero wrong labels on the workspace
regression set. A coherent-hypothesis matcher upgrade is deferred —
do not pre-emptively rewrite without a concrete precision gap demonstrated
on a new dataset.
Cross-references
- PuzzleBoard pipeline — the end-to-end target detector (chessboard grid + this decoder).
- calib-targets-puzzleboard crate — the crate’s API, search modes, and printable examples.
- Topological grid finder — the upstream grid whose interior edges this decoder samples.
ChArUco alignment & corner IDs
Code:
calib-targets-charuco(the marker matcher + alignment + corner mapping stages). Layout-compatible with OpenCV’s aruco/charuco.
A ChArUco board carries an ArUco marker in every white square. Alignment recovers the board→image transform from the sampled marker cells and then assigns each chessboard inner corner its absolute, OpenCV-compatible corner ID. This is what makes ChArUco robust to partial views: even a handful of identifiable markers anchor the whole detected grid to the board’s canonical frame.
Inputs
- The labelled chessboard grid from the topological grid finder (one or more components).
- Per-cell sampled marker bits (the candidate marker cells extracted from the grid).
- The board specification (
CharucoBoardSpec): rows, cols, dictionary, marker layout.
Alignment — the board-level matcher
The detector uses a single, board-level matcher that chooses the whole
board placement jointly rather than decoding each cell independently and
then voting. It solves one question: which board placement — a D4 rotation
together with an integer (Δcol, Δrow) translation on the grid — is most
consistent with all the sampled cells at once.
-
Per-cell × per-marker score matrix. Each candidate cell is sampled into a small bit grid. For every board marker
m(and each of its four rotations) the matcher accumulates a soft-bit log-likelihoodΣ_bits max(log_sigmoid(κ · sign · (otsu − mean)/255), per_bit_floor), wheresign = ±1is the marker’s expected bit andκ(bit_likelihood_slope) sets the per-bit confidence. Theper_bit_floorclip stops a single wildly-wrong bit from dominating a cell’s score. Each cell also gets a weight, attenuated toward zero when its border did not read as black (cell_weight_border_threshold). -
Hypothesis enumeration. For each of the four D4 rotations the matcher maps the observed cells onto the board and enumerates exactly the integer translations that keep every cell inside the board. Each
(rotation, translation)hypothesis scoresΣᵢ wᵢ · sᵢ(m_{p_i(H)})— the weighted score of the marker each cell would contain under that placement. A hypothesis with no contributing cells is rejected (it would otherwise score zero and beat genuine negative-log-likelihood evidence). -
Maximum-likelihood placement + margin gate. The matcher keeps the best and runner-up hypotheses and computes the relative margin
(best − runner-up)/max(|best|, |runner-up|). The placement is accepted only when that margin clearsalignment_min_margin; below it, detection is rejected rather than mislabelled — heavy bit noise or a near-symmetric layout produces a near-tie, and a coin-flip alignment would risk a wrong ID. -
Constrained re-emit. Under the chosen placement every cell has a single expected marker. The matcher re-emits each cell’s marker under that identity, so a returned marker can never disagree with the alignment it was matched against — the wrong-id count is zero by construction.
The brute-force hypothesis space is tiny (four rotations × a bounded translation window), so the joint search is cheap. Because the decision is made over all cells jointly, the matcher tolerates per-cell bit noise that would defeat an independent hard decode, which is what lets it recover blurred, tiny-marker, and large-board frames.
The accepted alignment must still clear the downstream inlier floors
min_marker_inliers (primary component) / min_secondary_marker_inliers
(non-primary components); because the margin gate already does the real
accept/reject work, for_board keeps these floors low.
Corner-ID assignment
With the alignment fixed, each board-spec inner-corner position is mapped through the transform into the image and matched to a detected chessboard corner. Only inner-cell intersections receive IDs — the marker corners themselves are not emitted. Each emitted corner carries:
- its absolute ChArUco
id(identical to OpenCV’sCharucoBoardnumbering), - a
target_positionin board units (mm whencell_size > 0), - the sub-pixel
positionfrom the chessboard stage.
A final corner validation pass checks each detected corner against its
marker-predicted seed; a corner that deviates beyond
corner_validation_threshold_rel × px_per_square triggers a
marker-constrained redetection or is dropped. This is the marker-aware
half of ChArUco’s precision: the chessboard layer already guarantees no
wrong (i, j) labels, and marker-ID consistency guards the ID assignment
on top.
Why marker-anchored, not grid-only
A bare chessboard grid has no canonical origin — the detector does not
know which physical corner is (0, 0). The markers break that symmetry:
because each marker ID is unique and tied to a known board cell, even a
partial view recovers the absolute board frame, so corners from
different frames or cameras share IDs without manual stitching. This is
the same anchoring idea the marker board achieves
with three reference circles, but with per-cell IDs instead of a 3-point
pose.
Cross-references
- ArUco bit decode — supplies the decoded marker IDs and confidences.
- ChArUco pipeline — the full end-to-end stage map.
- calib-targets-charuco crate and ChArUco Alignment and Refinement — the crate’s API and refinement pass.
Pipelines
This section documents each target’s complete, end-to-end detection pipeline — one page per target type. Where the Algorithms section describes each building block in isolation, a pipeline page shows how a particular target composes those blocks from a grayscale image (or a pre-detected corner cloud) to a labelled, ID-carrying detection.
Each pipeline page narrates and links the canonical stage map that
lives next to the code. The crate-level docs/PIPELINE.md files are the
source of truth; these pages mirror them and must not diverge.
The shared front-end
Every detector shares the same first three steps:
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ Image │ -> │ ChESS │ -> │ Target- │ -> │ Labelled │
│ (u8 gray) │ │ corners │ │ specific │ │ grid out │
└───────────┘ │ (front- │ │ detector │ │ │
│ end) │ │ │ │ │
└───────────┘ └───────────┘ └───────────┘
- Input image —
image::GrayImageor aGrayImageView. The facade helpers incalib_targets::detectaccept either. - Corner front-end — the ChESS X-junction
detector via the
chess-cornerscrate produces a raw corner cloud (sub-pixel position + two undirected axes + strength / contrast / fit_rms). The workspace default iscalib_targets::detect::default_chess_config(). - Grid recovery — every target then runs the same grid stack: axis clustering → the topological grid finder → recovery & validation. This is the chessboard pipeline, and it is the shared spine of all the others.
- Target-specific decode + IDs — self-identifying targets add their own decoder (ArUco bits, PuzzleBoard edge codes) and ID assignment on top of the recovered grid.
- Output — every detector produces a
TargetDetectionwrapping aVec<LabeledCorner>; higher-level detectors wrap that in their own result struct with extra metadata (marker decodes, alignment, IDs). See Understanding Results.
The pages
| Pipeline | Composes | Source of truth |
|---|---|---|
| Regular grid | clustering + topological grid + validation | docs/algorithms/topological-grid-detection.md |
| Chessboard | the full grid stack, precision-anchored | crates/calib-targets-chessboard/docs/PIPELINE.md |
| PuzzleBoard | chessboard grid + edge-code decode | crates/calib-targets-puzzleboard/docs/PIPELINE.md |
| ChArUco | chessboard grid + ArUco decode + alignment | crates/calib-targets-charuco/docs/PIPELINE.md |
| Marker board | chessboard grid + 3-circle anchoring | crates/calib-targets-marker/docs/PIPELINE.md |
One builder, everywhere
There is no grid-builder choice to make. GraphBuildAlgorithm is a
single-variant, #[non_exhaustive] enum (Topological) retained only as
a reserved config seam; the topological grid finder is the sole builder
for every target, including ChArUco. A config that carries a legacy value
is re-pinned to Topological on load.
Output types
Output types are standardised in calib-targets-core as TargetDetection
with LabeledCorner values. The chessboard layer’s labelling carries the
precision contract every target inherits: wrong (i, j) labels are
unrecoverable for downstream calibration, so the grid stage may fail to
detect a corner but must never deliver a wrong label. Higher-level crates
enrich that output with additional metadata (marker detections, rectified
views, per-corner IDs).
Regular grid pipeline
Composes: axis clustering → topological grid finder → recovery & validation. Source of truth:
docs/algorithms/topological-grid-detection.md. Public surface: The Grid Model.
The regular grid pipeline is the target-free end-to-end path: a cloud
of oriented point features in, a labelled (i, j) lattice out, with no
image and no calibration vocabulary. It is the standalone
projective-grid crate, and it is the spine every
target detector builds on. Reach for it directly when you have a grid that
is not one of the workspace’s named targets — a laser-dot cloud, a
scanned form, a photographed board game.
End-to-end stages
OrientedFeature<2>[] (positions + two undirected axes each)
→ axis clustering recover global directions {Θ₀, Θ₁} (optional hint)
→ topological grid Delaunay → classify → quads → walk (the builder)
→ validation + fit line / local-H / residual gate
→ GridSolution labelled (i, j) component(s) + projective fit
- Axis clustering (optional). If the caller can supply the two global
grid directions, they gate the topological usability prefilter. For the
bare
projective-gridentry points the caller may skip this and let the detector synthesize axes from neighbour geometry. - Topological grid finder. The sole builder: Delaunay triangulation →
axis-driven edge classification → triangle-pair → quad merge →
flood-fill
(i, j)walk → orchestration into components. See the algorithm page for each stage. - Per-component validation + projective fit. A pattern-agnostic
geometry gate (line collinearity, local-H residual, edge-length band)
plus a projective fit with a
max_residual_pxgate. For the bare grid crate this stage is active (it is the precision gate); the chessboard wrapper disables it and substitutes its own mandatory geometry check. - Output. A
GridSolutionper component —grid: LabelledGrid,fit: Option<LatticeFit>,rejected: Vec<RejectedFeature>.
Public surface
The detection input is the Evidence enum — it names exactly how much
orientation the caller can supply (Positions, Oriented1, Oriented2,
Oriented3). The native square shape is Oriented2; less-oriented kinds
synthesize the missing axes up front. detect_grid returns the largest
component; detect_grid_all returns all of them. A separate
check_consistency entry point scores pre-labelled features against a
single projective fit. The full surface — DetectionRequest,
GridSolution, RejectedFeature, and the worked example — is documented
in The Grid Model and the
Regular Grid Detection example.
Hex lattices
The same pipeline detects a hexagonal point lattice on the topological
path: the Delaunay triangles are the unit cells, so the
diagonal/quad-merge stage is bypassed and the axial (q, r) walk runs
directly, with the projective-fit back-half shared.
Failure modes
| Symptom | Likely stage | What it means |
|---|---|---|
GridError::InsufficientEvidence | input | Too few features to assemble a 2×2 seed cell. |
GridError::DegenerateGeometry | input | Coincident or collinear points; no usable lattice spread. |
GridError::UnsupportedCombination | dispatch | The (lattice, evidence) pair has no algorithm (e.g. Hex + Oriented1). Returned rather than guessed. |
Few entries, many Unlabelled rejects | topological walk | Noisy or low-resolution axes — the classifier could not build enough confident grid edges. |
ValidationDropped rejects | validation | Placed by the walk but failed line / local-H / edge-band; a gross mislabel was caught. |
ResidualTooHigh rejects | fit | Reprojection residual over max_residual_px; loosen only if the geometry is genuinely distorted. |
Tuning
For the bare grid crate, tuning is DetectionParams:
max_residual_px— the fit residual gate. Raise on genuinely distorted captures; it is the precision lever, so prefer the smallest value that still recovers the grid.topologicalsub-config — the axis / quad / cell-size-band tolerances of the topological finder.validatesub-config — the line / local-H tolerances of the active validation stage.
When this pipeline runs inside a target detector, these knobs are mostly set by the wrapper (the chessboard wrapper disables the validate stage and owns its own checks); see Tuning the Detector.
Cross-references
- The Grid Model — the full public surface and a worked example.
- Chessboard pipeline — the same spine with the chessboard precision discipline layered on.
docs/algorithms/topological-grid-detection.md— the generic core in full.
Chessboard pipeline
Composes the full grid stack: ChESS corners → axis clustering → topological grid → recovery & validation. Source of truth:
crates/calib-targets-chessboard/docs/PIPELINE.md. Crate reference: The Chessboard Detector.
The chessboard detector takes a cloud of ChESS X-junction
corners and produces an integer-labelled grid
(i, j) → image position. It is the shared spine of every other
target pipeline, and it is precision-anchored: every stage that can
attach a label runs an axis / parity / edge invariant, and the mandatory
final geometry check drops anything that slipped through. Wrong (i, j)
labels are unrecoverable for downstream calibration; missing corners are
acceptable — that asymmetry is the whole contract.
The six stages
The orchestrator is pipeline::detect_all_topological. The canonical
stage map (mirror of the crate docs/PIPELINE.md):
| # | Stage | In → Out | What it does |
|---|---|---|---|
| 1 | prefilter | ChESS corners → usable-flagged corners | Keep a corner iff strength ≥ min_corner_strength and fit_rms ≤ max_fit_rms_ratio · contrast. Weak corners are kept as positions with no-information axes (so indices stay stable) but cannot vote. |
| 2 | cluster_axes | strong corners’ axes → {Θ₀ ≤ Θ₁} + per-corner slot label | The generic axis clustering (histogram + plateau peak picking + double-angle 2-means), then the DiskFit slot-coherence repair (below). |
| 3 | topological_grid | oriented features + cluster centres → labelled components | The topological grid finder (detect_grid_all); its own post-build validation / residual / recovery are disabled — the chessboard owns those downstream. |
| 4 | recover_components | merged components → boosted, re-merged grid | Per-component cell-size estimate, then the recovery boosters (interior gap fill + line extrapolation with a per-axis directional edge scale), optional weak-cluster rescue, then merge_components_local. Every addition re-runs the axis / parity / edge-slot-swap invariants. |
| 5 | final_geometry_check | labelled set → drop list + refuse flag | Mandatory, can only DROP. The shared drop_set precision pass: line collinearity + local-H residual + the topological wrong-label checks (skipped-corner edges, duplicate-pixel labels, frontier line-spacing smoothness) + the largest-component filter. Refuses if survivors < min_labeled_corners. |
| 6 | output | surviving set → ChessboardDetection | Build a LabelledGrid and call normalize() (rebase min → (0, 0); canonicalise +u ≈ +x, +v ≈ +y; stable (v, u) sort). The lattice Coord{u,v} is the canonical grid-coordinate type, so it is copied straight onto each output corner. |
Note on the output shape.
ChessboardDetectionisCoord{u,v}-based; what moved is where normalization lives — the rebase + canonicalise + sort algorithm is now owned byprojective_grid::LabelledGrid::normalize, with the output stage merely calling it.
Key invariants
These hold across every stage that can attach a label, and are what make a miss recoverable but a false positive impossible:
- Two grid directions. Clustering recovers
{Θ₀, Θ₁}(≈ 90° apart) as the only global axis prior. All axis means use the undirected(cos 2θ, sin 2θ)accumulation — there is noCorner::orientation, onlyCorner.axes: [AxisEstimate; 2]. - Parity / edge-slot-swap. A corner’s four cardinal neighbours sit at the opposite axis-slot parity by construction. Every attachment checks that the candidate edge crosses a slot-swap boundary, so a diagonal or skipped-corner attachment is rejected structurally, not by a magnitude threshold.
- Geometry check can only subtract. Stage 5 never adds or relabels; a corner that survives every stage has been proven to sit at a real intersection.
- Non-negative labels. Output rebases the labelled bbox minimum to
(0, 0).
DiskFit slot-coherence repair (Stage 2)
The ChESS detector’s DiskFit mode can uniformly
pick the wrong antipodal dark sector, reversing a corner’s
(axes[0], axes[1]) ordering and breaking the parity invariant globally.
A live recall safety-net (slot_coherence) detects this with a
gross-imbalance gate, BFS-2-colours the clustered corners at cell spacing,
and swaps the two AxisEstimate slots of whichever corners disagree. A
bipartite-quality gate aborts the pass unless the 2-colouring is
essentially perfect, so it can only add recall, never a wrong label. Under
RingFit the split is already ~50/50 and the pass is a no-op.
Multi-component dispatch
Detector::detect_all is the multi-board entry point: it returns several
ChessboardDetections (up to max_components) when one image contains
physically distinct grids. Within a single image, the topological facade
already merges connected components, so a single physical board split into
disjoint sub-grids (e.g. ChArUco rows separated by markers) is reunited in
label space by the Stage-4 merge. The precision contract holds per emitted
component. The workspace explicitly does not support multiple separate
physical boards in one frame.
Failure modes
Identify the stage from the serializable topological trace
(pipeline::trace_topological, layered over the production path) and the
final-check GeometryCheckTrace drop counters, then consult:
| Symptom | Likely stage | Knob to try | Notes |
|---|---|---|---|
| No detection, no grid directions | Stage 2 (clustering) | min_peak_weight_fraction, peak_min_separation_deg | The two grid axes never separated — common on very-bad-light frames. |
| No cell size / no seed | Stage 3 (topological) | detect_chessboard_best with sweep_default() | No quad assembled. Builder tolerances are internal. |
| Very few corners | Stage 4 (recover) | attach_search_rel, attach_axis_tol_deg, step_tol, edge_axis_tol_deg | Grid grew but couldn’t extend — common on heavily distorted views. |
| Many dropped corners | Stage 5 (geometry check) | geometry_check_local_h_tol_rel | Invariants found outliers; check the drop reasons. |
Wrong (i, j) labels | never | — | File a bug. The precision contract has been violated; do not tune around it. |
Tuning
DetectorParams splits into a stable core (graph_build_algorithm
[single-variant], min_labeled_corners, max_components,
min_corner_strength) plus an opt-in, non-semver advanced
(AdvancedTuning) block of per-stage knobs. Leave advanced unset unless
a specific input fails and you have evidence for the change. For
challenging images use detect_chessboard_best with
DetectorParams::sweep_default() (three configs varying only
recall-affecting tolerances; all preserve the precision invariants). The
full knob table is in Tuning the Detector and the
chessboard crate chapter.
Cross-references
- The Chessboard Detector — the full invariant stack, the topological-trace diagnostics surface, and a quickstart.
crates/calib-targets-chessboard/docs/PIPELINE.md— the canonical stage map this page mirrors.- The downstream pipelines that build on this spine: PuzzleBoard, ChArUco, Marker board.
PuzzleBoard pipeline
Composes: the chessboard grid stack + PuzzleBoard edge-code decode. Source of truth:
crates/calib-targets-puzzleboard/docs/PIPELINE.md. Crate reference: calib-targets-puzzleboard.
A PuzzleBoard is a self-identifying chessboard: every interior edge carries a midpoint dot, and the dot pattern uniquely identifies any ≥ 4×4 fragment’s position on a 501×501 master code. The pipeline runs the full chessboard grid stack first, then samples the interior-edge dots and decodes them into absolute master positions — so a visible fragment still yields absolute corner IDs and object-space coordinates.
End-to-end stages
| # | Stage | In → Out | What it does |
|---|---|---|---|
| 0 | chessboard grid detect | ChESS corners → Vec<ChessDetection> | The full chessboard pipeline (multi-component). Wrong (i, j) labels here become wrong absolute master labels — same precision-unrecoverable property as ChArUco. |
| 1 | edge sampling | labelled corners + image → observed edges | Per interior edge: sample a disk of radius sample_radius_rel × edge_len at the midpoint, derive bright/dark references from the adjacent cells, classify into bit ∈ {0,1} with a confidence ∈ [0,1]. |
| 2 | bit-confidence filter | observed edges → high-confidence edges | Drop bits below min_bit_confidence; low-confidence bits become unknown. |
| 3 | minimum-edges gate | filtered edges → pass / fail | Require enough edges for a min_window × min_window fragment (min_window² ≥ 4², the uniqueness floor). |
| 4 | origin sweep | filtered edges + master maps → (D4, origin) + score | Enumerate (D4 rotation, master_origin) hypotheses — Full (8 × 501 × 501) or FixedBoard (8 × (rows+1)²) scope, with HardMajority (BER gate) or SoftLogLikelihood (margin gate) scoring. See the decode algorithm. |
| 5 | best-component selection | per-component results → one decode | Rank components by edges-matched, then BER, then soft score. Conflict detection: two well-supported components disagreeing on master origin → InconsistentPosition (refuse, don’t guess). |
| 6 | emit detection | best decode → result | Rebase (i, j) to non-negative; sort by (j, i); assign absolute IDs (j·501 + i) and target_position (i·cell_size, j·cell_size). |
What it inherits from the chessboard detector
The full chessboard topological pipeline runs on the input ChESS corners —
prefilter, axis clustering, the
topological grid walk, booster-driven
component recovery, and the mandatory final geometry
check. PuzzleBoard already defaulted to the
topological builder, which is now the only builder; graph_build_algorithm
is a single-variant reserved seam.
The partial-view guarantee
For a given printed board, any subset of its corners decodes to the same
master IDs a full-view decode would produce — across single-camera
captures that frame only part of a large board and across multi-camera
rigs where each camera sees a different fragment. Overlapping corners
share master IDs without further stitching. FixedBoard mode sidesteps the
per-view master-origin drift by scoring against the declared board rather
than the full master.
Failure modes
| Symptom | Likely stage | What it means / knob to try |
|---|---|---|
| No grid components | Stage 0 (chessboard) | Sparse / empty corner cloud — see the chessboard failure modes. Try --upscale on small boards. |
NotEnoughEdges | Stage 2–3 | Too few high-confidence edge bits survived. Lower decode.min_bit_confidence; check that interior dots are resolved at this image scale. |
| Every hypothesis over BER | Stage 4 (HardMajority) | Board too small or too noisy. Raise decode.max_bit_error_rate, or switch to SoftLogLikelihood (more robust on ambiguous fragments). |
| Small / ambiguous decode margin | Stage 4 (SoftLogLikelihood) | Near-symmetric fragment or few high-confidence bits. Capture a larger fragment; the margin gate is correctly refusing a coin-flip. |
InconsistentPosition | Stage 5 | Two sub-grids disagree on the master origin — an unrecoverable ambiguity. Crop to a single board, or use FixedBoard with the known spec. |
| Wrong absolute IDs | never | A wrong chessboard (i, j) would cause this — file a bug at the chessboard layer; the decode itself is exhaustive and gated. |
Tuning
The grid side is the standard chessboard DetectorParams (under
params.chessboard); the decode side is params.decode:
min_bit_confidence(default0.5) — the confidence floor for an edge bit to count. Lower on blurry boards; too low admits noise bits.max_bit_error_rate(default0.3) — theHardMajorityBER gate.min_window(default4) — the uniqueness floor; rarely changed.search_mode—Full(default) vsFixedBoard(cheaper, fixes per-view origin drift when the board is known).scoring_mode—HardMajority(default) vsSoftLogLikelihood(robust on ambiguous fragments; adds the margin gate).search_all_components(defaulttrue) — decode every grid component and pick the best, with conflict detection.
For threshold-sensitive images use
PuzzleBoardParams::sweep_for_board(&spec) with
detect_puzzleboard_best. The sweep tries the default soft scorer first,
then a hard-weighted fallback at the paper’s 40% BER allowance for
high-distortion fragments. The naive hard-bit decoder already clears the
precision/recall contract at zero wrong labels; do not rewrite to a
coherent-hypothesis matcher without a demonstrated precision gap.
Cross-references
- PuzzleBoard edge-code decode — the decoder algorithm in detail.
- calib-targets-puzzleboard — the crate API, search modes, and printable examples.
crates/calib-targets-puzzleboard/docs/PIPELINE.md— the canonical stage map this page mirrors.
ChArUco pipeline
Composes: the chessboard grid stack + ArUco bit decode + ChArUco alignment & corner IDs. Source of truth:
crates/calib-targets-charuco/docs/PIPELINE.md. Crate reference: calib-targets-charuco.
A ChArUco board carries an ArUco marker in every white square. The pipeline runs the chessboard grid detector first, then decodes the per-cell markers, aligns them to the board spec, and assigns each inner corner its absolute, OpenCV-compatible corner ID. The markers are what make ChArUco robust to partial views and unambiguous about orientation.
End-to-end stages
| # | Stage | In → Out | What it does |
|---|---|---|---|
| 0 | chessboard grid detect | ChESS corners → Vec<ChessDetection> | ChessDetector::detect_all on the topological builder. The min_corner_strength floor keeps marker-bit saddles out of the grid (below). |
| 1 | grid smoothness pre-filter | grid corners + image → cleaned corners | Per-corner position vs midpoint-averaged neighbours; a deviation over grid_smoothness_threshold_rel × px_per_square triggers a local ChESS redetection or a drop. |
| 2 | marker cell enumeration | corner map → Vec<MarkerCell> | Per cell, require all four corners {(i,j),(i+1,j),(i+1,j+1),(i,j+1)}; skip incomplete cells. |
| 3 | marker decode + alignment | cells + image → markers + alignment | Score each cell’s soft bits (ArUco bit decode) against every (D4 rotation, integer translation) board hypothesis, pick the maximum-likelihood placement, and accept it through a margin gate. See alignment. |
| 4 | alignment validation | markers + spec → inliers | Require ≥ min_marker_inliers (primary component) or ≥ min_secondary_marker_inliers (subsequent). |
| 5 | ChArUco corner mapping | corners + alignment → IDed corners | Map each board-spec inner-corner position through the alignment; only inner-cell intersections get IDs (not marker corners). |
| 6 | corner validation | mapped corners + markers + image → validated corners | Check each corner against its marker-predicted seed; deviation over corner_validation_threshold_rel × px_per_square → marker-constrained redetect or drop. |
| 7 | emit detection | validated corners + alignment → result | Sort typed ChArUco corners by ID; refuse below the caller’s threshold. |
What it inherits from the chessboard detector — and the strength floor
ChArUco runs the full chessboard topological pipeline (prefilter,
clustering, the grid walk,
booster recovery, and the mandatory geometry
check). The chessboard precision contract
carries forward: a wrong (i, j) label here would corrupt every
downstream marker match.
CharucoParams::for_board sets two chessboard knobs that adapt the shared
detector to marker scenes:
min_corner_strength = 33.0— an absolute ChESS-strength floor that cuts the weak corner responses on ArUco marker-bit saddles before the grid grows. Those corners are grid-consistent but lie inside the marker interior; cutting them early keeps the topological per-cell axis test from being poisoned by marker-internal X-corners. This floor — not marker presence — is the precision lever here. (It is the concern the historical ChArUco builder pin used to guard; the topological builder is now safe on marker scenes because of this floor.)enable_final_edge_shape_check = false— ChArUco keeps the chessboard component recall-oriented because the marker-ID and board-alignment validation downstream is its precision gate.
chessboard.graph_build_algorithm is the single-variant topological seam;
a config carrying a legacy value is re-pinned to Topological on load.
Failure modes
| Symptom | Likely stage | What it means / knob to try |
|---|---|---|
No grid / None from Stage 0 | Stage 0 (chessboard) | Sparse corner cloud or clustering failure — see the chessboard failure modes. |
NoMarkers (grid found, no decodes) | Stage 3 (decode) | Wrong dictionary / marker_size_rel, or blur. Enable multi_threshold, lower min_border_score, verify the board spec. |
AlignmentFailed { inliers: 0 } | Stage 4 | No decoded ID is in the layout — board-spec mismatch (rows, cols, dictionary, marker_layout) or a non-zero first_marker offset. |
AlignmentFailed, small but non-zero inliers | Stage 4 | Partial view or strong perspective — lower min_marker_inliers to what you reliably see. |
| Small soft-matcher margin | Stage 3 (board-level matcher) | Ambiguous decodes / heavy bit noise — the margin gate is flagging a low-confidence alignment. |
| Corners drift off true intersections | Stage 6 | Weak alignment — verify the board pose / occlusion; check corner_validation_threshold_rel. |
| Wrong corner IDs | never | A wrong chessboard (i, j) would cause this — file a bug at the chessboard layer. |
Tuning
ChArUco config layers three surfaces:
- Chessboard grid —
CharucoParams.chessboardis aDetectorParams(stable core + opt-inadvanced).for_boardpre-sets the marker-scene-safemin_corner_strengthfloor; do not lower it on marker boards. - Marker decode —
scan.*(ScanDecodeConfig):min_border_score(default0.75; lower cautiously to0.65on blur),multi_threshold(defaulttrue),inset_frac(default0.06; raise when borders bleed into the bit grid).marker_size_relmust match the printed board. - Alignment — the board-level matcher’s
alignment_min_margin/bit_likelihood_slope, the downstreammin_marker_inliers/min_secondary_marker_inliersfloors (default1each — the matcher is its own gate), andcorner_validation_threshold_rel(default0.08).
Board sampling scale is px_per_square (starts at 60 in for_board);
adjust it first if the board appears at a very different pixel scale. For
challenging images use CharucoParams::sweep_for_board(&board) with
detect_charuco_best. See Tuning the Detector and
Troubleshooting.
Cross-references
- ArUco bit decode and ChArUco alignment & corner IDs — the two marker-side algorithms.
- calib-targets-charuco and ChArUco Alignment and Refinement — the crate API.
crates/calib-targets-charuco/docs/PIPELINE.md— the canonical stage map this page mirrors.
Marker board pipeline
Composes: the chessboard grid stack + 3-circle anchoring. Source of truth:
crates/calib-targets-marker/docs/PIPELINE.md. Crate reference: calib-targets-marker.
A marker board is a chessboard with three reference circles in known cells. The pipeline runs the chessboard grid detector to recover the lattice, then detects the three circles and uses them to anchor the otherwise-unlabelled grid to a known board frame. It is the lightest self-identifying target: where ChArUco anchors with per-cell marker IDs, this anchors with a single 3-point pose.
End-to-end stages
| # | Stage | In → Out | What it does |
|---|---|---|---|
| 0 | chessboard grid detect | ChESS corners → ChessDetection | ChessDetector::detect — single best component (multi-component is not supported here). |
| 1 | circle candidate detection | corner map + image → Vec<CircleCandidate> | For each complete 4-corner cell, warp the cell to a square patch, find the centroid + radius of a bright/dark disk, and keep the top max_candidates_per_polarity per polarity. |
| 2 | expected-circle matching | candidates + spec → Vec<CircleMatch> | For each of the 3 expected circles, find the nearest candidate within max_distance_cells (optional), matching by polarity. |
| 3 | grid alignment estimation | matches → GridAlignment + inliers | Fit a dihedral transform + translation in (i, j)-space from the matched 3-circle layout; require ≥ min_offset_inliers consistent matches. |
| 4 | per-corner offset mapping | matches + alignment → offsets | Apply the alignment transform to each candidate cell coord; compute the delta from expected. |
| 5 | emit detection | grid + circles + alignment → result | Emit typed marker-board corners (optional IDs / target_position); circle evidence is returned through MarkerBoardDiagnostics. |
What it inherits from the chessboard detector
The full chessboard topological pipeline (prefilter,
clustering, the grid walk,
booster recovery, and the mandatory geometry
check). The 3-circle pattern serves only to
anchor the labelled grid to a known frame — a wrong (i, j) label at
the chessboard layer would mis-align every alignment-derived ID. This
detector uses detect (single best component), not detect_all.
Failure modes
| Symptom | Likely stage | What it means / knob to try |
|---|---|---|
No grid / None from Stage 0 | Stage 0 (chessboard) | Sparse corner cloud or clustering failure — see the chessboard failure modes. |
| No / too few circle candidates | Stage 1 | Circles absent, wrong polarity (e.g. white circle on white cell), or low contrast. Adjust circle_score (min_contrast, diameter_frac); check roi_cells is not excluding them. |
| Candidates found, no matches | Stage 2 | Candidates outside max_distance_cells, or polarity mismatch vs the spec. Verify the three MarkerCircleSpec cells + polarities against the printed board. |
Alignment None (too few inliers) | Stage 3 | Fewer than the required consistent matches, or circles on the board boundary giving an unreliable pose. Lower min_offset_inliers only if you genuinely see fewer circles. |
Grid found but target_position empty | output | layout.cell_size is unset (or alignment failed) — target_position is only populated when both hold. |
| Wrong anchored IDs | never | A wrong chessboard (i, j) would cause this — file a bug at the chessboard layer. |
Tuning
MarkerBoardParams is layout + chessboard params + circle scoring +
matching:
layout— theMarkerBoardSpec(rows, cols, the threeMarkerCircleSpeccells + polarities, optionalcell_size). The marker circles supply the geometry constraint, so the v1expected_rows/colsandcompleteness_thresholdno longer apply.chessboard— aDetectorParamsfor the underlying grid step.circle_score(CircleScoreParams) —patch_size,diameter_frac,ring_thickness_frac,ring_radius_mul,min_contrast,samples,center_search_px.match_params(CircleMatchParams) —max_candidates_per_polarity(default6),max_distance_cells(optional),min_offset_inliers(default1).roi_cells— optional[i0, j0, i1, j1]to restrict the circle search.
Cell coordinates (i, j) in the spec refer to square cells by their
top-left corner index; the cell center is at (i + 0.5, j + 0.5). Use the
*_with_diagnostics entry points to inspect scored candidates, matches,
and alignment_inliers when tuning.
Cross-references
- calib-targets-marker — the crate API and key types.
- Chessboard pipeline — the grid spine this detector anchors.
crates/calib-targets-marker/docs/PIPELINE.md— the canonical stage map this page mirrors.
Project Overview
calib-targets-rs is a single Cargo workspace with multiple publishable crates under crates/. The design is layered: calib-targets-core provides geometry and shared types, higher-level crates build on top, and the facade crate (calib-targets) is intended to be the main entry point.

Workspace layout
calib-targets-core: shared geometry types and utilities.calib-targets-chessboard: chessboard detection from corner clouds.calib-targets-aruco: embedded dictionaries and decoding on rectified grids.calib-targets-charuco: grid-first ChArUco detector and alignment.calib-targets-puzzleboard: self-identifying chessboard detector with absolute corner IDs from edge dots.calib-targets-marker: checkerboard marker detector (chessboard + circles).calib-targets: facade crate, currently hosting examples and future high-level APIs.
Strengths
- Clear crate boundaries with a small, geometry-first core.
- Chessboard detection pipeline is implemented end-to-end with debug outputs.
- Mesh-warp rectification supports lens distortion without assuming a single global homography.
- Examples and regression tests exist for all workflows.
Gaps and early-stage areas
- Public APIs are not yet stable.
- ArUco decoding assumes rectified grids and does not perform quad detection.
- Performance/benchmarks are not yet a focus.
Metrics
This page records the workspace’s measured recall, precision, and
performance characteristics. All concrete numbers below come from
public sources only — the checked-in testdata/ images, the
deterministic synthetic suites, and the criterion microbenchmarks.
Private real-world regression datasets are referenced qualitatively
only, per the project’s dataset-disclosure policy.
The numbers are indicative and drift with tuning; the binding contracts are the gates (the tests that fail on regression), not the exact figures. Re-generate them with the commands noted in each section.
Precision contract (all detectors)
The non-negotiable contract across every detector is zero wrong
(i, j) labels. A wrong label poisons downstream calibration and is
unrecoverable; a missing label is acceptable. Every recall figure
below is therefore reported alongside the standing zero-wrong-label
guarantee — recall may move with tuning, precision may not.
Chessboard / grid recall on public testdata
The public baseline (crates/calib-targets-bench/baselines/chessboard.json,
the default topological cell) labels 1834 corners across 15 public
images with zero position / id / duplicate diffs. Per-image labelled
counts:
| Image | Labelled corners |
|---|---|
testdata/large.png | 345 |
testdata/puzzleboard_reference/example1.png | 253 |
testdata/puzzleboard_reference/example2.png | 180 |
testdata/small2.png | 135 |
testdata/small0.png | 134 |
testdata/small3.png | 125 |
testdata/small4.png | 121 |
testdata/small5.png | 132 |
testdata/small1.png | 119 |
testdata/mid.png | 77 |
testdata/02-topo-grid/gptchess1.png | 60 |
testdata/02-topo-grid/GeminiChess1.png | 54 |
testdata/02-topo-grid/GeminiChess3.png | 42 |
testdata/02-topo-grid/GeminiChess2.png | 29 |
testdata/puzzleboard_reference/example3.png | 28 |
Reproduce with cargo run -p calib-targets-bench --release --bin bench -- check --dataset public. A passing run reports pos=0 id=0 dup=0 on
every image — the pos= counter validates positions of baseline
corners, not new labels (see the debugging guide), so new (i, j)
labels are gated separately by overlay inspection + the geometry checks.
Synthetic suites (projective-grid)
Two in-crate synthetic suites gate the precision contract on
deterministic, image-free fixtures (seeded LCG, no rand dependency):
- Square positions (
tests/detect_square_positions.rs) — perfect / perspective / outlier grids on both algorithms; headline assertion is full recovery of a perfect grid with zero wrong labels, plus a determinism assertion (identical output across runs). - Hex positions (
tests/detect_hex_positions.rs) — the hex regression gate (perfect / perspective / position-noise / dropouts / off-lattice-clutter / nativeOriented3/ D6-under-rotation / determinism). Recall floors are measured-minus-margin (e.g. ≥ 24 nodes under perspective and under noise, ≥ 15 with dropouts) and every case asserts zero wrong(q, r)labels modulo the 12 D6 automorphisms.
Run with cargo test -p projective-grid.
Performance (criterion, indicative)
cargo bench -p projective-grid --bench detect_grid measures the public
detect_grid_all entry on deterministic synthetic fixtures (a single
mild-perspective grid per cell). Indicative wall-clock times on the
reference dev machine (16×16 = 256-corner square, hex radius-6 =
127-node):
| Cell | Algorithm | Time |
|---|---|---|
square_oriented2 | topological | ~0.6 ms |
square_positions | topological (axis synthesis) | ~0.8 ms |
hex_positions | topological (axis synthesis) | ~0.19 ms |
These are perf-regression tracking numbers, not a benchmark of any competitor; absolute values depend heavily on hardware, corner count, and perspective.
The workspace also ships a puzzleboard-size criterion suite
(cargo bench -p calib-targets --bench puzzleboard_sizes).
Private real-world regression (qualitative)
Beyond the public surfaces above, the chessboard, ChArUco, and puzzleboard detectors are validated against private real-world regression sets as part of every change’s gate. These confirm the zero-wrong-label contract on real captured frames under perspective, foreshortening, and partial occlusion. Per the dataset-disclosure policy, no counts, filenames, or frame identifiers from those sets appear in this (or any) public document — the statement is qualitative: validated on private real-world regression sets at zero wrong labels.
Conventions
These conventions are used throughout the workspace. They are not optional and should not change silently.
Coordinate systems
- Image pixels: origin at top-left,
xincreases right,yincreases down. - Grid coordinates:
iincreases right,jincreases down. - Grid indices in detections are corner indices (intersections), not square indices, unless explicitly stated otherwise.
Homography and quad ordering
- Quad corner order is always TL, TR, BR, BL (clockwise).
- The ordering must match in both source and destination spaces.
- Never use self-crossing orders like TL, TR, BL, BR.
Sampling and pixel centers
- Warping and sampling should be consistent about pixel centers.
- When in doubt, treat sample locations as
(x + 0.5, y + 0.5)in pixel space.
Orientation angles
- ChESS-style corner orientations are in radians and defined modulo
pi(not2*pi). - Orientation clustering finds two dominant directions and assigns each corner to cluster 0 or 1, or marks it as an outlier.
Marker bit conventions
- Marker codes are packed in row-major order.
- Black pixels represent bit value 1.
- Border width is defined in whole cells (
border_bits).
If you introduce new algorithms or data structures, document any additional conventions in the relevant crate chapter.
Crates
The workspace is organized as a stack of crates with minimal, composable boundaries.
Dependency direction
calib-targets-coreis the base and should not depend on higher-level crates.calib-targets-chessboarddepends oncorefor geometry and types.calib-targets-arucodepends oncorefor rectified image access.calib-targets-charucodepends onchessboardandaruco.calib-targets-puzzleboarddepends onchessboardand uses committed code-map blobs for absolute edge-code decoding.calib-targets-markerdepends onchessboardandcore.calib-targets-printdepends on the target crates and owns printable-target rendering.calib-targetsis the facade that re-exports types and offers end-to-end helpers.
Python bindings
Python bindings are provided by the calib-targets-py crate (module name
calib_targets). It depends on the facade crate and is built with maturin;
see crates/calib-targets-py/README.md in the repository root.
Where to start
If you are new to the codebase, start with:
Then branch into the target-specific crates depending on your use case.
calib-targets-core
calib-targets-core provides shared geometric types and utilities. It is intentionally small and purely geometric; it does not depend on any particular corner detector or image pipeline.
Global rectification output from the chessboard pipeline.
Core data types
AxisEstimate: one local grid-axis direction — an angle in[0, π)plus a 1σ angular uncertainty. A corner carries two of these asaxes: [AxisEstimate; 2]; there is no single-orientation field (Corner::orientationwas removed workspace-wide).Coord: integer grid indices(u, v)in board space, withuright andvdown. Re-exported fromprojective-grid; it is the single canonical grid-coordinate type across the workspace.LabeledCorner: a detected corner with optional grid coordinates and logical ID —position,grid,id,target_position,score.- The raw per-corner input type is detector-specific and lives in the
detector crates (e.g.
calib_targets_chessboard::ChessCorner), not incalib-targets-core. TargetDetection: a collection of labeled corners for one board instance.TargetKind: enum forChessboard,Charuco, orCheckerboardMarker.
These types are shared across all detectors so downstream code can be target-agnostic.
Orientation clustering
ChESS corner orientations are only defined modulo pi. The clustering utilities help recover two dominant grid directions:
cluster_orientations: histogram-based peak finding followed by 2-means refinement.OrientationClusteringParams: histogram size, separation thresholds, outlier rejection.compute_orientation_histogram: debug visualization helper.estimate_grid_axes_from_orientations: a lightweight fallback when clustering fails.
Chessboard detection uses these helpers to label corners by axis and to reject outliers.
Homography and rectification
Homography is a small wrapper around a 3x3 matrix with helpers for DLT estimation and point mapping:
estimate_homography_rect_to_img: DLT with Hartley normalization for N >= 4 point pairs.homography_from_4pt: direct 4-point estimation.warp_perspective_gray: warp a grayscale image using a homography.
For mapping rectified pixels back to the original image, core defines:
RectifiedView: a rectified grayscale image and its mapping info.RectToImgMapper: either a single global homography or a per-cell mesh map.
Higher-level crates (notably chessboard) wrap these utilities for global or mesh rectification.
Image utilities
GrayImage and GrayImageView are lightweight, row-major grayscale buffers with bilinear sampling helpers:
sample_bilinear: float sampling with edge clamp to 0.sample_bilinear_u8: u8 sampling with clamping to 0..255.
These utilities are used by rectification and marker decoding.
Conventions recap
- Coordinate system: origin at top-left,
xright,ydown. - Grid coordinates:
iright,jdown, and grid indices refer to corners. - Quad order: TL, TR, BR, BL in both source and destination spaces.
If you build on core types, stick to these conventions to avoid subtle alignment bugs.
The Chessboard Detector
Code:
calib-targets-chessboard. Related: the generic axis clustering, topological grid construction, and line/local-H validation live in the standaloneprojective-gridcrate.For the canonical end-to-end stage map see the Chessboard pipeline; for the individual building blocks see the Algorithms section. This page is the crate’s invariant-and-API reference and goes deeper on the precision-by-construction design.
The chessboard detector takes a cloud of ChESS X-junction corners and produces
an integer-labelled chessboard grid (i, j) → image position. It is
precision-by-construction: every emitted label has been proven to sit at
a real grid intersection by a stack of independent geometric invariants.
Missing corners are acceptable; wrong corners are not.
On our private regression dataset (captured with non-negligible lens
distortion and motion blur — uncommitted; see privatedata/ for how
to reproduce locally) the detector achieves a high detection rate
with zero wrong (i, j) labels — precision-by-construction.
A wrong label would corrupt downstream calibration; that is the constraint the algorithm refuses to break.
┌───────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌────────┐
│Corners│ ->│Prefilter│ ->│ Cluster │ ->│ Topo │ ->│ Recover │ ->│ Geom │
│ in │ │(Stage 1)│ │ axes │ │ grid │ │ + boost│ │ check │
└───────┘ └─────────┘ │(Stage 2)│ │(Stage 3) │ │(Stage 4)│ │(Stage 5)│
└─────────┘ └──────────┘ └─────────┘ └────────┘
│
v
┌────────┐
│ Output │
│(Stage 6)│
└────────┘
1. Corner axes contract
The detector reads only one orientation signal per corner:
ChessCorner.axes: [AxisEstimate; 2]. Convention (enforced workspace-wide
and documented in CLAUDE.md):
axes[0].angle ∈ [0, π),axes[1].angle ∈ (axes[0].angle, axes[0].angle + π).axes[1] − axes[0] ≈ π/2— the two axes are orthogonal grid directions (NOT diagonals of unit squares).- The CCW sweep from
axes[0]toaxes[1]crosses a dark sector. This encodes parity: at parity-0 cornersaxes[0] ≈ Θ_horizontal(dark-entering), at parity-1 cornersaxes[0] ≈ Θ_vertical. Adjacent chessboard corners therefore have opposite axis-slot assignments. - Default-constructed axes carry
sigma = π(no information) and are filtered out in Stage 1.
Any function computing a circular mean of axis angles MUST accumulate
(cos 2θ, sin 2θ) and halve the atan2 result. Accumulating raw
(cos θ, sin θ) breaks at the 0°/180° seam.
2. Invariants
A labelled corner C at (i, j) is kept iff every one of these holds at
convergence:
- Axis membership. Both
C.axes[0]andC.axes[1]are withincluster_tol_degof the two global grid-direction peaks{Θ₀, Θ₁}, each axis matching a different peak. - Cluster label = axis-slot.
cluster(C) = 0iffC.axes[0]is closer toΘ₀; otherwise1. Binary, per-corner. - Parity.
cluster(C) ≡ (i + j) mod 2(modulo a global sign fixed by the seed quad). - Edge orientation along the corner’s axes. For every in-graph edge
C ↔ Nwith vectorv = N.pos − C.pos,atan2(v) mod πis withinedge_axis_tol_degof exactly one ofC.axes[*]AND of exactly one ofN.axes[*]. (No ±π/4 offset — edges align with axes, not diagonals.) - Edge axis-slot swap. Let
ax_C ∈ {0, 1}be the slot ofCmatching the edge, andax_Nthe slot ofN. Requireax_C ≠ ax_N. - Cell-size consistency.
|v| ∈ [1 − step_tol, 1 + step_tol] × s. - Line collinearity. For every labelled row / column through
Cwith≥ line_min_membersmembers,C’s perpendicular residual to the fitted line is≤ line_tol × s. Projective-line fits use a looser tolerance to absorb mild lens distortion. - Local-H consistency. A local 4-point homography from 4 non-collinear
labelled neighbors predicts
C’s pixel position with residual≤ local_h_tol × s. - No ambiguity at attachment. When admitted via prediction, no other
strong corner lies within
attach_ambiguity_factor ×the attachment distance.
A corner failing any invariant is blacklisted. A blacklist update
restarts seed → grow → validate with the blacklist excluded; the loop is
capped at max_validation_iters.
3. Pipeline
The detector runs as a sequence of named stages, orchestrated by
pipeline::detect_all_topological with one module per stage group under
crates/calib-targets-chessboard/src/pipeline/. The canonical six-stage
map (this mirrors crates/calib-targets-chessboard/docs/PIPELINE.md and
the crate-level rustdoc — that crate doc is the authoritative stage list):
ChessCorner[]
→ 1. prefilter strength + fit-quality gates; weak corners kept as
→ positions with no-information axes (indices stay stable)
→ 2. cluster_axes global axes Θ₀, Θ₁ + per-corner slot label,
→ then the DiskFit slot-coherence repair
→ 3. topological_grid the projective-grid topological builder
→ (Delaunay → classify → quads → walk → facade merge)
→ 4. recover_components per-component cell-size estimate, recall boosters
→ (gap fill + line extrapolation), weak-cluster rescue,
→ merge_components_local
→ 5. final_geometry_check MANDATORY precision pass; can only DROP corners
→ 6. output LabelledGrid::normalize (Coord copied straight out)
→ Output: ChessboardDetection (one per component) or None
The precision core is the whole chain: any corner that survives to output has passed every axis / parity / edge invariant. The boosters (Stage 4) only add corners — each addition re-runs the same invariants the topological walk uses — and the final geometry check (Stage 5) only drops them. Neither relaxes an invariant.
One builder, no seed/grow loop. The historical seed-and-grow grid builder (with its
find_seed/grow/extend_boundary/ blacklist-restart loop) has been removed.(i, j)labelling is done by the topological grid finder; the chessboard crate owns the prefilter, clustering, recovery boosters, the mandatory geometry check, and output canonicalisation around it. The generic builder is documented on the Topological grid finder algorithm page and indocs/algorithms/topological-grid-detection.md.
Stage 1 — Pre-filter (inputs.rs)
Mark corner c usable iff:
c.strength ≥ min_corner_strength(default0.0, off); andc.contrast ≤ 0, orc.fit_rms ≤ max_fit_rms_ratio × c.contrast(default0.5).
A corner that fails keeps its pixel position but has its axes replaced by
the no-information sentinel (sigma = π), so it cannot vote on edges but
the corner array is not renumbered (trace / index stability).
Stage 2 — Axis clustering (cluster/)
Recover the two global grid directions {Θ₀ ≤ Θ₁} from the strong
corners’ axes with the generic axis clustering
(circular histogram + plateau-aware peak picking + double-angle 2-means
on (cos 2θ, sin 2θ)), and label each corner Canonical (axes[0] matches
Θ₀), Swapped, or NoCluster.
Why double-angle. Axes are undirected —
θandθ + πare the same direction. Naïve circular mean over raw(cos θ, sin θ)produces zero when votes straddle the 0°/π seam. Doubling the angle wraps both halves together; the inverse halving gives a stable mean.
The DiskFit slot-coherence repair (slot_coherence.rs) then runs: when
the upstream detector’s DiskFit mode uniformly reverses a corner’s
(axes[0], axes[1]) ordering, a gross-imbalance gate fires, the clustered
corners are BFS-2-coloured at cell spacing, and the two AxisEstimate
slots of the disagreeing corners are swapped. A bipartite-quality gate
aborts the pass unless the 2-colouring is essentially perfect, so it can
only add recall, never a wrong label. Under RingFit it is a no-op.
Stage 3 — Topological grid (mod.rs → projective-grid)
Hand the oriented features (positions + dual axes) and the cluster centres
(as an axis hint) to the topological grid finder
(via detect_grid_all — the sole grid builder, no algorithm enum): Delaunay
triangulation → axis-driven edge classification → triangle-pair → quad
merge → flood-fill (i, j) walk → the facade’s merge_components_local.
The facade’s own post-build validation / residual drop / recovery are
disabled here (tolerances at +∞, recovery Off) — the chessboard owns
those downstream.
Stage 4 — Recover components (recover.rs + boosters.rs)
Per labelled component: estimate the cell size from the labelled cardinal
edges, then run the recovery boosters —
interior gap fill + line extrapolation via fill_grid_holes, with a
per-axis directional edge scale because a partially-grown component can
be anisotropic before its boundaries fill in. Each addition re-runs the
same axis / parity / edge-slot-swap invariants as the walk; the pass is
capped by max_booster_iters. Optional weak-cluster rescue re-admits
NoCluster corners within weak_cluster_tol_deg. Finally
merge_components_local reunites components in label space.
Stage 5 — Final geometry check (geometry_check.rs)
Mandatory, and can only DROP (never add or relabel). It sequences the
shared drop_set precision pass:
- the shared
validate(line collinearity + local-H residual) with loosergeometry_check_*tolerances — catches gross mislabels (full-cell / diagonal ≈ 1.4-cell residual) without flagging accepted perspective drift; - the direct topological wrong-label check (interior skipped-corner edges, duplicate-pixel labels, frontier line-spacing smoothness);
- the largest-cardinally-connected-component filter, dropping isolated leaks outside the main grid.
The detection is refused if survivors fall below min_labeled_corners.
Stage 6 — Output (output.rs)
Build a projective_grid::LabelledGrid from the surviving labelled set and
call LabelledGrid::normalize() (rebase
min → (0, 0); canonicalise so +u ≈ +x, +v ≈ +y; stable (v, u)
sort — all owned by projective-grid). The normalized lattice Coord{u,v}
is the workspace’s canonical grid-coordinate type, so it is copied straight
onto each output corner with no adaptation step.
4. Why precision is by construction
The design constraint “wrong (i, j) labels are unrecoverable” is what
shapes every non-obvious choice in the pipeline. Two examples:
Cell size is an OUTPUT, not an input. A naïve detector estimates a
global cell size first, then uses it to set a search window. On ChArUco
scenes the nearest-neighbor histogram is bimodal (marker-internal
pairs at ~10 px vs true board pairs at ~55 px); even multimodal mean-shift
can pick the wrong mode. The topological builder instead assembles cells
from local axis topology — its quad filter uses a per-component
edge-length band (relative to that component’s own median), never a global
scalar — and the chessboard recovery stage then derives each component’s
cell size from its own labelled cardinal edges. There is no global pitch to
mispick. See the per-component cell-size band on the
Topological grid finder page and the
Cell-size gotcha in CLAUDE.md.
Edges align with axes, not diagonals. Some chessboard detectors model
ChESS corners as having a single orientation θ and check that grid
edges align with θ ± π/4. It reads the two axes directly and requires
edges to align with one axis (per invariant 4). The edge check then
becomes “does the edge match exactly one of the two axes within
tolerance?” — robust to the axis-swap parity that ChESS X-junctions
exhibit at adjacent corners. Skipping the ±π/4 offset removes a
single-orientation dependence that the workspace already discarded
(Corner::orientation was removed entirely).
Multi-component scenes are first-class. The same precision contract
applies to Detector::detect_all, which peels off disconnected components
of the same physical board (the typical ChArUco case where markers
interrupt grid contiguity). Each component is rebased to its own (0, 0)
origin; alignment to a global frame is the caller’s job.
We explicitly do NOT support scenes containing multiple separate physical boards. One target per frame is the contract.
5. Failure modes
When detection fails or returns fewer corners than expected, run the
serializable trace (pipeline::trace_topological, see §7) and consult this
table.
| Symptom | Likely stage | Knob to try | Notes |
|---|---|---|---|
No detection; trace shows few usable corners | Stage 1–2 (prefilter / clustering) | min_corner_strength ↓, max_fit_rms_ratio ↑, min_peak_weight_fraction, peak_min_separation_deg | Either the corners failed the prefilter or the two grid axes never separated. Most common on very-bad-light frames. |
No detection; trace shows usable corners but NoComponents | Stage 3 (topological grid) | Try detect_chessboard_best with DetectorParams::sweep_default() | No quad mesh assembled. Builder tolerances are internal; the sweep widens the upstream clustering / attachment tolerances. |
| Detection has very few corners | Stage 4 (recover) | attach_search_rel, attach_axis_tol_deg, step_tol, edge_axis_tol_deg | The grid walked but couldn’t extend. Common on heavily distorted views. |
Many corners dropped (GeometryCheckTrace.dropped high) | Stage 5 (geometry check) | geometry_check_local_h_tol_rel | Invariants found outliers; inspect the per-reason dropped_* counters. |
Wrong (i, j) labels emitted | never | — | If you ever see this, file a bug. The precision contract has been violated. |
The rare unrecovered frame on our internal regression set is typically a very-bad-light capture whose Stage-2 clustering never converges.
6. Parameters
DetectorParams is #[non_exhaustive] and splits into a small stable
core — graph_build_algorithm (single-variant Topological; retained as a
reserved config seam), min_labeled_corners, max_components,
min_corner_strength — plus an opt-in, unstable AdvancedTuning sub-struct
(DetectorParams::advanced) holding the per-stage tuning knobs. Build with
Default::default() and overwrite the stable fields, attach advanced
overrides with DetectorParams::with_advanced(...), or call
DetectorParams::sweep_default() for a 3-config preset (default, tighter,
looser) suitable for detect_chessboard_best-style sweeps.
advanced is Option-wrapped and serialized as a nested "advanced"
object — it is not flattened, and is omitted entirely when unset (in
which case detection runs on the defaults). The four stable knobs stay
top-level JSON keys. AdvancedTuning’s fields are not covered by
semver and may change between minor versions. The Field column below
shows the access path: top-level for the four stable knobs,
advanced.<knob> for the rest.
| Field | Default | Stage | Purpose |
|---|---|---|---|
graph_build_algorithm | Topological | — | Grid builder algorithm. Topological is the only value; the field is a reserved config seam. |
max_components | 3 | — | Cap for detect_all. |
min_labeled_corners | 8 | 5 | Minimum labelled corners to emit a ChessboardDetection. |
min_corner_strength | 0.0 | 1 | Minimum ChESS strength. 0 disables. (Stable.) |
advanced.max_fit_rms_ratio | 0.5 | 1 | Drop if fit_rms > k × contrast. ∞ disables. |
advanced.num_bins | 90 | 2 | Axis-direction histogram bins on [0, π). |
advanced.cluster_tol_deg | 12.0 | 2 | Per-axis tolerance from a cluster center. |
advanced.peak_min_separation_deg | 60.0 | 2 | Minimum separation between the two peaks. |
advanced.min_peak_weight_fraction | 0.02 | 2 | Minimum fraction of total vote weight per peak. |
advanced.attach_search_rel | 0.35 | 4 | Candidate radius around predicted position (booster attachment). |
advanced.attach_axis_tol_deg | 15.0 | 4 | Axis match at booster attachment. |
advanced.attach_ambiguity_factor | 1.5 | 4 | Reject if 2nd-nearest within factor × nearest. |
advanced.step_tol | 0.25 | 4 | Edge-length window when admitting attachments. |
advanced.edge_axis_tol_deg | 15.0 | 4 | Edge axis tolerance at admission. |
advanced.geometry_check_local_h_tol_rel | 0.20 | 5 | Local-H prediction tolerance in the final geometry check. |
advanced.line_min_members | 3 | 5 | Minimum members to fit a row / column. |
advanced.enable_weak_cluster_rescue | true | 4 | Toggle for the weak-cluster rescue booster. |
advanced.weak_cluster_tol_deg | 18.0 | 4 | Loosened cluster tolerance for rescue candidates. |
The advanced. rows above are part of AdvancedTuning, which is opt-in
and not covered by semver. (AdvancedTuning carries more per-stage
knobs than shown — see crates/calib-targets-chessboard/src/params/.)
All spatial tolerances are multiplicative with respect to the cell size — the pipeline is scale-invariant once the per-component cell size is estimated.
7. Debugging via the topological trace
The diagnostic entry point is pipeline::trace_topological(corners, params) -> Result<TopologicalTrace, TopologicalTraceError>. It is layered
over the production detect_grid_all facade (no separate timed
implementation), so the trace stays consistent with what detect()
actually does. TopologicalTrace (re-exported from
projective_grid::topological::trace) carries:
params: TopologicalParams— the parameters the topological stage ran with.corners: Vec<TopologicalCornerTrace>— every input corner with itsindex,source_index,position, per-axisaxis_angles_rad/axis_sigmas_rad, and ausableflag (did it survive the sigma/axis prefilter).components: Vec<TopologicalComponentTrace>— the labelled connected components, each a list of(u, v) -> source_indexlabels sorted by(v, u, source_index).diagnostics: TopologicalTraceDiagnostics— summary counters (corners_in,corners_used,components,labels).
TopologicalTraceError is NotEnoughCorners { usable } (fewer than three
usable corners for Delaunay) or NoComponents (production detection
returned no labelled component) — these are the two ways the grid stage
can come up empty.
For the drop accounting in the final geometry check, the pipeline’s
GeometryCheckTrace records dropped plus per-reason counters
(dropped_line_collinearity, dropped_local_h_residual,
dropped_edge_invariant, dropped_disconnected), components_seen, and a
detection_refused flag — the place to look when corners that should
survive are being dropped.
The stable cell_size (the grid pitch in px) is carried on
ChessboardDetection directly, populated on the normal detect() path.
8. Quickstart
use calib_targets_chessboard::{ChessCorner, Detector, DetectorParams};
fn detect(corners: &[ChessCorner]) {
let params = DetectorParams::default();
// `Detector::new` validates params and is fallible: it returns
// `Err(ChessboardParamsError)` for an invalid combination. No combination
// the public surface can express is rejected today; the fallible signature
// is a reserved seam for future validations.
let det = Detector::new(params).expect("valid params");
if let Some(d) = det.detect(corners) {
println!("labelled {} corners", d.corners.len());
// `cell_size` (the seed-derived grid pitch in px) is populated on the
// normal `detect()` path; `Option<f32>`, so `None` on edge cases.
if let Some(pitch) = d.cell_size {
println!("grid pitch ≈ {pitch:.1} px");
}
for c in &d.corners {
// `grid` is non-optional; `input_index` points back into `corners`.
println!(
"(u, v) = ({}, {}) at ({:.1}, {:.1}) [input #{}]",
c.grid.u, c.grid.v, c.position.x, c.position.y, c.input_index
);
}
}
}
fn detect_multi(corners: &[ChessCorner]) {
let det = Detector::new(DetectorParams::default()).expect("valid params");
for (k, comp) in det.detect_all(corners).iter().enumerate() {
println!("component {k}: {} corners", comp.corners.len());
}
}
For a minimal, dependency-free onboarding program — a synthetic
corner cloud detected and printed end to end — see
crates/calib-targets-chessboard/examples/detect_chessboard.rs:
cargo run -p calib-targets-chessboard --example detect_chessboard
The per-image regression overlays for the testdata/ set are emitted by
the driver script scripts/chessboard_regression_overlays.sh and are
wired into a #[test] harness at
crates/calib-targets-chessboard/tests/testdata_regression.rs.
9. Open questions
- Degenerate axes (one axis with
sigma = π) — current: the corner keeps its position but cannot vote on edges. Could a single-axis attachment pathway recover some recall on low-quality inputs? - Three-corner cells. The topological merge needs a complete cell (two triangles sharing a diagonal); one missing corner per cell starves the surrounding walk and the gap fill only recovers single interior holes. A richer local-geometry recovery could rebuild more partial cells.
- Distortion-curved lines — current: projective-line fit when there are enough members, straight-fit fallback. A true polynomial fit could absorb more distortion at the cost of false-negative risk.
- Delaunay under severe distortion — current: a Delaunay triangle can span more than one physical cell under combined perspective + radial distortion, leaving cells the diagonal-inference rule cannot resolve. A distortion-aware candidate-neighbour graph could help.
Contributions welcome.
calib-targets-aruco
calib-targets-aruco provides embedded ArUco/AprilTag-style dictionaries and decoding on rectified grids. It does not detect quads or perform image rectification by itself.
Rectified grid used for ArUco/AprilTag decoding.
For the algorithm — the grid-aware bit sampling model, the explicit bit order / polarity /
borderBitsconventions, and why this is not generic contour detection — see ArUco bit decode. This page is the crate API reference.
Current API surface
Dictionary: built-in dictionary metadata and packed codes.Matcher: brute-force matching against a dictionary with rotation handling.ScanDecodeConfig: how to scan a rectified grid (border size, inset, polarity).scan_decode_markers: read and decode markers from rectified cells.scan_decode_markers_in_cells: decode markers from per-cell image quads (no full warp).decode_marker_in_cell: decode a single marker inside one square cell.
The crate expects a rectified view where each chessboard square is approximately px_per_square pixels and where cell indices align with the board grid.
Decoding paths
There are two supported scanning modes:
- Rectified grid scan (
scan_decode_markers): build a rectified image first and scan a regular grid. - Per-cell scan (
scan_decode_markers_in_cells): pass a list of per-cell quads and decode each cell directly.
Per-cell scanning avoids building the full rectified image and is easy to parallelize across cells.
Scan configuration
ScanDecodeConfig controls how bit sampling and thresholding behave:
border_bits: number of black border cells (OpenCV typically uses 1).marker_size_rel: marker size relative to the square size (ChArUco uses < 1.0).inset_frac: extra inset inside the marker to avoid edge blur.min_border_score: minimum fraction of border bits that must be black.dedup_by_id: keep only the best detection per marker id.
If decoding is too sparse on real images, reduce inset_frac slightly and re-run.
Conventions
- Marker bits are packed row-major with black = 1.
Match::rotationis in0..=3such thatobserved == rotate(dict_code, rotation).border_bitsmatches the OpenCV definition (typically 1).
Status
Decoding is implemented and stable for rectified grids, but quad detection and image-space marker detection are deliberately out of scope.
For deeper tuning and sampling details, see ArUco Decoding Details.
ArUco Decoding Details
This chapter expands on the marker decoding path in calib-targets-aruco. The decoder is grid-first: it samples expected square cells and reads bits in rectified space (or per-cell quads).
Per-cell decoding
scan_decode_markers_in_cells reads marker bits in their own cells given an existing grid of square corners, without warping the full image. ChArUco detection drives this path: only valid grid cells are decoded, and the per-cell work parallelises trivially.
Sampling model
- Bits are sampled on a regular grid inside the marker area.
- The marker area is defined by
marker_size_rel, with an extra inset frominset_frac. - A per-marker threshold (Otsu) is computed from sampled intensities.
Tuning knobs
inset_fraccontrols how far inside the marker area bits are sampled. Lower values capture more of the marker; higher values are more robust to thin black borders bleeding into the bit grid.min_border_scoreis the minimum “frame looks like a marker border” score required to accept a cell. Higher values reject ambiguous cells.dedup_by_idcollapses repeated decodes of the same dictionary ID across cells.marker_size_relis the marker side relative to the enclosing chessboard cell and must match the physical board spec.
calib-targets-charuco
calib-targets-charuco combines chessboard detection with ArUco decoding to detect ChArUco boards. ChArUco dictionaries and board layouts are fully compatible with OpenCV’s aruco/charuco implementation. The flow is grid-first:
ChArUco detection overlay with assigned corner IDs.
For the end-to-end stage map, failure modes, and tuning, see the ChArUco pipeline; for the marker-side algorithms see ArUco bit decode and ChArUco alignment & corner IDs. This page is the crate API reference.
- Detect a chessboard grid from ChESS corners.
- Build per-cell quads from the detected grid.
- Decode markers per cell (no full-image warp).
- Align marker detections to a board specification and assign corner IDs.
Board specification
CharucoBoardSpecdescribes the board geometry:rows,colsare square counts (not inner corners).cell_sizeis the physical square size.marker_size_relis the marker size relative to a square.dictionaryselects the marker dictionary.marker_layoutdefines the placement scheme.
CharucoBoardvalidates and precomputes marker placement.
Detector
CharucoParams::for_boardprovides a reasonable default configuration.CharucoDetector::detectreturns aCharucoDetectionResultwith:detection: labeled corners with ChArUco IDs, filtered to marker-supported corners.markers: decoded marker detections in rectified grid coordinates (with optionalcorners_img).alignment: grid alignment from detected grid coordinates into board coordinates.
Per-cell decoding
The detector decodes markers per grid cell. This avoids building a full rectified image and keeps the work proportional to the number of valid squares. If you need a full rectified image for visualization, use the rectification helpers in calib-targets-chessboard on a detected grid.
Alignment and refinement
Alignment maps decoded marker IDs to board positions using a small set of grid transforms and a translation vote. Once an alignment is found, the detector re-decodes markers at their expected cell locations and re-solves the alignment to filter out inconsistencies.
This two-stage approach helps reject spurious markers while keeping the final corner IDs consistent.
Tuning notes
scan.inset_fractrades off robustness vs. sensitivity. The defaults infor_boarduse a slightly smaller inset (0.06) to improve real-image decoding.min_marker_inlierscontrols how many aligned markers are required to accept a detection.
Status
The current implementation focuses on the OpenCV-style layout and is intentionally conservative about alignment.
For alignment details, see ChArUco Alignment and Refinement.
ChArUco Alignment
calib-targets-charuco aligns decoded marker IDs to a board layout and assigns ChArUco corner IDs. Alignment is discrete and fast: it tries a small set of grid transforms and selects the translation with the strongest inlier support.
Alignment pass
- Each decoded marker votes for a board translation under each candidate transform.
- The best translation wins (ties broken by inlier count).
- Inliers are the markers whose
(sx, sy)map exactly to the expected board cell for their ID.
Inlier filtering
After alignment is chosen, the detector keeps only inlier markers and assigns ChArUco corner IDs based on the aligned grid coordinates. The final alignment in the result is a GridAlignment that maps detected grid coordinates into board coordinates.
calib-targets-puzzleboard

calib-targets-puzzleboard detects PuzzleBoard targets: checkerboards whose
interior edge midpoints carry binary dots. The dots identify the board position
inside a 501 x 501 master pattern, so a visible fragment can still produce
absolute corner IDs and object-space coordinates.
PuzzleBoard is based on Stelldinger 2024, arXiv:2409.20127.
For the end-to-end stage map, failure modes, and tuning, see the PuzzleBoard pipeline; for the decoder itself see PuzzleBoard edge-code decode. This page is the crate API reference.
Target Model
PuzzleBoardSpec describes the printable board:
rows,cols: square counts, not inner-corner counts.cell_size: physical square size.origin_row,origin_col: top-left square in the 501 x 501 master pattern.
Detected inner corners are returned as LabeledCorner values with:
grid: absolute master corner coordinates(i, j).id:j * 501 + i.target_position:(i * cell_size, j * cell_size).
Bit Layout
The board uses two embedded cyclic maps:
- map A, shape
(3, 167), for horizontal interior edges. - map B, shape
(167, 3), for vertical interior edges.
Dots encode bits directly: white dot = 0, black dot = 1.
corner (i,j) ---- A(j,i) ---- corner (i+1,j)
| |
B(j,i) B(j,i+1)
| |
corner (i,j+1) -- A(j+1,i) -- corner (i+1,j+1)
The committed blobs are src/data/map_a.bin and src/data/map_b.bin.
generate-puzzleboard-code-maps and verify-puzzleboard-code-maps are kept as
repo tools so the runtime detector does no map construction.
Detection Pipeline
The flow is grid-first:
- Run ChESS corner detection.
- Assemble one or more chessboard grid components.
- Sample every visible interior edge midpoint and estimate a bit confidence.
- Drop bits below
decode.min_bit_confidence. - Decode against the master maps over all D4 rotations/reflections.
- Assign absolute IDs and target-space positions to inlier corners.
The default decode.min_window is 4, meaning the detector requires enough
edge samples for a 4 x 4 square fragment after confidence filtering.
Rust Facade Example
use calib_targets::{detect, puzzleboard::{PuzzleBoardParams, PuzzleBoardSpec}};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let img = image::open("testdata/puzzleboard_small.png")?.to_luma8();
let spec = PuzzleBoardSpec::new(10, 10, 12.0)?;
let params = PuzzleBoardParams::for_board(&spec);
let result = detect::detect_puzzleboard(&img, ¶ms)?;
println!("{} corners", result.corners.len());
Ok(())
}
For threshold-sensitive images, use:
#![allow(unused)]
fn main() {
use calib_targets::{detect, puzzleboard::{PuzzleBoardParams, PuzzleBoardSpec}};
let img = image::GrayImage::new(1, 1);
fn run(img: &image::GrayImage) -> Result<(), Box<dyn std::error::Error>> {
let spec = PuzzleBoardSpec::new(10, 10, 12.0)?;
let configs = PuzzleBoardParams::sweep_for_board(&spec);
let result = detect::detect_puzzleboard_best(img, &configs)?;
let _ = result;
Ok(()) }
}
Search Modes
The default PuzzleBoardSearchMode::Full scans all 501 × 501 × 8 (D4, origin) candidates against the full master code. When the caller already
knows which board they printed, PuzzleBoardSearchMode::FixedBoard
matches observations directly against that declared board’s own bit
pattern under 8 × (rows+1)² candidate shifts:
#![allow(unused)]
fn main() {
use calib_targets::{detect, puzzleboard::{PuzzleBoardParams, PuzzleBoardSearchMode, PuzzleBoardSpec}};
let img = image::GrayImage::new(1, 1);
fn run(img: &image::GrayImage) -> Result<(), Box<dyn std::error::Error>> {
let spec = PuzzleBoardSpec::new(50, 50, 1.0)?;
let mut params = PuzzleBoardParams::for_board(&spec);
params.decode.search_mode = PuzzleBoardSearchMode::FixedBoard;
let _ = detect::detect_puzzleboard(img, ¶ms)?;
Ok(()) }
}
Partial-view guarantee: for a given printed board, any subset of its corners decodes to the same master IDs a full-view decode would produce. This applies equally to single-camera captures that only frame part of a large board and to multi-camera rigs where each camera sees a different fragment — in both cases overlapping corners across frames or cameras share master IDs without further stitching.
The decoder’s per-view master origin is otherwise not fixed — it shifts
with which print-corner the chessboard stage picks as local (0, 0),
which depends on what the camera sees. FixedBoard sidesteps that
entirely by scoring against the board rather than against the full
master.
FixedBoard runs 8 × (rows + 1)² × N operations, where N is the
number of confidence-filtered edge observations. At typical edge counts
even a 50 × 50 board decodes in well under 10 ms natively. The default
stays Full; switch via params.decode.search_mode as shown.
Printable Example
Canonical sample specs live in:
testdata/printable/puzzleboard_small.jsontestdata/printable/puzzleboard_mid.json
Generate one from the workspace root:
cargo run -p calib-targets --example generate_printable -- \
testdata/printable/puzzleboard_small.json \
tmpdata/printable/puzzleboard_small
Print the SVG at 100 percent scale. The generated PNG is intended for previews and regression tests.
calib-targets-marker
calib-targets-marker targets a checkerboard marker board: a chessboard grid with three circular markers near the center. The detector is grid-first and works with partial boards.
Detected circle markers and aligned grid overlay.
For the end-to-end stage map, failure modes, and tuning, see the Marker board pipeline. This page is the crate API reference.
Detection pipeline
- Chessboard detection: run
calib-targets-chessboardto obtain grid-labeled corners (partial boards are allowed). - Per-cell circle scoring: for every valid square cell, warp the cell to a canonical patch and score a circle by comparing a disk sample to an annular ring.
- Candidate filtering: keep the strongest circle candidates per polarity.
- Circle matching: match candidates to the expected layout (cell coordinates + polarity).
- Grid alignment estimation: derive a dihedral transform + translation from detected grid coordinates to board coordinates when enough circles agree.
Key types
MarkerBoardDetector: main entry point.MarkerBoardSpec: rows/cols plus the three expected circles (cell coordinate + polarity).MarkerBoardParams: layout + chessboard params + circle score + match settings.MarkerBoardDetectionResult:detection:TargetDetectionlabeled asCheckerboardMarker.alignment: optionalGridAlignmentfrom detected grid coords to board coords.
MarkerBoardDiagnostics(opt-in, from the*_with_diagnosticsentry points):circle_candidates: scored circles per cell.circle_matches: matched circles (with offsets).inliers: per-corner provenance back into the input ChESS-corner slice.alignment_inliers: number of circle matches used for the alignment.
Parameters
MarkerBoardSpec defines the board and marker placement:
rows,cols: inner corner counts.cell_size: optional square size in your world units (when set,target_positionis populated).circles: threeMarkerCircleSpecentries withcell(top-left corner indices) andpolarity.
MarkerBoardParams configures detection:
layout: theMarkerBoardSpecto detect.chessboard:DetectorParamsfor the underlying corner-grid step. The chessboard detector is scale-invariant, so the v1expected_rows/colsandcompleteness_thresholdknobs no longer apply — the marker circles supply the geometry constraint.circle_score: per-cell circle scoring parameters.match_params: candidate filtering and matching thresholds.roi_cells: optional cell ROI[i0, j0, i1, j1].
CircleScoreParams controls scoring:
patch_size: canonical square size in pixels.diameter_frac: circle diameter relative to the square.ring_thickness_frac: ring thickness relative to circle radius.ring_radius_mul: ring radius relative to circle radius.min_contrast: minimum accepted disk-vs-ring contrast.samples: samples per ring for averaging.center_search_px: small pixel search around the cell center.
CircleMatchParams controls matching:
max_candidates_per_polarity: top-N candidates to keep per polarity.max_distance_cells: optional maximum distance for a match.min_offset_inliers: minimum agreeing circles to return an alignment.
Notes
- Cell coordinates
(i, j)refer to square cells, expressed by the top-left corner indices. The cell center is at(i + 0.5, j + 0.5). alignmentmaps detected grid coordinates into board coordinates using a dihedral transform and translation.
calib-targets-print
calib-targets-print is the dedicated crate for printable target generation.
The same functionality is also exposed through the published calib-targets
facade as calib_targets::printable.
This page is the canonical guide for printable-target generation across the published Rust crates, the repo-local CLI, and the Python bindings.
What it generates
The input is one canonical JSON-backed document with:
schema_versiontarget:chessboard,charuco,marker_board, orpuzzle_boardpage: size, orientation, and margin in millimetersrender: debug overlay toggle and PNG DPI
Generation writes one output bundle:
<stem>.json<stem>.svg<stem>.png
The normalized .json file records the exact document that was rendered. SVG
and PNG are emitted from the same internal scene description, so they describe
the same board geometry.
All physical dimensions are expressed in millimeters. The board is centered in the printable area, and generation fails if the chosen page and margins do not leave enough room.
Concrete example
testdata/printable/charuco_a4.json is the canonical ChArUco example:
{
"schema_version": 1,
"target": {
"kind": "charuco",
"rows": 5,
"cols": 7,
"square_size_mm": 20.0,
"marker_size_rel": 0.75,
"dictionary": "DICT_4X4_50",
"marker_layout": "opencv_charuco",
"border_bits": 1
},
"page": {
"size": {
"kind": "a4"
},
"orientation": "portrait",
"margin_mm": 10.0
},
"render": {
"debug_annotations": false,
"png_dpi": 300
}
}
Matching examples also exist for chessboard and marker-board targets:
testdata/printable/chessboard_a4.jsontestdata/printable/marker_board_a4.jsontestdata/printable/puzzleboard_small.jsontestdata/printable/puzzleboard_mid.json
Rust quickstart
If you are using the published Rust crates today, you can either depend on the
dedicated calib-targets-print crate directly or use the calib-targets
facade re-export. The facade path stays shortest when you also want detector
APIs:
use calib_targets::printable::{write_target_bundle, PrintableTargetDocument};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = PrintableTargetDocument::load_json("testdata/printable/charuco_a4.json")?;
let written = write_target_bundle(&doc, "tmpdata/printable/charuco_a4")?;
println!("{}", written.json_path.display());
println!("{}", written.svg_path.display());
println!("{}", written.png_path.display());
Ok(())
}
The same flow is available in the workspace example:
cargo run -p calib-targets --example generate_printable -- \
testdata/printable/charuco_a4.json \
tmpdata/printable/charuco_a4
The underlying implementation crate is the published calib-targets-print
crate; within this workspace it lives at crates/calib-targets-print.
CLI quickstart
The calib-targets CLI ships with the facade crate and the Python package:
cargo install calib-targets provides the Rust binary and pip install calib-targets installs the same command as a Python console script. Both use
the same subcommand taxonomy.
List the built-in ArUco dictionaries:
calib-targets list-dictionaries
One-step generation (flags → JSON + SVG + PNG bundle):
calib-targets gen chessboard \
--out-stem tmpdata/printable/chessboard \
--inner-rows 6 --inner-cols 8 --square-size-mm 20
calib-targets gen charuco \
--out-stem tmpdata/printable/charuco_a4 \
--rows 5 --cols 7 --square-size-mm 20 \
--marker-size-rel 0.75 --dictionary DICT_4X4_50
calib-targets gen puzzleboard \
--out-stem tmpdata/printable/puzzle \
--rows 8 --cols 10 --square-size-mm 15
Two-step init → validate → generate for reviewable / committable specs:
calib-targets init charuco \
--out tmpdata/printable/charuco_a4.json \
--rows 5 --cols 7 --square-size-mm 20 \
--marker-size-rel 0.75 --dictionary DICT_4X4_50
calib-targets validate --spec tmpdata/printable/charuco_a4.json
calib-targets generate \
--spec tmpdata/printable/charuco_a4.json \
--out-stem tmpdata/printable/charuco_a4
validate prints valid <target-kind> on success and exits non-zero if the
spec fails printable validation.
Both init and gen support all four target families: chessboard,
charuco, puzzleboard, marker-board. Page and render options
(--page-size, --orientation, --margin-mm, --png-dpi,
--debug-annotations) are shared across every subcommand.
Python quickstart
The Python bindings expose the same printable document model and write API:
.venv/bin/python crates/calib-targets-py/examples/generate_printable.py \
tmpdata/printable/charuco_a4_py
That example constructs a small ChArUco document in Python and writes the same three-file bundle.
Printing guidance
For a physically accurate calibration target:
- Print at 100% scale or “actual size”.
- Disable “fit to page”, “scale to fit”, or similar printer-driver options.
- Prefer the generated SVG when sending the target to a print workflow that preserves vector geometry.
- After printing, measure at least one known square width with a ruler or
caliper and confirm it matches
square_size_mm. - If the printed size is wrong, fix the print dialog or driver scaling and reprint instead of compensating in calibration code.
Choosing an entry point
- Use
calib_targets::printablewhen you want the published Rust facade crate. - Use
calib-targets-printwhen you want the dedicated published printable-target crate. - Use the
calib-targetsCLI (cargo install calib-targetsorpip install calib-targets) when you want a command-line init/render tool. - Use the Python bindings when your downstream workflow is already in Python.
calib-targets (facade)
The calib-targets crate is the unified entry point for the workspace. It re-exports the lower-level crates and provides optional end-to-end helpers in calib_targets::detect (feature image, enabled by default).
Facade examples cover detection and rectification workflows.
Single-config detection
Each detect_* function takes a single params struct. The chessboard
detector uses the workspace’s default_chess_config() for ChESS
corner detection automatically; ChArUco / PuzzleBoard / marker board
params embed a DetectorParams under params.chessboard.
#![allow(unused)]
fn main() {
use calib_targets::detect;
use calib_targets::chessboard::DetectorParams;
let img = image::open("board.png").unwrap().to_luma8();
let params = DetectorParams::default();
let result = detect::detect_chessboard(&img, ¶ms);
}
Multi-config sweep
For challenging images (uneven lighting, Scheimpflug optics), try multiple parameter configs and keep the best result:
#![allow(unused)]
fn main() {
use calib_targets::detect;
use calib_targets::charuco::{CharucoBoardSpec, CharucoParams};
use calib_targets::aruco::builtins;
let img = image::open("charuco.png").unwrap().to_luma8();
let board = CharucoBoardSpec::new(22, 22, 1.0, 0.75, builtins::DICT_4X4_1000)
.with_marker_layout(calib_targets::charuco::MarkerLayout::OpenCvCharuco);
let configs = CharucoParams::sweep_for_board(&board);
let result = detect::detect_charuco_best(&img, &configs);
}
sweep_for_board() returns three configs with different ChESS thresholds
(default, high, low). detect_charuco_best tries each and returns the result
with the most markers (then most corners).
PuzzleBoard follows the same facade shape. Its sweep also includes a hard-weighted fallback for high-distortion fragments:
#![allow(unused)]
fn main() {
use calib_targets::detect;
use calib_targets::puzzleboard::{PuzzleBoardParams, PuzzleBoardSpec};
let img = image::open("puzzleboard.png").unwrap().to_luma8();
let spec = PuzzleBoardSpec::new(10, 10, 12.0).unwrap();
let configs = PuzzleBoardParams::sweep_for_board(&spec);
let result = detect::detect_puzzleboard_best(&img, &configs);
}
Features
image(default): enablescalib_targets::detect.tracing: enables tracing output across the subcrates.diagnostics(off): forwards to thediagnosticsfeature of the chessboard, ChArUco, and puzzleboard subcrates, gating their serializable trace surfaces (the chessboardtrace_topological/GeometryCheckTrace, and the ChArUco / puzzleboard per-component decode diagnostics). The detectors build no per-stage trace on the hotdetect_*paths unless this is enabled (thedatasetfeature oncalib-targets-chessboardimplies it). ChArUco and puzzleboard diagnostics are gated behind the same feature as chessboard rather than being always-on.
See the Migration Guide for the full breaking-change list when upgrading from an earlier release.
Examples
Examples live under crates/*/examples/ and are built per crate. The
facade examples under calib-targets take an image path directly;
the lower-level crate examples synthesize their own inputs or accept a
JSON config file (defaults point to testdata/ or tmpdata/).
To run an example from the workspace root:
# Standalone projective-grid — synthesizes its own oriented features:
cargo run -p projective-grid --example hello_grid
# Image-in / detection-out via the facade crate:
cargo run -p calib-targets --example detect_chessboard -- testdata/mid.png
The standalone projective-grid crate ships
three onboarding examples that need no image files — hello_grid
(the minimal detect-a-grid quickstart), detect_square_oriented2 (a
larger detection run), and check_square_consistency (scoring
caller-supplied labels). The image-free chessboard detector has its
own minimal onboarding program, cargo run -p calib-targets-chessboard --example detect_chessboard.
Python examples live under crates/calib-targets-py/examples/ and use the calib_targets module.
After maturin develop, run them with an image path, for example:
python crates/calib-targets-py/examples/detect_charuco.py testdata/small2.png
python crates/calib-targets-py/examples/detect_puzzleboard.py testdata/puzzleboard_small.png
See the sub-chapters for what each example produces and how to interpret the outputs.
Regular Grid Detection Example
Reference: crates/projective-grid/examples/hello_grid.rs — the
minimal, image-free story for the standalone
projective-grid crate: a handful of oriented
feature points go in, a labelled (i, j) grid comes out, with no
image and no other workspace crate.
Quick run
cargo run -p projective-grid --example hello_grid
The example synthesizes its own input (no image files needed): a small square lattice with a mild perspective shear, so the cloud looks like a board photographed at a slight angle.
Walkthrough
1. Build oriented features
projective-grid knows nothing about images — for the square detector
it works on OrientedFeature<F, 2> values: a position plus two
roughly-orthogonal local axis directions. The example builds a 3×3
grid by hand. In a real application these positions and axes would come
from a corner detector, a blob detector with a local-orientation
estimate, or a laser-dot extractor.
Each feature pairs a PointFeature (an image-frame position plus a
stable, caller-owned source_index) with two LocalAxis directions
(an angle in radians plus an optional angular uncertainty sigma):
#![allow(unused)]
fn main() {
use nalgebra::Point2;
use projective_grid::{LocalAxis, OrientedFeature, PointFeature};
let mut features: Vec<OrientedFeature<f32, 2>> = Vec::new();
for j in 0..3 {
for i in 0..3 {
// Image-frame position (origin top-left, x right, y down).
// The `+ j * 6.0` term shears each successive row, so this is a
// genuine projective grid, not a perfectly axis-aligned one.
let x = 60.0 + i as f32 * 40.0 + j as f32 * 6.0;
let y = 50.0 + j as f32 * 40.0;
// `source_index` is a stable handle; the solution reports it back
// so a recovered `(i, j)` label maps to the exact input feature.
let point = PointFeature::new(features.len(), Point2::new(x, y));
// Two undirected, roughly-orthogonal axes: horizontal (0 rad)
// and vertical (pi/2 rad), each with a small angular sigma.
let axes = [
LocalAxis::new(0.0, Some(0.02)),
LocalAxis::new(std::f32::consts::FRAC_PI_2, Some(0.02)),
];
features.push(OrientedFeature::new(point, axes));
}
}
}
2. One call: detect_grid
Wrap the features as Evidence::Oriented2, bundle them into a
DetectionRequest for a Square lattice, and call detect_grid. Grid
dimensions are unknown (None); the detector infers the extent.
#![allow(unused)]
fn main() {
use projective_grid::{
detect_grid, DetectionParams, DetectionRequest, Evidence, LatticeKind,
};
let request = DetectionRequest::new(
LatticeKind::Square,
Evidence::Oriented2(&features),
None, // grid dimensions unknown; the detector infers the extent
DetectionParams::default(),
);
let solution = detect_grid(request)?;
assert_eq!(solution.grid.entries.len(), 9);
}
DetectionParams::default() carries a max_residual_px fit gate and runs
the topological grid builder — the sole builder, so there is no algorithm to
select. It runs a
Delaunay triangulation over the corner cloud, classifies edges by axis
match, merges triangle pairs into cells, and floods integer coordinates
across the mesh, then fits a projective transform. See the
Topological grid finder algorithm page for the
full method.
3. Handle the Result
Detection returns Result<GridSolution, GridError>. GridError is
#[non_exhaustive], so callers always need a wildcard arm. The
variants worth matching:
UnsupportedCombination { task, lattice, evidence }— the requested(lattice, evidence)pair has no algorithm yet. Today only(Square, Oriented2)is solved; everything else (aHexlattice, orPositions/Oriented1/Oriented3evidence) returns this rather than a guessed answer.InsufficientEvidence— too few features to assemble a2×2seed.DegenerateGeometry— coincident or collinear points; no usable lattice spread.InconsistentInput(String)— input slices disagree or carry duplicatesource_indexhandles.
4. Read the result
A successful detection is a GridSolution:
grid: LabelledGrid— the recovered component.grid.entriesis oneGridEntryper placed feature;grid.bboxis the inclusive coordinate bounding box;grid.dimensionsechoes any caller-suppliedGridDimensions.fit: Option<LatticeFit>— the fitted model-plane-to-image projective transform (model_to_image) plus a residual summary (residuals.count,residuals.mean_px,residuals.max_px).rejected: Vec<RejectedFeature>— features this component could not place, each with aRejectionReason(Unlabelled,ValidationDropped, orResidualTooHigh).
Each GridEntry carries:
coord: Coord— the(i, j)label ascoord.u/coord.v, rebased so the labelled bounding box starts at(0, 0).source_index: usize— back into the input slice.image_position: Point2<F>— the feature’s image-frame pixel-center position.residual_px: Option<F>— reprojection residual in pixels, present when a fit was computed.
#![allow(unused)]
fn main() {
for entry in &solution.grid.entries {
// coord.u = i, coord.v = j; source_index maps back to the input.
println!(
"(i={}, j={}) <- feature {} at ({:.1}, {:.1})",
entry.coord.u,
entry.coord.v,
entry.source_index,
entry.image_position.x,
entry.image_position.y,
);
}
}
Running it labels all nine features (0,0) through (2,2) with a
sub-pixel fit residual.
Going further
- Multiple components —
detect_grid_allreturns aDetectionReportwhosesolutionsvector holds oneGridSolutionper recovered component, ordered by labelled count descending. Use it when the lattice fragments into islands (for example by occlusion) and the secondary components matter. The topological algorithm may yield several components. - Checking caller-supplied labels — when
(i, j)labels already exist (for instance from a marker decode),check_consistencyscores them against a single projective fit instead of recovering them from scratch. The runnable version iscrates/projective-grid/examples/check_square_consistency.rs. - A larger detection run —
crates/projective-grid/examples/detect_square_oriented2.rsexercises the sameEvidence::Oriented2path on a bigger synthetic grid.
See the projective-grid chapter for the full
model — the two lattice families, the Evidence shapes, and the
topological algorithm.
Chessboard Detection Example
Reference: crates/calib-targets/examples/detect_chessboard.rs —
end-to-end image-in / detection-out using the facade’s default
chessboard configuration.
Example output overlay for chessboard detection on testdata/mid.png.
Quick run
cargo run --release -p calib-targets --example detect_chessboard -- testdata/mid.png
The example:
- Decodes the image with
image::open(...).to_luma8(). - Calls
calib_targets::detect::detect_chessboard(&img, &DetectorParams::default()). - Prints the detected
Detection— labelled corner count, cell size, the two grid-direction angles, and every(i, j) → pixel_positionpair.
If detection fails (None), rerun with the _best helper, which
tries three pre-tuned configs (default + tighter + looser) and returns
whichever produced the most labelled corners:
cargo run --release -p calib-targets --example detect_chessboard_best -- testdata/mid.png
Instrumentation
The chessboard crate’s diagnostic entry point is
calib_targets_chessboard::trace_topological(corners, params),
which returns a serializable TopologicalTrace layered over the
production detect_grid_all path (so the trace matches what detect()
actually does). It records every input corner with its usable flag, the
labelled connected components ((u, v) -> source_index), and summary
counters; the final-check drop accounting lives in the pipeline’s
GeometryCheckTrace. See
The Chessboard Detector §7
for the field-by-field walkthrough.
The crates/calib-targets-py/examples/overlay_chessboard.py script draws
labelled corners in gold with their (i, j) text, grid edges,
cluster-direction tangent lines, and the faint grey input-corner cloud as
context.
Direct crate-level usage
If you need control over the ChESS corner front-end (e.g., custom
ChessConfig), bypass the facade:
#![allow(unused)]
fn main() {
use calib_targets::detect::{default_chess_config, detect_corners};
use calib_targets_chessboard::{Detector, DetectorParams};
use image::ImageReader;
let img = ImageReader::open("board.png").unwrap().decode().unwrap().to_luma8();
let chess_cfg = default_chess_config();
let corners = detect_corners(&img, &chess_cfg);
let params = DetectorParams::default();
let detector = Detector::new(params);
if let Some(detection) = detector.detect(&corners) {
println!("{} labelled corners", detection.corners.len());
}
}
Detector::detect_all(&corners) returns every same-board component
found in the scene (see the chessboard chapter for
the multi-component contract).
Global Rectification Example
File: crates/calib-targets-chessboard/examples/rectify_global.rs
This example detects a chessboard and computes a single global homography to produce a rectified board view. The output includes:
Global rectification output from the small test image.
- A rectified grayscale image.
- A JSON report with homography matrices and grid bounds.
The code defaults to tmpdata/rectify_config.json, but a ready-made config exists in
testdata/rectify_config.json (input: testdata/small.png, rectified output:
tmpdata/rectified_small.png, report: tmpdata/charuco_report_small.json).
Run it with:
cargo run -p calib-targets-chessboard --example rectify_global -- testdata/rectify_config.json
If rectification succeeds, the rectified image is written to tmpdata/rectified.png unless
overridden in the config.
Mesh Rectification Example
File: crates/calib-targets-aruco/examples/rectify_mesh.rs
This example detects a chessboard, performs per-cell mesh rectification, and scans the rectified grid for ArUco markers. It writes:
Per-cell mesh rectification output from the small test image.
- A mesh-rectified grayscale image.
- A JSON report with rectification info and marker detections.
The code defaults to testdata/rectify_mesh_config_small0.json, and that config is a good
starting point (input: testdata/small0.png, mesh output: tmpdata/mesh_rectified_small0.png,
report: tmpdata/rectify_mesh_report_small0.json).
Run it with:
cargo run -p calib-targets-aruco --example rectify_mesh -- testdata/rectify_mesh_config_small0.json
This is a good reference for the full grid -> rectification -> marker scan pipeline.
Data and Tools
This repository includes data and scripts that support testing and debugging.
Data folders
testdata/: sample images and JSON configs used by examples and tests.
Tools
The tools/ folder contains helper scripts for visualization and synthetic data generation, for example:
- Overlay plotting for chessboard, marker, and ChArUco outputs.
- Synthetic marker target generation.
- Dictionary utilities.
These tools are optional but useful when debugging or extending detectors.