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::ChessboardParams;
use calib_targets::image::ImageReader; // re-exported; version always matches
fn main() -> Result<(), Box<dyn std::error::Error>> {
let img = ImageReader::open("board.png")?.decode()?.to_luma8();
let params = ChessboardParams::default();
let result = detect::detect_chessboard(&img, &detect::default_chess_config(), ¶ms);
println!("detected: {}", result.is_ok());
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, plus diagnose_charuco,
diagnose_puzzleboard, and diagnose_marker_board for the
diagnostics-returning counterparts. 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.board.cell_size is set and alignment succeeds.
MSRV: Rust 1.91 (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.13"
The image crate ships with calib-targets (default image feature) and is
re-exported as calib_targets::image, so you do not need a separate image
dependency — importing through the re-export keeps your GrayImage type
version-matched to the helpers. (A direct image = "0.25" dependency still
works.)
use calib_targets::charuco::{CharucoBoardSpec, CharucoParams, MarkerLayout};
use calib_targets::detect;
use calib_targets::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 crate root contains the ordinary detection facade. Detector builders use
the curated composition paths
projective_grid::expert::lattice::{predict_grid_position, GridTransform} and
projective_grid::expert::geometry::estimate_homography; Coord and
LatticeKind remain at the root. GridTransform is the single affine
integer-grid mapping (matrix * coordinate + translation) shared by lattice
symmetries and target alignments. It never acts on image pixels.
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) |
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) or (Hex, Oriented1/Oriented2) — 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.
let request = DetectionRequest::new(
LatticeKind::Square,
Evidence::Oriented2(&features),
);
// `detect_grid` returns the largest recovered component.
let detection = detect_grid(request)?;
for entry in detection.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
GridDetection. When the lattice is split into islands (for example by
occlusion) and the secondary components matter, call detect_grid_all:
it returns a Vec<GridDetection> ordered by labelled-count descending.
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) is the ordinary entry point.
Optional GridDimensions and stable parameters use the named
with_dimensions / with_params builders. Stage-specific controls live in
the opt-in projective_grid::expert::DetectionTuning namespace.
Outputs
A successful detection is a GridDetection:
| Field | Meaning |
|---|---|
grid() | Accepted entries, lattice family, inclusive bbox, and optional dimensions. |
fit() | Mandatory model-plane-to-image transform and residual summary. |
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.
Rejected features and exact stage traces are intentionally separate from the
ordinary result. Detector builders and evidence campaigns enable the
diagnostics feature and use projective_grid::diagnostics::detect_grid*.
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 exposes passed(),
grid(), fit(), rejected(), and max_residual_px().
check_square_consistency in
the examples directory is the runnable version.
This is the only entry point that consumes coordinate hypotheses; detection’s
Evidence enum does not include an unsupported placeholder variant.
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::ChessboardParams;
let params = ChessboardParams::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
ChessboardParams — 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::ChessboardDetector::new(params).detect(&corners).
For ChArUco, CharucoParams.chessboard is a ChessboardParams: 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::ChessboardParams;
use calib_targets::charuco::CharucoParams;
let img: image::GrayImage = todo!();
let board = todo!();
let chess_configs = ChessboardParams::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);
}
ChessboardParams::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 ChessboardDetector::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
ChessboardParams::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 ChessboardParams::with_advanced(...). ChArUco /
PuzzleBoard decode.* knobs sit on their own config structs.
| Symptom | Parameter to adjust |
|---|---|
detect_chessboard returns Err(NoDetection) | 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::ChessboardParams
ChessboardParams is a #[non_exhaustive] struct split into two surfaces:
- a stable core of three fields covered by semver —
min_labeled_corners,max_components, andmin_corner_strength(see Output gates and Stage 1 below); - an opt-in
advancedsub-struct (Option<Box<ChessboardAdvancedTuning>>) holding the ~40 per-stage knobs.ChessboardAdvancedTuningis NOT covered by semver — leave it unset unless a specific input fails and you have evidence for the change.
Attach overrides with ChessboardParams::with_advanced(tuning) and read the
effective tuning with effective_tuning(). ChessboardAdvancedTuning is
#[non_exhaustive], so build it from ChessboardAdvancedTuning::default() and
mutate the knobs you need:
#![allow(unused)]
fn main() {
use calib_targets::chessboard::{ChessboardAdvancedTuning, ChessboardParams};
let mut advanced = ChessboardAdvancedTuning::default();
advanced.cluster_tol_deg = 16.0;
advanced.attach_search_rel = 0.5;
let params = ChessboardParams::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
A corner’s axes are admitted when strength ≥ min_corner_strength and
max(σ₀, σ₁) ≤ advanced.topological.axis_align_tol_rad. Corners that fail
either half are kept as positions (so corner indices stay stable) but carry
no-information axes and cannot classify Delaunay edges.
| Field | Default | Guidance |
|---|---|---|
min_corner_strength | 33.0 | The only magnitude gate on the corner set. Lower it and marker-bit / noise saddles enter grid construction; raise it on scenes with many spurious saddles at the cost of recall on soft corners. |
There is no second pre-filter knob. The axis half of the gate is derived
from axis_align_tol_rad (default 15°, a Stage 3 tolerance) rather than
configured separately: an axis whose own 1σ uncertainty is wider than the
alignment window cannot answer the question the cell test poses, so it must
not vote. Tying the two together is deliberate — loosening the cell test
automatically loosens admission by exactly the same amount.
Removed in 0.11.0.
max_fit_rms_ratio(fit_rms ≤ ratio × contrast) is gone: chess-corners 1.0 no longer reportscontrastorfit_rms. See the Migration Guide.
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 ChessboardParams::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_chessboardreturnsErr(NoDetection)): runcalib_targets_chessboard::trace_topological(gated behind the chessboard crate’s off-by-defaultdiagnosticsfeature) — 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 reports no board
The facade detect_chessboard returns Err(DetectError::NoDetection) when
no board is found — a miss carries no specific cause, only that some stage
declined to emit a detection. 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, ChessboardParams};
let img: image::GrayImage = todo!();
let corners = detect_corners(&img, &default_chess_config());
match trace_topological(&corners, &ChessboardParams::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}"),
}
trace_topological(and theTopologicalTracetype) are gated behind the chessboard crate’s off-by-defaultdiagnosticsfeature. Enable it withcalib-targets-chessboard’sdiagnosticsfeature — or the facade’sdiagnosticsfeature — to call it; the hotdetect_*path builds no trace.
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(8.0)to drop the noise floor below the workspace default of15.0. Since chess-corners 1.0 the threshold is a plain absolute response floor (f32); theThresholdenum and its relative mode are gone — ChESS reads only an absolute floor now. -
Corners found but few
usable? The prefilter is rejecting most corners — either onstrengthor because their axes are too uncertain to vote (max(σ₀, σ₁) > axis_align_tol_rad). Lowermin_corner_strength, 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_bestwithChessboardParams::sweep_default()(widens the clustering and attachment tolerances). -
Components found but
detect_chessboardstill reports no board? 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 returns Err(NoDetection) | 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 | CharucoDetection::target_detection() |
TargetKind::PuzzleBoard | PuzzleBoardDetection::target_detection() |
TargetKind::CheckerboardMarker | MarkerBoardDetection::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 board.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: CharucoDetection
detect_charuco returns CharucoDetection rather than a bare TargetDetection:
#![allow(unused)]
fn main() {
pub struct CharucoDetection {
pub corners: Vec<CharucoCorner>,
pub markers: Vec<MarkerDetection>,
pub alignment: GridAlignment, // alias of the canonical GridTransform
}
}
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.
Migrating to 0.13.0
Workspace 0.13.0 gives the detector structs and the facade free functions a single, symmetric surface. This is a deliberate pre-1.0 breaking release.
Detection geometry, pixel-coordinate conventions and grid-label semantics are unchanged, and the API restructuring itself changes no results — the corner pass moved, it did not change.
Behaviour does change in one place: the PuzzleBoard decoder, in three deliberate ways, each described below.
| change | effect on which frames decode |
|---|---|
| Rotations, not reflections | more — an unreachable mirrored hypothesis can no longer force a rejection |
| Period-3 consensus | more on noisy frames; some very noisy ones are now refused explicitly instead of decoded on thin evidence |
| The window gates agree on their unit | more — fragments at exactly the documented minimum are no longer rejected unread |
No detector becomes more permissive about what it will claim. All three changes leave the uniqueness proof intact, and the second strengthens it.
Every Rust break in this release is a compile error at the call site. Nothing keeps compiling with a changed meaning.
Why
The three composite params structs each carried a chess: DetectorConfig
field that no detector ever read. Only the facade free functions ran the ChESS
corner pass, so a caller who wanted to configure corner detection and hold a
reusable detector had to abandon the detector API, run the corner pass by hand,
and feed the corners back in. Every binding crate hand-rolled that same glue.
Detectors can now run the corner pass themselves, from the field they already owned.
Detectors take an image
detect is now the whole pipeline from an image. The old corner-consuming
method is still there under an explicit name.
// before
let corners = calib_targets::detect::detect_corners(&img, ¶ms.chess);
let detector = PuzzleBoardDetector::new(params)?;
let detection = detector.detect(&gray_view(&img), &corners)?;
// after
let detector = PuzzleBoardDetector::new(params)?;
let detection = detector.detect(&view)?;
The corner-injection path is unchanged in behaviour, only in name — use it when one corner pass feeds several target detectors:
let corners = detector.detect_corners(&view);
let a = charuco_detector.detect_with_corners(&view, &corners)?;
let b = puzzle_detector.detect_with_corners(&view, &corners)?;
detect_corners on a detector runs exactly the pass that detector’s own
params.chess configures, so the corners you inject match the ones detect
would have produced. That identity holds by construction and is covered by a
test:
d.detect(image) == d.detect_with_corners(image, &d.detect_corners(image))
This applies to CharucoDetector, PuzzleBoardDetector and
MarkerBoardDetector.
ChessboardDetector is unchanged. It consumes a corner cloud by design —
ChessboardParams deliberately has no chess field, because that struct is
embedded inside all three composite params types and a nested corner-detector
config there would be dead in exactly the way this release removes. Configure
the corner pass for a chessboard through the explicit argument that
detect_chessboard(img, chess_cfg, params) already takes.
detect_with_diagnostics is now diagnose
// before
let (result, diag) = detector.detect_with_diagnostics(&view, &corners);
// after — from an image
let (result, diag) = detector.diagnose(&view);
// after — from corners you already have
let (result, diag) = detector.diagnose_with_corners(&view, &corners);
The _with_corners suffix now means one thing everywhere: you supply the
corners.
New facade free functions
Each of charuco, puzzleboard and marker_board now has the same five
entry points, and each is definitionally the detector one-liner:
detect_t(img, params)
detect_t_with_corners(img, corners, params)
diagnose_t(img, params) // `diagnostics` feature
diagnose_t_with_corners(img, corners, params) // `diagnostics` feature
detect_t_best(img, configs)
Arguments are data first, config last, matching the existing
detect_corners(img, cfg).
Two notes:
detect_puzzleboard_with_cornersexisted privately with the arguments in the order(img, params, corners). It is now public and takes(img, corners, params).- The
diagnose_*free functions return(Result<TDetection, DetectError>, Option<TDiagnostics>). TheOptionis not cosmetic: the facade has one failure mode the detector method does not, since it constructs the detector for you andTDetector::newcan reject bad parameters before the pipeline runs.Nonemeans exactly that.
Chessboard diagnostics are now reachable through the facade too —
trace_topological and trace_topological_detection are re-exported under the
diagnostics feature. Previously a Rust caller had to depend on
calib-targets-chessboard directly.
MarkerBoard reports why it failed
MarkerBoardDetector returned Option, which collapsed two very different
failures into one None. It now matches ChArUco and PuzzleBoard:
// before
let Some(detection) = detector.detect(&view, &corners) else { /* why? */ };
// after
match detector.detect(&view) {
Ok(detection) => { /* … */ }
Err(MarkerBoardDetectError::ChessboardNotDetected) => { /* no grid */ }
Err(MarkerBoardDetectError::AlignmentFailed { matched, candidates }) => {
// grid found; the three circle markers did not agree with the board
}
Err(_) => { /* the enum is #[non_exhaustive] */ }
}
The set of inputs that succeed is unchanged — only the failure value carries information now.
Two consequences:
diagnose/diagnose_with_cornersreturn diagnostics even when detection fails, as ChArUco and PuzzleBoard already did. On an alignment failure you get the scored circle candidates and the attempted matches, which is when you most want them.- The facade’s
detect_marker_boardnow returnsDetectError::MarkerBoardDetect(..)instead ofDetectError::NoDetection { target: CheckerboardMarker }. detect_marker_board_bestreports the last config’s ownMarkerBoardDetectErrortoo, matching whatdetect_charuco_bestanddetect_puzzleboard_bestalready did. It still yieldsNoDetectionin the one case where there is no typed error to report: no config got as far as attempting a detection, becauseconfigswas empty or every config was rejected byMarkerBoardDetector::new.
DetectError::NoDetection therefore remains, but now only for chessboard and
for that empty-sweep case. If you match on it to mean “the marker board was not
found”, match DetectError::MarkerBoardDetect(..) instead — and keep a
wildcard arm, since DetectError is #[non_exhaustive].
MarkerBoardDetector::detect_all deliberately does not exist. A marker board is
localised by its three circle markers, which realistically fall inside one
connected grid component; ChArUco and PuzzleBoard can be anchored from two
disjoint fragments and therefore consume every component. That difference is
intentional.
MarkerBoardParams::sweep_for_board (additive)
detect_marker_board_best existed with no preset to feed it. There is one now,
mirroring CharucoParams::sweep_for_board and
PuzzleBoardParams::sweep_for_board:
let configs = MarkerBoardParams::sweep_for_board(&spec);
let detection = detect_marker_board_best(&img, &configs)?;
It sweeps the shared grid-build axis only. No circle-scoring or matching constants are varied.
Bindings
Python and the WASM package:
| before | after |
|---|---|
detect_charuco_with_diagnostics | diagnose_charuco |
detect_marker_board_with_diagnostics | diagnose_marker_board |
detect_puzzleboard_with_diagnostics | diagnose_puzzleboard |
C ABI:
| before | after |
|---|---|
ct_charuco_detector_detect_diagnostics_json | ct_charuco_detector_diagnose_json |
ct_marker_board_detector_detect_diagnostics_json | ct_marker_board_detector_diagnose_json |
ct_puzzleboard_detector_detect_diagnostics_json | ct_puzzleboard_detector_diagnose_json |
The C ABI version goes to 4.0.0. Beyond those three symbol renames, two PuzzleBoard structs grow fields, so this is a struct-layout break as well as a link-time one:
ct_puzzleboard_decode_config_tgainssymmetry_mode(CT_PUZZLEBOARD_SYMMETRY_MODE_ROTATIONS/..._ROTATIONS_AND_REFLECTIONS).ct_puzzleboard_result_tgainslogical_bits,logical_bit_error_rateanddot_dissent_rate.
One semantic change: ct_marker_board_detector_diagnose_json now returns
CT_STATUS_OK with a well-formed payload on a failed detection, where it
previously returned CT_STATUS_NOT_FOUND. That matches the ChArUco and
PuzzleBoard diagnostics entry points, and it is the case where the evidence is
most worth having.
Recompile — do not relink — against the shipped
include/calib_targets_ffi.h.
Python: parameter defaults and sweep presets
Two Python-only fixes. Neither changes the Rust pipeline, and neither affects callers who set the fields explicitly.
Defaults now match Rust. The dataclasses in calib_targets.config mirror
the Rust params structs, and five of their literal defaults had drifted:
| field | was | now |
|---|---|---|
PuzzleBoardDecodeConfig.min_window | 4 | 7 |
ChessboardParams.min_corner_strength | 0.0 | 33.0 |
CircleScoreParams.min_contrast | 60.0 | 10.0 |
CharucoParams.min_marker_inliers | 3 | 1 |
ScanDecodeConfig.min_border_score | 0.45 | 0.75 |
The first two are precision defects, not preferences. min_window is the
bounded-distance uniqueness floor: at 4 the decoder accepted fragments far
below the size at which a decode can be proven unique, which is a latent
false-positive path, and a wrong absolute corner ID cannot be recovered
downstream. min_corner_strength is the floor that clears false corners
produced by marker bits — and since ChessboardParams is embedded in
CharucoParams and MarkerBoardParams, that default leaked into three
detectors.
Expect slightly lower recall and materially better precision on the affected paths. If you were relying on the old permissive values, set them explicitly and be aware of what they disable.
Sweep presets are Rust-computed. PuzzleBoardParams.sweep_for_board had
drifted onto a different axis from the Rust preset of the same name — it
varied the ChESS corner-detector threshold where Rust varies the grid-graph
angular tolerances. The config count is unchanged at six; the axis is now
Rust’s, so detect_puzzleboard_best searches the same space from either
language.
ChessboardParams.sweep_default() and CharucoParams.sweep_for_board(board)
had no Python surface at all and are now available:
configs = ct.CharucoParams.sweep_for_board(board)
detection = ct.detect_charuco_best(img, configs)
PuzzleBoard searches rotations, not reflections
This release also changes a default. A camera imaging the printed side of an opaque planar board can see it rotated by a multiple of 90°, but never mirrored — a rigid pose composed with a perspective projection preserves handedness. The decoder previously searched all eight dihedral relabellings, so half of every search was physically unreachable, and an unreachable hypothesis that happened to match the observed bits could still compete in the uniqueness gate and turn a correct decode into a rejection.
The default is now the four rotations. Decoding is substantially faster and clean-window uniqueness begins at a smaller fragment.
// restore the previous eight-transform search
params.decode.symmetry_mode = PuzzleBoardSymmetryMode::RotationsAndReflections;
Set that when the optical path flips handedness — a mirror or beam splitter in the path, or an image mirrored before detection. Under the default a mirrored view declines to decode rather than returning a wrong absolute labelling.
PuzzleBoard corrects errors before it gates
Both PuzzleBoard code maps repeat every three rows or columns. A fragment
sampling ~2w² dots therefore reads only ~6w distinct master bits, each of
them several times over. The decoder now reduces the dots to those bits by
confidence-weighted majority vote before the accept/reject gates run — the
error correction the pattern was designed around, and which the crate had not
been using.
Nothing to change at the call site. It is on by default and the reduction is pure topology: which dots share a bit depends only on the grid, not on where the fragment sits or how it is oriented, so it runs before any pose hypothesis exists.
Three things are visible:
A new error variant. PuzzleBoardDetectError::NotEnoughLogicalBits { determined, needed } fires when a fragment spans enough corners but too many
of its bit-classes split evenly to resolve. An even split is treated as an
erasure, not guessed. This is a new rejection path, and it is deliberately
strict: over a handful of surviving bits, margin > k_winner still proves the
winner is the only codeword within its error radius, but many master positions
are inside that radius, so the proof holds while saying nothing. Match it with
a wildcard arm — PuzzleBoardDetectError is #[non_exhaustive], so this is
additive, not a compile break.
Three new fields on PuzzleBoardDecodeInfo (also #[non_exhaustive], and
mirrored into ct_puzzleboard_result_t and the Python/WASM result shapes):
| field | what it tells you |
|---|---|
logical_bits | the fragment’s true code length. edges_observed / logical_bits is the redundancy the board gave you |
logical_bit_error_rate | error rate after voting — this is what max_bit_error_rate gates on now |
dot_dissent_rate | hypothesis-free read quality: computed before any pose is hypothesised, so it is meaningful even when the decode is wrong |
dot_dissent_rate is the one to reach for when a frame misbehaves. It rises
sharply when the grid labelling is wrong — which mixes dots reading different
master bits into one class — and stays near zero on a board that is merely
noisy. Read it as “clean” versus “not clean”; it is monotone in the dot error
rate but not calibrated to it.
The gates moved domain. max_bit_error_rate and the uniqueness gate are
now evaluated over voted logical bits rather than physical dots. At the minimum
window each logical bit is read twice, so one bad bit used to count as two
mismatches and both gates were over-conservative by construction. The
bit_error_rate field still reports the raw physical rate, so if you had
calibrated a threshold against it, compare against logical_bit_error_rate
instead.
If you need the old behaviour for an A/B, it is one flag:
let mut tuning = PuzzleBoardAdvancedTuning::new();
tuning.edge_consensus = false;
params.decode = params.decode.with_advanced(tuning);
The two window gates agreed on a number, not a unit
min_window is a span in corners, and the span gate read it that way — but
the edge-count pre-filter read it as a count of squares, demanding 84
interior edges where a 7-corner window yields 60. Fragments spanning exactly
the documented minimum were rejected before they were ever decoded.
The floor is now the interior edge count of the span the gate actually requires. This loosens a pre-filter, so slightly smaller fragments reach the decoder — but they still have to clear the uniqueness gate to be returned, and 60 edges is exactly 30 distinct bits, the exhaustively verified floor at which every master position is uniquely determined under the four rotations.
crates/calib-targets-puzzleboard/src/lib.rs documented the unit as “squares”;
that is corrected too.
PuzzleBoardSpec::master(cell_size) (additive)
Declaring the whole master pattern no longer means spelling out its size:
// before
let spec = PuzzleBoardSpec::new(501, 501, 1.0)?;
// after
let spec = PuzzleBoardSpec::master(1.0)?;
This is the right default for detection — a PuzzleBoard identifies itself, so
the detector does not need to know which sub-rectangle was printed. Declare the
actual printed board instead when you use
PuzzleBoardSearchMode::FixedBoard, which restricts the search to that
board’s extent; a declared 501×501 rectangle is the one case where FixedBoard
is slower than Full.
The constructor is mirrored on the Python dataclass as
PuzzleBoardSpec.master(cell_size).
Migrating to 0.12.0
Workspace 0.12.0 and projective-grid 0.13.0 consolidate integer grid
algebra around one affine GridTransform. This is a deliberate pre-1.0
breaking release. Detection geometry and pixel-coordinate conventions are
unchanged.
projective-grid imports
The crate root remains the ordinary detection facade. Reusable detector-
builder primitives live under expert; the removed root imports are not
restored.
use projective_grid::{Coord, LatticeKind};
use projective_grid::expert::lattice::{
predict_grid_position, GridTransform,
};
use projective_grid::expert::geometry::estimate_homography;
In particular, migrate these old imports:
use projective_grid::{Coord, predict_grid_position};
use projective_grid::{Coord, GridTransform};
use projective_grid::geometry::estimate_homography;
to the expert paths above. Coord and LatticeKind remain at the crate root.
One affine grid transform
projective-grid now owns the only implementation. It represents
destination = matrix * source + translation
for one lattice family. The matrix is row-major. Grid coordinates use u
right and v down; the transform contains no image or pixel coordinates.
Before:
use calib_targets_core::{GridAlignment, GridTransform};
let alignment = GridAlignment {
transform: GridTransform { a: 0, b: 1, c: -1, d: 0 },
translation: [10, 20],
};
let board = alignment.map(grid.u, grid.v);
After:
use projective_grid::{Coord, LatticeKind};
use projective_grid::expert::lattice::GridTransform;
let alignment = GridTransform::new(
LatticeKind::Square,
[[0, 1], [-1, 0]],
[10, 20],
);
let board = alignment.apply(Coord::new(grid.u, grid.v));
Use matrix(), translation(), and lattice() instead of public fields.
with_translation() turns a zero-translation D4/D6 symmetry into an affine
alignment. inverse() returns None unless the integer matrix is unimodular.
calib_targets_core::GridTransform is a re-export of this canonical type.
calib_targets_core::GridAlignment remains as a semantic type alias, so
existing result field types retain a descriptive name without another data
structure or implementation.
JSON, Python, and TypeScript
Alignment values no longer nest a four-scalar transform. The canonical shape
is shared with GridTransform:
{
"lattice": "square",
"matrix": [[0, 1], [-1, 0]],
"translation": [10, 20]
}
The old shape was:
{
"transform": { "a": 0, "b": 1, "c": -1, "d": 0 },
"translation": [10, 20]
}
Python’s GridAlignment and TypeScript’s GridAlignment are now aliases of
GridTransform. Update code that reads alignment.transform.a to read
alignment.matrix[0][0], and similarly for the other matrix elements.
C ABI
There is no C ABI migration. ct_grid_alignment_t keeps its existing matrix
and translation layout, and the FFI crate adapts the canonical Rust type at
the boundary. The FFI package version remains 3.0.0.
Compatibility helpers
calib_targets_core::square_predict_grid_position remains available, but is
now a thin square-lattice wrapper over
projective_grid::expert::lattice::predict_grid_position. D4 tables and
modulo-π angular helpers likewise have one implementation; detector-specific
ordering and naming are compatibility views only.
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). - A quality scalar —
strength, the ChESS response magnitude.
Together these are exactly what the prefilter reads. A corner’s axes are
admitted into grid construction when strength ≥ min_corner_strength
and neither axis is more uncertain than the axis-alignment tolerance
the grid builder will test it against
(max(σ₀, σ₁) ≤ topological.axis_align_tol_rad) — see
the chessboard pipeline. Before 0.11.0 the
second half of that rule was a fit-residual ratio
(fit_rms ≤ max_fit_rms_ratio · contrast); contrast and fit_rms no
longer exist upstream, and the σ-based rule is the direct replacement.
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:
crates/projective-grid/src/topological/(reached throughdetect_grid_all, or through the curatedexpert::squarecomponent seam for pattern-specific builders). 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. It and the algorithm selector have been removed; there is no algorithm choice in the current public configuration.
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/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). - Generic component merge. Disconnected walk patches are reunited in label space using local geometry and cell scale, without relying on a global homography.
- Public validation + projective fit, or detector-builder handoff. The
ordinary facade canonicalizes labels, runs pattern-agnostic line/local-H
validation, and fits the mandatory projective transform. Pattern-specific
builders may instead stop after component merge through
expert::square::assemble_oriented2_components; the chessboard takes this branch so parity/recovery see the original axis-slot frame and then applies its own mandatory geometry check. - Orchestration. Component detections 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,GridDetection).
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 by
GridDetection::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 —
GridDetection::fit()and 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—detector/edge_sampling.rs(reading the dots) anddetector/decode/(recovering the position). Based on Stelldinger 2024, arXiv:2409.20127.
A PuzzleBoard is a self-identifying chessboard: every interior edge carries a dot at its midpoint, and the pattern of dots is designed so that a fragment of the board identifies where on the board it is. Decode turns the dots a camera happened to see into an absolute position on a fixed 501 × 501 master pattern, so even a partial view yields absolute corner IDs and object-space coordinates — no need to see a corner of the board, no need for the whole target to be in frame.
This chapter is about the decoder specifically. The grid that feeds it comes from the chessboard pipeline; the end-to-end flow is in PuzzleBoard pipeline.
What is printed
Two cyclic binary maps are superposed on the board:
| map | shape | governs | lookup |
|---|---|---|---|
| A | 3 × 167 | vertical edges | map_a[mr mod 3][mc mod 167] |
| B | 167 × 3 | horizontal edges | map_b[mr mod 167][mc mod 3] |
A horizontal edge at master cell (mr, mc) is the edge between square
(mr, mc) and the square below it; a vertical edge is the edge between
(mr, mc) and the square to its right. Both maps tile cyclically, which is what
makes the 501 × 501 master storable in 126 bytes.
The dot colour is the bit, and the polarity is worth stating precisely because it is easy to get backwards:
bit 0 = black dot, bit 1 = white dot.
The dot sits on the boundary between a light and a dark square, so its visible half-moon always falls on the square of the opposite colour, and the intensity at the exact edge midpoint reads the dot directly. The sampler compares that intensity against bright/dark references taken from the two adjacent squares, which is what makes the read robust to vignetting and local exposure.
Why 3 × 167
Each map is a sub-perfect map: over its cyclic domain, all 501 of its 3 × 3 windows are pairwise distinct. Superposing the two makes every 4 × 4 window of squares — 3 × 4 horizontal bits plus 4 × 3 vertical ones, 24 bits in all — distinct across every one of the 501 × 501 master positions.
501 = 3 · 167 is not incidental. Because gcd(3, 167) = 1, a master row mr
is uniquely determined by the pair (mr mod 3, mr mod 167), and likewise for
columns. That is the Chinese Remainder Theorem, and the decoder leans on it
twice: once to store the pattern compactly, and once to avoid searching it. See
Code map construction for how the maps are
built and where the shipped ones come from.
What the decoder is given
Edge sampling emits one observation per interior edge it could read:
struct PuzzleBoardObservedEdge {
row: i32, // anchor corner, in the fragment's own frame
col: i32,
orientation: EdgeOrientation, // Horizontal | Vertical
bit: u8, // 0 = black, 1 = white
confidence: f32, // 0 = ambiguous, 1 = crisp
}
An observation is produced only when the corners bounding both adjacent squares were detected. That rule matters more than it looks: it means the fragment’s outermost corners host no dots, and therefore every lookup cell an observation refers to lies on the printed board. The fixed-board search depends on that.
Confidence is not decoration — it weights every score below, and bits under
min_bit_confidence (default 0.15) are dropped entirely rather than guessed.
The hypothesis space
A fragment knows neither where it sits on the master nor which way up it is: the camera may have seen the board rotated or mirrored. So a hypothesis is a pair — one of the 8 D4 transforms, and a master origin:
8 transforms × 501 rows × 501 columns = 2,008,008 hypotheses
Scoring one means predicting the bit at every observed edge and counting
agreement. Done naively that is 8 · 501² · N bit comparisons — for a
1200-edge fragment, 2.4 billion. The decoder does not do that.
Collapsing the search
Step 1: the score depends only on residues
Because the maps are cyclic, the predicted bit at an origin depends on that
origin only through mr mod 167, mr mod 3, mc mod 3, mc mod 167. There
are only 501 distinct horizontal classes and 501 vertical ones, and an origin’s
score splits into one term from each:
score(mr, mc) = H[mr mod 167][mc mod 3] + V[mr mod 3][mc mod 167]
So instead of scoring each origin against each observation, the decoder builds
the two tables once per transform — O(501 · N) — after which every origin
costs two lookups and an add. That is the single most important step:
O(8 · 501² · N) becomes O(8 · (501 · N + 501²)).
Step 2: CRT removes the 501² as well
The remaining origin walk is 2 M table reads. For the hard scorer it disappears
entirely. Since 501 = 3 · 167 with gcd(3, 167) = 1, the four residues are
mutually independent and each ranges over its full domain, so
argmax over origins of ( H[·] + V[·] ) = ( argmax over H , argmax over V )
and the winner is recovered from the two per-table argmaxes by CRT inversion:
mr = (334·va + 168·ha) mod 501. The scan drops to O(501).
The separation needs an integer key, and that is a real constraint rather
than an implementation detail. With integers, a table entry below the maximum is
at least one below it, so it provably cannot reach the maximum sum. With f32,
rounding breaks that implication: two origins built from different table values
can land on the same sum. The hard scorer ranks on an integer bit-match count
and separates safely; the soft scorer ranks on an f32 log-likelihood sum and
therefore keeps the 501² walk, stripped down to two reads and a compare per
origin.
A pathological all-tied input (an empty or degenerate observation set) can make the per-table argmax sets large; the hard path falls back to a direct table scan for the affected transform, so worst-case cost never exceeds the pre-CRT version.
Scoring: hard and soft
Both scorers consume the same tables.
HardWeighted ranks by (bits matched, summed confidence of matched bits),
lexicographically, and rejects anything whose bit-error rate exceeds
max_bit_error_rate (default 0.30). Integer-keyed, so it gets the CRT
collapse.
SoftLogLikelihood — the default — scores each bit as
log σ(±κ · confidence), clipped below by a per-bit floor so one
catastrophically wrong bit cannot dominate, and sums. A crisp bit contributes
strongly; a marginal one barely moves the score. This is materially better on
noisy or small fragments, and it is the same transfer function the ChArUco
board matcher uses. It additionally requires the winner to clear
alignment_min_margin over the runner-up.
The uniqueness gate
Winning is not enough. A decode is accepted only if
margin > k_winner
where margin = best_matched − runner_up_matched and
k_winner = edges_observed − best_matched is the winner’s own mismatch count.
Equivalently: the winner’s net score must strictly beat the runner-up’s
matched count.
This is parameter-free — it compares two counts — and it separates two failure modes that any single magnitude threshold conflates:
- A clean exact read has
k_winner = 0and passes at any margin ≥ 1, so the code’s exact-uniqueness design is honoured at any fragment size. - A noisy ambiguous read fails: if a wrong origin matches nearly as many
bits (small margin) while the winner itself mismatches many (large
k_winner), the winner is not meaningfully closer to a perfect read than its competitor, and the decode declines.
Declining is the right answer. A missing label costs a calibration nothing; a wrong absolute label is unrecoverable.
The runner-up is taken across all eight transforms, not just within the winning one, precisely because a fragment too small to break the board’s D4 symmetry must not be allowed to invent an orientation.
How big a fragment do you need?
The paper’s headline is that a 4 × 4 fragment is unique. That is true across
positions at a fixed orientation — and the decoder does not have a fixed
orientation. Over D4 × position, measured on clean, noise-free windows at
seven planted origins:
Mind the unit. This table sizes windows in squares, as the paper does.
min_window is a corner span, and a window of s × s squares spans
s + 1 corners — so the 6 × 6 row below is what min_window = 7 means.
Both columns are given to keep the two readings from being confused.
| window (squares) | corner span | edge bits | decoded | rejected as D4-aliased |
|---|---|---|---|---|
| 3 × 3 | 4 | 12 | 0 / 7 | 7 |
| 4 × 4 | 5 | 24 | 0 / 7 | 7 |
| 5 × 5 | 6 | 40 | 5 / 7 | 2 |
| 6 × 6 | 7 | 60 | 7 / 7 | 0 |
| 7 × 7 | 8 | 84 | 7 / 7 | 0 |
Every clean 4 × 4 window tested had a perfect alias under some other
transform. Clean uniqueness begins at 6 × 6 squares — a 7-corner span, which
research/puzzleboard-rings then verified exhaustively at all 251 001 master
positions under the four rotations.
Hence min_window = 7 by default, and the edge-count pre-filter that
accompanies it is required_edges(7) = 60 — the interior edge count of exactly
that span. (Before 0.13 the pre-filter read min_window as squares and demanded
84, one full ring more than the span gate intended, so fragments at exactly the
documented minimum were rejected before being decoded.)
The span gate is applied to the corner span on both axes, because a wide-but-short strip can meet an edge-count floor while carrying too little code distance on its thin axis.
Noise pushes the floor up: the code’s minimum Hamming distance is 1 at 4 × 4, so a single flipped bit can turn a fragment into a perfect read of a different location. A 300k-trial sweep over random origins and error patterns put the smallest window with zero false accepts at 7 × 7 squares — 84 edge bits — at both 30 % and 40 % bit-error rates.
That measurement predates the gates now standing between a noisy read and a
returned detection, and it is no longer what sets the floor. Window size is a
blunt instrument for the job: what actually rules out a false accept is the
uniqueness gate, a bounded-distance proof that the winner is the only codeword
within its error radius. Two additions make that proof carry the noise case at
a 7-corner span — the period-3 majority vote, which repairs a minority of
misread dots before any gate sees them, and the
PuzzleBoardDetectError::NotEnoughLogicalBits floor, which refuses the decode
when too few distinct bits survive for the proof to mean anything. Without that
second guard the proof stays formally valid while becoming vacuous, and
wrong-origin decodes reappear at high corruption.
Reproduce the table with:
cargo test --release -p calib-targets-puzzleboard --lib -- \
window_uniqueness_report --ignored --nocapture
Restricting the search to a declared board
When the caller knows which board they printed, PuzzleBoardSearchMode::FixedBoard
restricts the origin to that board’s rectangle. Because a printed board is a
sub-rectangle cut from the master, its bit at board cell (r, c) is exactly
the master bit at (origin + r, origin + c) — so this is the same scoring
problem, restricted, and it reuses the same class tables.
Restricting the origins also restricts the residue classes they can reach, so the precompute itself does less work. That is why declaring the board is cheaper than not declaring it rather than merely bounded — until the board grows past the maps’ 167-long period, where there is nothing left to restrict.
The scan considers only shifts under which every observation lands on the board. Because a dot is only sampled where the surrounding corners were detected, every observation does lie on the printed board, so a shift placing one outside it cannot describe the physical scene. Excluding those keeps each hypothesis at two table lookups, and keeps impossible placements out of the uniqueness gate where they could only ever suppress a correct decode.
Two guarantees follow: the decode cannot return a position the board does not cover, and any subset of the board decodes to the same master IDs a full view would — so fragments from different frames or different cameras stitch without further work.
Complexity
With N observed edges, w the observed window in squares, and L_r × L_c the
shift rectangle a declared board admits:
| stage | cost |
|---|---|
| Edge sampling | O(N · r²), r = dot sample radius |
| Class precompute, per transform | O(min(501, reachable classes) · N) |
| Origin scan — hard, full master | O(501) via CRT |
| Origin scan — soft, full master | O(501²) |
| Origin scan — fixed board | O(L_r · L_c) |
| Full master, hard | O(8 · 501 · N) |
| Full master, soft | O(8 · (501 · N + 501²)) |
| Fixed board | O(8 · (reachable · N + L_r · L_c)) |
Measured decode-only cost (synthetic observations, no image pipeline), in milliseconds:
| window | edges | full/hard | full/soft | fixed 25×25 | fixed 130×130 | fixed 501×501 |
|---|---|---|---|---|---|---|
| 7 × 7 | 84 | 0.33 | 2.19 | 0.08 | 0.73 | 3.94 |
| 13 × 13 | 312 | 2.09 | 3.90 | 0.20 | 1.88 | 5.23 |
| 25 × 25 | 1200 | 7.97 | 10.29 | 0.07 | 5.80 | 10.83 |
Reproduce with:
cargo test --release -p calib-targets-puzzleboard --lib -- \
decode_scaling_report --ignored --nocapture
Multiple components, and the origin conflict
A view can yield several disconnected grid components. Each decodes independently, and they are ranked by edges matched, then bit-error rate, then the scorer’s own tie-breaks.
If two well-supported components disagree on the master origin, that is an unrecoverable ambiguity — some part of the labelling must be wrong, and nothing in the image says which. The detector refuses the frame rather than picking one.
Cross-references
- Code map construction — how the two maps are built, why they work, and where the shipped ones come from.
- PuzzleBoard pipeline — the end-to-end detector.
- calib-targets-puzzleboard crate — API, search modes, printable targets.
- Topological grid finder — the grid whose interior edges this decoder samples.
PuzzleBoard code maps and registration
Code:
calib-targets-puzzleboard/src/code_maps.rs(the maps),tools/import_author_maps.rsandtools/generate_code_maps.rs(where they come from),detector/pipeline.rs(registration).
The decode chapter takes the master pattern as given. This chapter answers the two questions underneath it: how is a pattern with that uniqueness property constructed, and once decoded, how does an origin become an absolute corner ID and an object-space coordinate.
The construction
The requirement
We need a binary array whose every window is distinct — a perfect map, the two-dimensional analogue of a De Bruijn sequence. The PuzzleBoard needs two of them, one per edge orientation, and it needs them cyclic, so a board can be cut from anywhere on the master.
The paper’s choice is a (3, 167; 3, 3)₂ sub-perfect map: shape 3 × 167,
window 3 × 3, binary. “Sub-perfect” because a true perfect map of window 3 × 3
would hold all 2⁹ = 512 windows; this one holds the 501 that fit its domain,
and asks only that they be pairwise distinct.
The letter trick
Reading a 3 × 167 array by columns, each column is 3 bits — call it a letter
l ∈ {0..7} via l = b₀ | b₁≪1 | b₂≪2. The array becomes a length-167 string
over an 8-letter alphabet, and a 3 × 3 window becomes a 3-letter substring.
Now the cyclic requirement bites. The map has only 3 rows, so shifting the window down by one row wraps — and on letters, a row shift is the permutation
σ : (b₀, b₁, b₂) → (b₁, b₂, b₀)
σ has order 3, with fixed points 0 (all zero) and 7 (all one). So the
windows starting at the three row offsets of a given column position are
t, σ(t), σ²(t) — the σ-orbit of that triple.
For all 501 windows (167 column positions × 3 row shifts) to be distinct, two conditions suffice:
- No triple is a σ-fixed point — otherwise its three row shifts coincide, and three windows collapse into one.
- No two column positions share a σ-orbit — otherwise a window at one position equals a row-shifted window at the other.
The 512 possible triples split into 8 singleton orbits (the σ-fixed ones) and 168 orbits of size 3. We need 167 of them, one per column position. That budget — 167 needed out of 168 available — is what makes the search easy.
Finding a valid sequence
tools/generate_code_maps.rs does stochastic hill-climbing: start from a random
length-167 letter sequence; define the energy as the number of invalid column
triples (σ-fixed ones plus orbit duplicates); propose single-letter mutations
and accept those that lower it; restart on a local minimum. With one orbit of
slack it converges in milliseconds.
The companion paper (arXiv:2405.03309) gives a closed-form construction, but no reference implementation — local search is simply the cheaper route to the same contract.
Verification is independent of construction:
verify_cyclic_window_unique enumerates all 501 cyclic windows of a map and
asserts pairwise distinctness, and master_4x4_windows_unique does the
end-to-end check over all 501 × 501 master positions. Both run as ordinary unit
tests.
Provenance of the shipped maps — and why it matters
The maps in src/data/ are not generated. They are imported.
tools/import_author_maps.rs takes the reference implementation’s arrays from
PStelldinger/PuzzleBoard (CC0 1.0): map_a is the author’s code1
verbatim, and map_b is rot90(code2[::-1, ::-1]), which re-expresses code2
so its fundamental period is stored as 167 × 3. src/data/map_metadata.json
records the import, the derivation, and the packing.
This is a deliberate interoperability decision. A board printed from the reference tooling decodes here, and a board printed from this crate decodes with the reference Python decoder — because both sides agree on the same code.
A regenerated pair satisfies exactly the same uniqueness property and is, in every mathematical sense, just as good. It is also a different code: boards printed with it will not decode against the reference implementation. The generator is a research and validation tool, not the shipping path. If you run it, you have forked the pattern.
Registration: from origin to absolute IDs
Decode returns a GridAlignment — a D4 transform plus a translation — that maps
the fragment’s local corner coordinates onto the master. Turning that into
usable output takes three steps.
1. Apply the alignment
For each labelled corner at local (u, v):
(raw_i, raw_j) = alignment.apply(u, v)
2. Wrap into the master
wrap_master reduces both coordinates into [0, 501). This is not
housekeeping — it is load-bearing. Four of the eight D4 transforms have negative
entries on the diagonal, so a corner far from the origin can map to a negative
raw coordinate. A negative value still yields the correct id (the ID
computation reduces modulo 501 anyway) but the wrong target_position, since
that one is a plain multiplication by the cell size. Wrapping first keeps both
consistent:
let (master_i, master_j) = wrap_master(raw_i, raw_j);
3. Emit the ID and the object-space point
id = master_j · 501 + master_i
target_position = (master_i · cell_size, master_j · cell_size)
which preserves the invariant a consumer can rely on:
target_position.x == (id % 501) · cell_size
target_position.y == (id / 501) · cell_size
id is what makes multi-view calibration work without correspondence search:
two cameras that see overlapping parts of the board report the same id for
the same physical corner, so their observations join on it directly.
Origin conflicts
A view can produce several disconnected grid components, each decoding
independently. If two well-supported components disagree on the master origin —
compared modulo the cyclic master by origins_conflict — then at least one
labelling is wrong and the image contains no evidence of which. The detector
refuses the frame rather than emitting a plausible-looking mixture.
Per-view origin drift is expected
The master origin reported for a view depends on which print-corner the
chessboard stage happened to call local (0, 0), which depends on what the
camera framed. Two views of the same board routinely report different origins.
That is not an inconsistency: after registration both produce the same
absolute IDs for the same physical corners, which is the only thing downstream
consumers should compare.
Cross-references
- PuzzleBoard edge-code decode — how the origin is recovered in the first place.
- PuzzleBoard pipeline — the end-to-end detector.
- calib-targets-puzzleboard crate — API and printable targets.
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, each with a 1σ angular uncertainty + astrengthresponse magnitude). 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 or selector to configure: the topological grid finder is the sole builder for every target, including ChArUco. Legacy config keys for removed builder selectors are rejected as unknown fields.
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
→ GridDetection 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
GridDetectionper component: an acceptedLabelledGridplus a mandatoryLatticeFit. Rejections and stage snapshots are an opt-in diagnostics contract.
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,
GridDetection, the diagnostics seam, 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 ordinary callers, DetectionParams intentionally has one stable knob:
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.
Stage-specific topological, validation, and recovery controls are available
only by attaching projective_grid::expert::DetectionTuning. This keeps the
default configuration compact while preserving an explicit seam for detector
builders and reproducible evidence campaigns.
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 max(σ₀, σ₁) ≤ topological.axis_align_tol_rad. 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. |
| 3 | topological_grid | oriented features + cluster centres → labelled components | The topological grid finder through expert::square::assemble_oriented2_components; this builder seam stops after generic component merge and preserves the axis-slot frame for chessboard parity/recovery. |
| 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.
Why the prefilter reads σ (Stage 1)
The axis half of the prefilter is derived, not configured: it reuses
advanced.topological.axis_align_tol_rad, the tolerance the grid builder’s
cell test applies to those same axes. The argument is dimensional rather than
empirical — an axis estimate whose own 1σ uncertainty exceeds the alignment
window cannot answer the question the cell test asks of it, so letting it vote
adds noise to the classification, not information. Because the gate is the
tolerance it protects, the two cannot drift apart, and there is no separate
constant to tune.
This replaces the pre-0.11.0 fit-residual ratio
(fit_rms ≤ max_fit_rms_ratio · contrast), which is not expressible on
chess-corners 1.0 — contrast and fit_rms no longer exist. Note that the
builder’s own max_axis_sigma_rad is a looser backstop applied inside grid
construction and is unrelated to admission.
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).
Multi-component dispatch
ChessboardDetector::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
ChessboardParams splits into a stable core (min_labeled_corners, max_components,
min_corner_strength) plus an opt-in, non-semver advanced
(ChessboardAdvancedTuning) 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
ChessboardParams::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 a 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-window gate | filtered edges → pass / fail | Require the fragment to span min_window corners on both axes (default 7). That span carries 30 distinct code bits — the same information the paper’s 4 × 4 fragment carries — and is unique at every master position under the rotations-only search. |
| 3b | period-3 consensus | filtered edges → voted code bits | Both maps repeat every three rows or columns, so collapse the dots onto the distinct bits they read, by confidence-weighted majority vote. Origin- and orientation-invariant, so it runs before any hypothesis. Ties are erasures, never guesses; too few surviving bits → NotEnoughLogicalBits. |
| 4 | origin decode | filtered edges + master maps → (D4, origin) + score | Recover the (D4 transform, master origin) via the cyclic class tables — Full (whole master) or FixedBoard (declared board’s rectangle) scope, scored by HardWeighted (BER gate) or SoftLogLikelihood (margin gate, default). Both are re-gated for uniqueness. 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 the only builder and has no selector field.
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 (HardWeighted) | 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 ChESS corner front-end is params.chess (a DetectorConfig, consumed
by the facade’s whole-image entry points); the grid side is the standard
chessboard ChessboardParams (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) — the BER gate.min_window(default7) — the uniqueness floor; rarely changed, and lowering it trades misses for the risk of a wrong absolute label.search_mode—Full(default) vsFixedBoard(cheaper, fixes per-view origin drift when the board is known).scoring_mode—SoftLogLikelihood(default) vsHardWeighted(robust on ambiguous fragments; adds the margin gate).symmetry_mode—Rotations(default; the four 90° rotations, correct for an ordinary camera) vsRotationsAndReflections(all eight dihedral transforms; enable only when the optical path mirrors the image).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.)- the chessboard’s final wrong-label geometry check stays enabled, exactly
as for the standalone chessboard detector. Marker decoding is not a
substitute: the board alignment searches
D4 × integer translation, a rigid relabelling of whatever lattice the chessboard produced, so it cannot see or repair a labelling that is wrong within a component.
There is no chessboard grid-builder selector; legacy selector keys are rejected as unknown configuration fields.
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 aChessboardParams(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 → affine GridTransform + inliers | Fit a dihedral matrix + translation in (i, j)-space from the matched 3-circle layout; GridAlignment is only its semantic alias. 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 / Err(ChessboardNotDetected) | 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. |
Err(AlignmentFailed) (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 | board.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 board layout + chessboard params + circle scoring +
matching:
board— theMarkerBoardSpec(rows, cols, the threeMarkerCircleSpeccells + polarities, optionalcell_size). The marker circles supply the geometry constraint, so the v1expected_rows/colsandcompleteness_thresholdno longer apply.chessboard— aChessboardParamsfor 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
diagnose / diagnose_with_corners 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 real-world captures carrying non-negligible lens distortion and motion
blur, 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(default33.0); andmax(c.axes[0].sigma, c.axes[1].sigma) ≤ axis_align_tol_rad(default 15°, the Stage 3 axis-alignment tolerance).
The second condition is derived, not a knob of its own: an axis whose 1σ
uncertainty is wider than the window the cell test will judge it against
carries no usable information for that test. It replaces the pre-0.11.0
fit_rms ≤ max_fit_rms_ratio × contrast rule, which chess-corners 1.0 made
inexpressible by removing both scalars.
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.
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 expert::square::assemble_oriented2_components): Delaunay
triangulation → axis-driven edge classification → triangle-pair → quad
merge → flood-fill (i, j) walk → the facade’s merge_components_local.
The expert seam stops at that merge and preserves the topology axis-slot
frame; chessboard parity/recovery and its final validation run 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 ChessboardDetector::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 ↓, min_peak_weight_fraction, peak_min_separation_deg | Either the corners failed the prefilter (weak response, or axes too uncertain to vote) 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 ChessboardParams::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
ChessboardParams is #[non_exhaustive] and splits into a small stable
core — min_labeled_corners, max_components,
min_corner_strength — plus an opt-in, unstable ChessboardAdvancedTuning sub-struct
(ChessboardParams::advanced) holding the per-stage tuning knobs. Build with
Default::default() and overwrite the stable fields, attach advanced
overrides with ChessboardParams::with_advanced(...), or call
ChessboardParams::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 three stable knobs stay
top-level JSON keys. ChessboardAdvancedTuning’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 three stable knobs,
advanced.<knob> for the rest.
| Field | Default | Stage | Purpose |
|---|---|---|---|
max_components | 3 | — | Cap for detect_all. |
min_labeled_corners | 8 | 5 | Minimum labelled corners to emit a ChessboardDetection. |
min_corner_strength | 33.0 | 1 | Minimum ChESS strength. 0 disables. (Stable.) |
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 ChessboardAdvancedTuning, which is opt-in
and not covered by semver. (ChessboardAdvancedTuning 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, ChessboardDetector, ChessboardParams};
fn detect(corners: &[ChessCorner]) {
let params = ChessboardParams::default();
// `ChessboardDetector::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 = ChessboardDetector::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 = ChessboardDetector::new(ChessboardParams::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 aCharucoDetectionwith: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 uses the semantic GridAlignment alias for the canonical affine GridTransform; it maps detected grid coordinates into board coordinates with one matrix plus translation.
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 vertical interior edges. - map B, shape
(167, 3), for horizontal interior edges.
Dots encode bits directly: black dot = 0, white dot = 1.
corner (i,j) ---- B(j-1,i) --- corner (i+1,j)
| |
A(j,i-1) A(j,i)
| |
corner (i,j+1) -- B(j,i) ----- corner (i+1,j+1)
The committed blobs are src/data/map_a.bin and src/data/map_b.bin. They are
imported from the reference implementation (PStelldinger/PuzzleBoard, CC0)
by import-author-puzzleboard-maps, so boards interoperate with it;
generate-puzzleboard-code-maps can build an alternate, non-interoperable pair
for research, and verify-puzzleboard-code-maps checks the uniqueness property.
The runtime detector constructs nothing. See
Code maps and registration.
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 the candidate orientations — the
four 90° rotations by default (
symmetry_mode = Rotations), or all eight D4 transforms underRotationsAndReflections. - Assign absolute IDs and target-space positions to inlier corners.
The default decode.min_window is 7: after confidence filtering the fragment
must span at least 7 corners on both axes — 6 × 6 squares. A 4 × 4 window is
unique across master positions only at a fixed orientation, and a fragment
gives no cue to how the board was printed, so the decoder searches candidate
orientations too — over orientation × position, clean uniqueness begins at
exactly this span. Noise is handled by the uniqueness gate and the period-3
majority vote rather than by a larger window. See
the decode chapter.
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 considers every (D4, origin)
candidate against the full master code — without enumerating them, since the
code’s cyclic structure collapses the search (see
the decode chapter). When
the caller already knows which board they printed,
PuzzleBoardSearchMode::FixedBoard restricts the origin to that board’s
rectangle, which is both faster than the full search at every board size
below the master’s own and guarantees the decode cannot return a position
outside the printed board:
#![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: board layout + chessboard params + circle score + match settings.MarkerBoardDetection:detection:TargetDetectionlabeled asCheckerboardMarker.alignment: optionalGridAlignmentsemantic alias for the canonical affineGridTransform, from detected grid coordinates to board coordinates.
MarkerBoardDiagnostics(opt-in, from thediagnose/diagnose_with_cornersentry 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:
board: theMarkerBoardSpecto detect.chessboard:ChessboardParamsfor 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 the image plus a params struct. Where the
ChESS corner front-end is configured depends on the target shape — and the
asymmetry is deliberate:
- Plain chessboards are corner-cloud consumers, so
detect_chessboardtakes the front-end explicitly:detect_chessboard(&img, &chess_cfg, ¶ms). Passdetect::default_chess_config()unless you need to tune it. Because the chessboard detector is reusable on any corner cloud (custom upstreams, pre-detected corners), the facade keeps its corner config separable. - ChArUco, PuzzleBoard, and marker boards own their whole-image
pipeline, so the front-end travels on the params bundle as
params.chess(defaulting todefault_chess_config()); these entry points take no separate front-end argument. Their grid-step tuning lives under a separateparams.chessboardfield (aChessboardParams).
#![allow(unused)]
fn main() {
use calib_targets::detect;
use calib_targets::chessboard::ChessboardParams;
let img = calib_targets::image::open("board.png").unwrap().to_luma8();
let params = ChessboardParams::default();
let result = detect::detect_chessboard(&img, &detect::default_chess_config(), ¶ms);
}
Detector structs and free functions are one surface
Each detect_* free function is the detector one-liner: construct the
detector from params, then call detect. Reach for whichever fits — the
free function when a single call is all you need, the detector struct when
you configure once and detect many times, or when you want the
corner-reuse entry points below.
CharucoDetector, PuzzleBoardDetector, and MarkerBoardDetector each
expose the same five operations, mirrored by five free functions per
target (charuco, puzzleboard, marker_board):
detect(&view)/detect_t(img, params)— run the ChESS corner pass (fromparams.chess) and the whole pipeline.detect_with_corners(&view, &corners)/detect_t_with_corners(img, corners, params)— skip the corner pass and use corners you already have.diagnose(&view)/diagnose_t(img, params)(diagnosticsfeature) — likedetect, plus a diagnostics report.diagnose_with_corners(&view, &corners)/diagnose_t_with_corners(img, corners, params)(diagnosticsfeature) — likedetect_with_corners, plus diagnostics.detect_t_best(img, configs)— try several configs, keep the richest result.
detect_corners on a detector runs exactly the ChESS pass that detector’s
own params.chess configures, so corners you inject match what detect
would have produced on its own. The one case that genuinely wants
detect_with_corners: running a single corner pass across several target
detectors on the same image, rather than paying for it once per detector.
let corners = charuco_detector.detect_corners(&view);
let a = charuco_detector.detect_with_corners(&view, &corners)?;
let b = puzzle_detector.detect_with_corners(&view, &corners)?;
ChessboardDetector keeps its original, corner-cloud-only shape described
above — it has no chess field to run a corner pass from, by design: it
is embedded inside all three composite params types, so a nested
corner-detector config there would be dead in exactly the way a chess
field on ChessboardParams itself would be. Configure the corner pass for
a chessboard through detect_chessboard’s explicit chess_cfg argument
instead.
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 = calib_targets::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 = calib_targets::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, puzzleboard, and marker subcrates, gating their serializable trace surfaces (the chessboardtrace_topological/GeometryCheckTrace, the ChArUco / puzzleboard per-component decode diagnostics, and the marker-board circle-hypothesis diagnostics). The detectors build no per-stage trace on the hotdetect_*paths unless this is enabled (thedatasetfeature oncalib-targets-chessboardimplies it). The ChArUco, puzzleboard, and marker 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<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<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 omitted; the detector infers the extent.
#![allow(unused)]
fn main() {
use projective_grid::{detect_grid, DetectionRequest, Evidence, LatticeKind};
let request = DetectionRequest::new(
LatticeKind::Square,
Evidence::Oriented2(&features),
);
let detection = detect_grid(request)?;
assert_eq!(detection.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<GridDetection, 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. The unsupported cells are(Square, Oriented3)and(Hex, Oriented1/Oriented2).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 GridDetection:
grid(): &LabelledGrid— the recovered component.entries()is oneGridEntryper placed feature;bbox()is the inclusive coordinate bounding box;dimensions()echoes any caller-suppliedGridDimensions.fit(): &LatticeFit— the mandatory fitted model-plane-to-image projective transform (model_to_image) plus a residual summary (residuals.count,residuals.mean_px,residuals.max_px).
Rejected features and exact stage snapshots are available separately through
the opt-in diagnostics feature, so they do not expand the mandatory result.
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<f32>— the feature’s image-frame pixel-center position.residual_px: Option<f32>— reprojection residual in pixels, present when a fit was computed.
#![allow(unused)]
fn main() {
for entry in detection.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 aVec<GridDetection>, 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, &ChessboardParams::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. Both trace_topological and the
TopologicalTrace type are gated behind the chessboard crate’s
off-by-default diagnostics feature.
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::{ChessboardDetector, ChessboardParams};
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 = ChessboardParams::default();
let detector = ChessboardDetector::new(params);
if let Some(detection) = detector.detect(&corners) {
println!("{} labelled corners", detection.corners.len());
}
}
ChessboardDetector::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.