Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 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-corners crate.

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:

  1. Project Overview and Conventions
  2. Algorithms — the building blocks every detector shares
  3. Pipelines — each target’s end-to-end detection flow
  4. Crate chapters, starting with calib-targets-core and calib-targets-chessboard

API docs.

Quickstart

Install the facade crate (the image feature is enabled by default):

cargo add calib-targets image

Minimal chessboard detection:

use calib_targets::detect;
use calib_targets::chessboard::DetectorParams;
use image::ImageReader;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let img = ImageReader::open("board.png")?.decode()?.to_luma8();
    let params = DetectorParams::default();

    let result = detect::detect_chessboard(&img, &params);
    println!("detected: {}", result.is_some());
    Ok(())
}

Python bindings

Python bindings are built with maturin:

pip install maturin
maturin develop
python crates/calib-targets-py/examples/detect_chessboard.py path/to/image.png

The calib_targets module exposes detect_chessboard, detect_charuco, detect_puzzleboard, and detect_marker_board. The public API is dataclass-first: config inputs are typed models and detector results are typed dataclasses with to_dict()/from_dict(...) helpers for JSON interoperability. detect_charuco requires params and the board lives in params.board. For marker boards, target_position is populated only when params.layout.cell_size is set and alignment succeeds.

MSRV: Rust 1.88 (stable).

Interactive Playground

The same detectors shipped in the Rust facade — chessboard, ChArUco, PuzzleBoard, and marker board — also run directly in the browser via WebAssembly. The npm package is @vitavision/calib-targets; the playground below is a thin React UI on top of it. No data leaves your machine: detection happens in the WASM module loaded into this page.

What it does

SurfaceDescription
Image inputDrop 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 familySwitch between corner detection and the four target detectors (Chessboard, ChArUco, Marker board, PuzzleBoard).
Board geometryFor ChArUco, marker, and PuzzleBoard targets: configure rows, cols, and (ChArUco) ArUco dictionary directly in the panel.
Core paramsOverride min_corner_strength, min_labeled_corners, and max_components — the three params shared across all detector families.
Multi-config sweepToggle detect_*_best to run the built-in 3-config preset and keep the best result.
OverlaysRed corners, light-blue grid edges, yellow origin ring, and green far-corner ring drawn in image pixel coordinates. Toggled per-layer.
Zoom / panScroll 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 generationrender_*_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:

  1. Choose the right calibration target for your use case.
  2. Generate a printable target file.
  3. Print it correctly.
  4. Write detection code in Python or Rust.

No prior knowledge of the library is assumed.


Step 1: Choose your target type

TargetBest forRequires
ChessboardQuick start, simple intrinsic calibrationNothing — no markers
ChArUcoRobust calibration, partial visibility OK, absolute corner IDsArUco dictionary
Marker boardScenes where a full chessboard is impracticalCustom layout

If you are unsure, start with ChArUco. It combines the subpixel accuracy of chessboard corners with the robustness of ArUco markers. Each detected corner carries a unique ID and a real-world position in millimeters, so partial views of the board are useful and board orientation is unambiguous.

If you want the absolute simplest path and only need basic intrinsic calibration, use the plain chessboard.


Step 2: Generate a printable target

Pick the language you are most comfortable with. All paths produce the same three output files: <stem>.json, <stem>.svg, <stem>.png.

Python

pip install calib-targets
import calib_targets as ct

# ChArUco: 5 rows × 7 cols, 20 mm squares, DICT_4X4_50 markers
doc = ct.PrintableTargetDocument(
    target=ct.CharucoTargetSpec(
        rows=5,
        cols=7,
        square_size_mm=20.0,
        marker_size_rel=0.75,
        dictionary="DICT_4X4_50",
    )
)
written = ct.write_target_bundle(doc, "my_board/charuco_a4")
print(written.png_path)   # open this to preview

For a plain chessboard instead:

doc = ct.PrintableTargetDocument(
    target=ct.ChessboardTargetSpec(
        inner_rows=6,
        inner_cols=8,
        square_size_mm=25.0,
    )
)
ct.write_target_bundle(doc, "my_board/chessboard_a4")

CLI

cargo install calib-targets installs the Rust binary; pip install calib-targets installs the same command as a Python console script. One-step generation:

calib-targets gen charuco \
  --out-stem my_board/charuco_a4 \
  --rows 5 --cols 7 --square-size-mm 20 \
  --marker-size-rel 0.75 --dictionary DICT_4X4_50

# Or the reviewable three-step flow:
calib-targets list-dictionaries
calib-targets init charuco \
  --out my_board/charuco_a4.json \
  --rows 5 --cols 7 --square-size-mm 20 \
  --marker-size-rel 0.75 --dictionary DICT_4X4_50
calib-targets validate --spec my_board/charuco_a4.json
calib-targets generate --spec my_board/charuco_a4.json \
  --out-stem my_board/charuco_a4

Rust

use calib_targets::printable::{write_target_bundle, PrintableTargetDocument};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let doc = PrintableTargetDocument::load_json("my_board/charuco_a4.json")?;
    let written = write_target_bundle(&doc, "my_board/charuco_a4")?;
    println!("{}", written.png_path.display());
    Ok(())
}

See calib-targets-print for the full JSON schema and more options.


Step 3: Print it

Open my_board/charuco_a4.svg (or the .png at the generated DPI) in your printer dialog:

  • Set scale to 100% / “actual size”. Disable “fit to page”, “shrink to fit”, or any equivalent driver option.
  • After printing, measure one square width with a ruler or caliper and confirm it matches square_size_mm (20 mm in the example above).
  • If the size is wrong, fix the print dialog and reprint — do not compensate in code.
  • Mount or tape the target flat to a rigid surface. Warping or bowing degrades calibration accuracy significantly.
  • Prefer the SVG for professional print workflows; use the PNG for quick office printing (check the DPI matches your printer resolution).

Step 4: Detect corners in Python

The board spec used for detection must match the one used for generation exactly.

import numpy as np
from PIL import Image
import calib_targets as ct

def load_gray(path: str) -> np.ndarray:
    return np.asarray(Image.open(path).convert("L"), dtype=np.uint8)

# Board spec — must match the generated target
board = ct.CharucoBoardSpec(
    rows=5,
    cols=7,
    cell_size=20.0,          # mm; gives target_position in mm
    marker_size_rel=0.75,
    dictionary="DICT_4X4_50",
    marker_layout=ct.MarkerLayout.OPENCV_CHARUCO,
)

params = ct.CharucoParams(board=board)

image = load_gray("frame.png")

try:
    result = ct.detect_charuco(image, params=params)
except RuntimeError as exc:
    print(f"Detection failed: {exc}")
    raise SystemExit(1)

corners = result.corners
print(f"Detected {len(corners)} corners, {len(result.markers)} markers")

# Collect point pairs for solvePnP / calibrateCamera
obj_pts = []  # 3-D object points (Z = 0 for planar board)
img_pts = []  # 2-D image points
for c in corners:
    if c.target_position is not None:
        x_mm, y_mm = c.target_position
        obj_pts.append([x_mm, y_mm, 0.0])
        img_pts.append(c.position)

print(f"Point pairs ready for calibration: {len(obj_pts)}")

For a plain chessboard:

result = ct.detect_chessboard(image)
if result is None:
    raise SystemExit("No chessboard detected")

corners = result.corners
print(f"Detected {len(corners)} corners")
# target_position is None for chessboard — assign object points by grid index
for c in corners:
    i, j = c.grid          # (col, row), origin top-left
    obj_pts.append([i * square_size_mm, j * square_size_mm, 0.0])
    img_pts.append(c.position)

Step 5: Detect corners in Rust

# Cargo.toml
[dependencies]
calib-targets = "0.10"
image = "0.25"
use calib_targets::charuco::{CharucoBoardSpec, CharucoParams, MarkerLayout};
use calib_targets::detect;
use image::ImageReader;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let img = ImageReader::open("frame.png")?.decode()?.to_luma8();

    let board = CharucoBoardSpec::new(
        5,
        7,
        20.0, // mm
        0.75,
        "DICT_4X4_50".parse()?,
    )
    .with_marker_layout(MarkerLayout::OpenCvCharuco);

    let params = CharucoParams::for_board(&board);

    let result = detect::detect_charuco(&img, &params)?;
    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

TopicWhere
How detection works underneathThe Grid Model
Detection parameters explainedTuning the Detector
Detection fails or gives errorsTroubleshooting
What every output field meansUnderstanding Results
Full printable-target referencecalib-targets-print
ChArUco pipeline internalsChArUco crate

The Grid Model

Code: projective-grid.

This is the foundational model every detector in the workspace shares. Before you tune a chessboard, ChArUco, or PuzzleBoard detector it pays to understand the layer beneath them all — the projective-grid crate. Given a cloud of 2D feature points — optionally carrying one, two, or three local axis directions per point — it recovers an (i, j) → point labelling: which integer grid cell each feature occupies under perspective, together with a fitted projective transform from model-plane coordinates to image pixels.

The input-feature kinds introduced here (plain points versus oriented features) and the recovery algorithm below are exactly the vocabulary the Tuning the Detector chapter’s parameters act on — read this first, and the tuning knobs stop being a flat list of names.

The crate is deliberately small and image-free. There are no image, pixel-buffer, or camera types anywhere in its public surface, and no target-specific identifiers (marker IDs, ring IDs, calibration metadata). It is target-agnostic: the same lattice recovery serves a chessboard detector, a laser-dot cloud, a scanned form, or a photographed board game. The detection surface is single-precision (f32); the standalone projective geometry kernel stays generic over f32 / f64 via the Float trait. The other workspace detectors sit above this crate — they run a corner detector, convert its output into generic point or oriented features, and call in here for the labelling.

The crate ships independently on crates.io and is used directly for non-calibration tasks: rectifying a photograph of a board game, fitting a locally-planar lattice to a laser-dot cloud, extracting a grid from a scanned document, or building a new detector for a pattern the workspace doesn’t yet ship.


The model

Three small pieces define the public surface.

Two lattice families (LatticeKind). Square is the orthogonal (i, j) grid and is detected by the topological algorithm. Hex (axial (q, r)) is also detected on the topological path: its triangles are the unit cells directly, so there is no diagonal/quad-merge stage.

Two tasks.

  • Detectiondetect_grid / detect_grid_all: recover labels from raw evidence when you do not know which feature is which cell.
  • Consistencycheck_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 the Evidence enum.

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:

VariantPayloadSquareHex
Positions&[PointFeature]✅ synthesize 2 axes✅ synthesize 3 axes (topological)
Oriented1&[OrientedFeature<1>]✅ synthesize 2nd axisUnsupportedCombination
Oriented2&[OrientedFeature<2>]✅ native (topological)UnsupportedCombination
Oriented3&[OrientedFeature<3>]UnsupportedCombination✅ native (topological)
CoordinateHypothesesfeatures + hypothesesuse check_consistency instead

Each feature carries a PointFeature (position + caller-owned source_index) plus N undirected LocalAxis directions (N = 0 for Positions). Any unsupported (lattice, evidence) combination — for example (Square, Oriented3), (Hex, Oriented1/Oriented2), or CoordinateHypotheses for detection — returns a typed GridError::UnsupportedCombination { task, lattice, evidence }, never a guessed answer.


Worked example

A fully self-contained, image-free example: synthesize a small 3×3 grid with a mild perspective shear, wrap the features as evidence, detect, and read the recovered labels. This is the body of examples/hello_grid.rs — run it with cargo run -p projective-grid --example hello_grid.

use nalgebra::Point2;
use projective_grid::{
    detect_grid, DetectionParams, DetectionRequest, Evidence, LatticeKind, LocalAxis,
    OrientedFeature, PointFeature,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Build a 3x3 grid of oriented features. The `+ j * 6.0` term adds a
    // mild perspective-style shear, so this is a genuine projective grid,
    // not a perfectly axis-aligned one.
    let mut features: Vec<OrientedFeature<2>> = Vec::new();
    for j in 0..3 {
        for i in 0..3 {
            // Image-frame position: origin top-left, x right, y down.
            let x = 60.0 + i as f32 * 40.0 + j as f32 * 6.0;
            let y = 50.0 + j as f32 * 40.0;

            // `source_index` is a stable caller-owned handle; the solution
            // reports it back so you can map a label to the input feature.
            let point = PointFeature::new(features.len(), Point2::new(x, y));

            // Two roughly-orthogonal local axes: horizontal (0 rad) and
            // vertical (pi/2 rad), each with a small angular sigma.
            let axes = [
                LocalAxis::new(0.0, Some(0.02)),
                LocalAxis::new(std::f32::consts::FRAC_PI_2, Some(0.02)),
            ];
            features.push(OrientedFeature::new(point, axes));
        }
    }

    // Wrap as Oriented2 evidence and ask for a square lattice. Grid
    // dimensions are unknown (`None`); the detector infers the extent.
    let request = DetectionRequest::new(
        LatticeKind::Square,
        Evidence::Oriented2(&features),
        None,
        DetectionParams::default(),
    );

    // `detect_grid` returns the largest recovered component.
    let solution = detect_grid(request)?;
    for entry in &solution.grid.entries {
        // coord.u = i, coord.v = j; source_index maps back to the input.
        println!(
            "(i={}, j={}) <- feature {}",
            entry.coord.u, entry.coord.v, entry.source_index
        );
    }
    Ok(())
}

Running it labels all nine features (0,0) through (2,2) with a sub-pixel fit residual. Two sibling examples under crates/projective-grid/examples/ round out the surface: detect_square_oriented2 (a larger detection run) and check_square_consistency (the consistency task on pre-labelled features).


The square algorithm: Topological

Detection of (Square, Oriented2) uses the topological grid finder — the sole grid builder for all square targets. It is the Shu / Brunton / Fiala 2009 axis-driven grid finder: a Delaunay triangulation over the corner cloud whose edges are classified by per-corner axis match, with triangle pairs merged into cells and integer coordinates flooded across the mesh. Image-free; recovers dense grids and copes well with distortion. May produce several components — see detect_grid_all below.

There is only one square grid builder, so the request carries no algorithm choice: what to detect is selected by the LatticeKind (Square / Hex) and the Evidence shape on the DetectionRequest, not by an algorithm enum. The historical single-variant SquareAlgorithm / GraphBuildAlgorithm seams were removed once seed-and-grow was retired.

The algorithm shares the post-detection validation and projective fit across all target types, recovering the full pattern with zero wrong labels. The deep-dive — the axis-classification test, the triangle-to-cell merge, and the line between the generic machinery here and the chessboard-specific wrapper — is on the Topological grid finder algorithm page, with the full stage-by-stage reference in docs/algorithms/topological-grid-detection.md.

Hex also uses the topological algorithm. On a hex point lattice the Delaunay triangles are the unit cells, so the diagonal/quad-merge stage is bypassed; the axial (q, r) walk and the projective fit back-half are otherwise shared with the square topological path. The fit residual is the precision gate.

Single vs. multi-component results

detect_grid returns the largest recovered component as one GridSolution. When the lattice is split into islands (for example by occlusion) and the secondary components matter, call detect_grid_all: it returns a DetectionReport whose solutions vector holds one GridSolution per qualifying component, ordered by labelled-count descending. The topological path may yield several components.


Inputs

Detection input is the Evidence enum (see The model above). For the native Oriented2 shape each element is an OrientedFeature<2>:

  • point: PointFeatureposition (image-frame pixel center) and a stable, caller-owned source_index. The solution reports the source_index back so a recovered label maps to the exact input.
  • axes: [LocalAxis; 2] — two undirected local lattice directions, each an angle_rad plus an optional sigma_rad (angular uncertainty). Axes are undirected: θ and θ + π denote the same direction.

DetectionRequest::new(lattice, evidence, dimensions, params) bundles the lattice family, the evidence, optional known GridDimensions, and a DetectionParams. DetectionParams carries max_residual_px (the fit residual gate) and the algorithm selector (always Topological), with a topological sub-config and a shared validate sub-config; Default covers all the tuning knobs and the builder-style with_* methods override individual fields.


Outputs

A successful detection is a GridSolution:

FieldMeaning
grid: LabelledGridThe labelled component: entries (one per placed feature), the lattice family, an inclusive coordinate bbox, and the optional caller-supplied dimensions.
fit: Option<LatticeFit>The fitted model-plane-to-image projective transform plus a residuals: ResidualSummary (count, mean_px, max_px).
rejected: Vec<RejectedFeature>Features this component could not place.

Each GridEntry carries:

  • coord: Coord — the (i, j) label as coord.u / coord.v, rebased so the labelled bounding box starts at (0, 0).
  • source_index: usize — back into the caller’s input slice.
  • image_position: Point2<f32> — the feature’s image-frame pixel-center position.
  • residual_px: Option<f32> — reprojection residual in pixels, present when a fit was computed.

Each RejectedFeature carries the source_index, an optional coord, an optional residual_px, and a RejectionReason: Unlabelled (never placed — e.g. noise outside the recovered lattice), ValidationDropped (placed by the topological pass but dropped by post-detection validation: line collinearity, local-homography residual, or edge-length band), or ResidualTooHigh (reprojection residual exceeded max_residual_px).

For multi-component runs, detect_grid_all returns a DetectionReport with the per-component solutions vector plus a top-level rejected slot.


Checking caller-supplied labels

When labels already exist — for instance after decoding marker IDs into (i, j) coordinates — check_consistency scores them against a single projective fit instead of recovering them from scratch. Build a ConsistencyRequest::new(lattice, features, hypotheses, dimensions, params) from position-only PointFeatures and a parallel slice of CoordinateHypothesis (each pairing a source_index with a proposed Coord), with a ConsistencyParams whose max_residual_px sets the acceptance threshold. The returned ConsistencyReport has passed (true when every residual clears the threshold), the full solution (labels, fit, and any over-residual rejected entries), and a max_residual_px() convenience accessor. check_square_consistency in the examples directory is the runnable version.

This is also the one entry point that consumes coordinate hypotheses; Evidence::CoordinateHypotheses exists for symmetry in the detection enum but detect_grid does not yet act on it.


Conventions

  • Coordinates. Image pixels: origin top-left, x right, y down. Grid i (coord.u) increases right, j (coord.v) increases down.
  • Undirected axes. A LocalAxis angle is undirected — θ and θ + π are the same direction. Any circular mean over axis angles must therefore accumulate (cos 2θ, sin 2θ) and halve the resulting atan2; 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 over F: Float, for a future f64 calibration 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 / OrientedFeature values before calling in.
  • Dense, unstructured point clouds. Pure noise without any local axis structure does not yield a usable Delaunay classification.

Learn more

API reference: projective-grid on docs.rs. The topological grid finder has an in-repo deep-dive at docs/algorithms/topological-grid-detection.md.

Tuning the Detector

This chapter answers the question: “My detection fails or gives poor results — what do I change?”

Background first. Every parameter below acts on the grid-recovery pipeline — its input-feature kinds, the topological grid builder, and the per-stage contract. If a knob’s name reads as jargon, read The Grid Model first; the tuning reference assumes that vocabulary.

Start here: use the built-in defaults

Before tuning anything, confirm you are starting from the library defaults:

#![allow(unused)]
fn main() {
use calib_targets::detect::detect_chessboard;
use calib_targets::chessboard::DetectorParams;

let params = DetectorParams::default();
}

For ChArUco:

#![allow(unused)]
fn main() {
use calib_targets::charuco::CharucoParams;
let board = todo!();

let params = CharucoParams::for_board(&board);
}

The chessboard detector’s ChESS corner config is not carried inside DetectorParams — it’s a separate argument via calib_targets::detect::default_chess_config() (used automatically by the detect_chessboard* facade helpers). If you need to override it, call calib_targets::detect::detect_corners(&img, &custom_chess_config) directly and pass the resulting corner cloud into calib_targets::chessboard::Detector::new(params).detect(&corners).

For ChArUco, CharucoParams.chessboard is a DetectorParams: a stable core of four fields plus an opt-in advanced block (see the per-parameter reference below). Board sampling scale is controlled separately by CharucoParams::for_board, which starts with px_per_square = 60. If marker decoding is the problem and the board appears at a very different pixel scale, adjust px_per_square before touching other parameters.

Challenging images: multi-config sweep

For images with uneven lighting, Scheimpflug optics, or narrow focus strips, a single threshold may miss corners in some regions. Use the multi-config sweep to try several parameter variants and keep the best result:

#![allow(unused)]
fn main() {
use calib_targets::detect::{detect_chessboard_best, detect_charuco_best};
use calib_targets::chessboard::DetectorParams;
use calib_targets::charuco::CharucoParams;
let img: image::GrayImage = todo!();
let board = todo!();

let chess_configs = DetectorParams::sweep_default();
let chess_result = detect_chessboard_best(&img, &chess_configs);

let charuco_configs = CharucoParams::sweep_for_board(&board);
let charuco_result = detect_charuco_best(&img, &charuco_configs);
}

DetectorParams::sweep_default() returns three configs: default + tighter + looser on cluster_tol_deg, attach_axis_tol_deg, and related tolerances. All three preserve the detector’s precision- by-construction invariants; only recall-affecting tolerances are varied.

For PuzzleBoard, use PuzzleBoardParams::sweep_for_board(&spec).

Multi-component detection (via Detector::detect_all / the facade detect_chessboard_all) recovers fragmented grids where markers break contiguity — each disconnected piece comes back as its own Detection with its own locally-rebased (i, j) labels. Capped by DetectorParams::max_components (default 3).


Symptom → parameter table

min_corner_strength, min_labeled_corners, and max_components are stable top-level fields; every other chessboard knob below is an advanced knob set via DetectorParams::with_advanced(...). ChArUco / PuzzleBoard decode.* knobs sit on their own config structs.

SymptomParameter to adjust
detect_chessboard returns Nonemin_corner_strength ↓, cluster_tol_deg ↑, min_peak_weight_fraction ↓, or try detect_chessboard_best
Partial board, many holesattach_search_rel ↑, attach_axis_tol_deg
Scene has multiple chessboard componentsuse detect_chessboard_all (cap with max_components)
Fast perspective / wide-angle lensedge_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 ChArUcomin_border_score ↓, multi_threshold: true
AlignmentFailed (low inlier count)min_marker_inliers
DecodeFailed on PuzzleBoarddecode.min_bit_confidence ↓, decode.max_bit_error_rate

Per-parameter reference: chessboard::DetectorParams

DetectorParams is a #[non_exhaustive] struct split into two surfaces:

  • a stable core of four fields covered by semver — graph_build_algorithm (single-variant, Topological; retained as a reserved config seam), min_labeled_corners, max_components, and min_corner_strength (see Output gates and Stage 1 below);
  • an opt-in advanced sub-struct (Option<Box<AdvancedTuning>>) holding the ~40 per-stage knobs. AdvancedTuning is NOT covered by semver — leave it unset unless a specific input fails and you have evidence for the change.

Attach overrides with DetectorParams::with_advanced(tuning) and read the effective tuning with effective_tuning(). AdvancedTuning is #[non_exhaustive], so build it from AdvancedTuning::default() and mutate the knobs you need:

#![allow(unused)]
fn main() {
use calib_targets::chessboard::{AdvancedTuning, DetectorParams};

let mut advanced = AdvancedTuning::default();
advanced.cluster_tol_deg = 16.0;
advanced.attach_search_rel = 0.5;
let params = DetectorParams::default().with_advanced(advanced);
}

All knobs in the Stage 2-8 tables below are advanced knobs set on the advanced block; min_corner_strength (Stage 1) and the output gates are stable top-level fields. See the chessboard chapter for the full invariant-to-parameter mapping and crates/calib-targets-chessboard/src/params/ for defaults.

Stage 1 — pre-filter

min_corner_strength is a stable top-level field; max_fit_rms_ratio is an advanced knob.

FieldDefaultGuidance
min_corner_strength0.0Raise to 0.30.5 on noisy scenes with many spurious saddles. Drops weak corners before clustering.
max_fit_rms_ratio0.5ChESS fit_rms must be ≤ ratio × contrast. Raise to 0.8 when accepting softer corners; lower tightens the pre-filter.

Stages 2-3 — grid-direction clustering

FieldDefaultGuidance
num_bins90Histogram resolution (π / n per bin). Rarely adjusted.
cluster_tol_deg12.0Per-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_deg60.0Minimum angle between the two returned peaks. Guards against twin-peak collisions.
min_peak_weight_fraction0.02Fraction of total axis-vote weight a peak must carry. Lower on dense boards where each real peak only carries a few percent; higher rejects spurious noise peaks.

Stage 5 — seed

Seed-finding tolerances are internal to the topological grid builder and are not exposed as public tuning knobs. If seeding consistently fails, use detect_chessboard_best with DetectorParams::sweep_default() which varies the upstream clustering and attachment tolerances.

Stage 6 — grow

FieldDefaultGuidance
attach_search_rel0.35KD-tree search radius around each prediction (fraction of cell_size). Raise to 0.450.55 on images with noticeable perspective; tighter rejects more holes.
attach_axis_tol_deg15.0Candidate’s axes must match both cluster centres within this tolerance.
attach_ambiguity_factor1.5If the second-nearest candidate is within factor × nearest, attachment is skipped (the position is marked ambiguous).
step_tol0.25Edge-length window at attachment ([1 − step_tol, 1 + step_tol] × s).
edge_axis_tol_deg15.0Induced-edge axis alignment at attachment.

Stage 7 — validate

FieldDefaultGuidance
geometry_check_local_h_tol_rel0.20Local 4-point homography residual tolerance for the final geometry check.
line_min_members3Minimum 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.

FieldDefaultGuidance
min_labeled_corners8Detection rejected below this labelled count. Raise for validation boards with an expected floor.
max_components3Cap 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.100.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

  1. Start with defaults; run with RUST_LOG=debug to see corner counts and per-stage counters.
  2. If no corners are found: loosen min_corner_strength, check image resolution and contrast.
  3. If corners found but no grid (detect_chessboard returns None): run calib_targets_chessboard::trace_topological — few usable corners means the prefilter / clustering is too tight (try lowering min_corner_strength or the advanced min_peak_weight_fraction), Err(NoComponents) means the topological builder assembled no grid (try detect_chessboard_best), and components found but a refused detection points at the final geometry check (inspect GeometryCheckTrace.dropped_*; try a wider config). See Troubleshooting for the full chain.
  4. If grid found but no ChArUco markers: enable multi_threshold, lower min_border_score.
  5. If alignment fails: verify board spec (rows, cols, dictionary, marker_size_rel).
  6. 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 lineMeaning
input_corners=NN ChESS corners passed the strength filter
chessboard stage failed: ...Grid assembly error; reason follows
marker scan produced N detectionsN cells decoded a valid marker ID
alignment result: inliers=NN markers matched the board spec
cell (x,y) failed decodeThat cell did not match any dictionary entry
cell (x,y) passed threshold but no dict matchBinarization ok, wrong dictionary

If you do not see these lines, confirm RUST_LOG=debug is set in the shell that runs the binary, not in a parent process.


detect_chessboard returns None

The detector has no single error variant — a None return means some stage failed. To diagnose, run the chessboard crate’s serializable topological trace, calib_targets_chessboard::trace_topological, which is layered over the production path (so it reflects what detect() actually does) and reports per-corner usability plus the labelled components:

use calib_targets::detect::{default_chess_config, detect_corners};
use calib_targets_chessboard::{trace_topological, DetectorParams};
let img: image::GrayImage = todo!();

let corners = detect_corners(&img, &default_chess_config());
match trace_topological(&corners, &DetectorParams::default()) {
    Ok(trace) => {
        let usable = trace.corners.iter().filter(|c| c.usable).count();
        println!("corners_in: {}", trace.diagnostics.corners_in);
        println!("corners_used: {usable}");
        println!("components: {}", trace.components.len());
        println!("total labels: {}", trace.diagnostics.labels);
    }
    Err(e) => println!("topological stage produced no grid: {e}"),
}

Checklist:

  1. No ChESS corners found? corners.is_empty() (and trace.diagnostics.corners_in == 0). The ChESS detector saw nothing — check image resolution / contrast; override calib_targets::detect::default_chess_config() with a custom DetectorConfig if necessary — e.g. DetectorConfig::chess().with_threshold(Threshold::Absolute(8.0)) to drop the noise floor, or .with_threshold(Threshold::Relative(0.05)) for a fraction of the per-frame peak response. The chess-corners 0.10 release replaced the legacy (threshold_mode, threshold_value) pair with the tagged-enum Threshold shown above.

  2. Corners found but few usable? The strength / fit prefilter or the axis-usability gate is rejecting most corners. Lower min_corner_strength, raise max_fit_rms_ratio, and check the axis clustering tolerances (cluster_tol_deg default 12.0 → try 16.0; min_peak_weight_fraction default 0.02 → try 0.01). A perfectly rectilinear board with axes on the π-wrap boundary is handled by plateau-aware peak picking.

  3. Usable corners but Err(NoComponents)? The topological builder assembled no quad mesh. Try detect_chessboard_best with DetectorParams::sweep_default() (widens the clustering and attachment tolerances).

  4. Components found but detect_chessboard still None? The final geometry check refused the detection (survivors below min_labeled_corners) or only tiny components survived. Try a wider config via detect_chessboard_best; if the scene legitimately holds multiple boards, use detect_chessboard_all and handle each component separately.

  5. 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:

  1. Correct dictionary? The dictionary field in the board spec must match the one used when printing. A mismatch produces cell (x,y) passed threshold but no dict match in the log for every cell.

  2. 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.

  3. Blurry image?

    • Enable multi_threshold: true (already the default for ChArUco).
    • Lower min_border_score to 0.650.70.
  4. Uneven lighting? multi_threshold handles this automatically. If already enabled, check whether the board surface has specular reflections — these cannot be corrected by thresholding alone.

  5. Wrong scale? If px_per_square is far from the actual pixel size, the projective warp used for cell sampling will produce a very small or very large patch. Adjust CharucoParams.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:

  1. inliers = 0: No decoded marker ID appears in the board layout at all.

    • Board spec mismatch: wrong rows, cols, dictionary, or marker_layout.
    • Marker IDs may be correct but the layout offset is wrong (e.g. the board was generated with a non-zero first_marker id).
  2. inliers small but non-zero:

    • Board is partially visible — lower min_marker_inliers to 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 use detect_charuco_best with a sweep.
  3. inliers near threshold:

    • One or two spurious decodings are pulling the fit off. Raise min_border_score slightly to reject low-confidence markers.

Common image problems

ProblemRecommended fix
Strong blurLower min_border_score to 0.65, enable multi_threshold
Uneven / gradient lightingmulti_threshold (already default)
Strong perspective / wide-angleRaise edge_axis_tol_deg / attach_axis_tol_deg / geometry_check_local_h_tol_rel on the chessboard side
Partial occlusionUse detect_chessboard_all; for ChArUco, lower min_marker_inliers
Multiple same-board componentsdetect_chessboard_all; cap via max_components
Very small ChArUco board in frameRaise CharucoParams.px_per_square to match actual square size
Specular reflections on boardPre-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 NoneUse detect_chessboard_best; inspect the trace_topological components and the final-check GeometryCheckTrace.dropped_* counters

Getting more help

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:

VariantProduced by
TargetKind::ChessboardChessboardDetection::target_detection()
TargetKind::CharucoCharucoDetectionResult::target_detection()
TargetKind::PuzzleBoardPuzzleBoardDetectionResult::target_detection()
TargetKind::CheckerboardMarkerMarkerBoardDetectionResult::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 typeWhen populated
ChessboardNever (no physical size in ChessboardParams)
ChArUcoAlways when board.cell_size > 0 and alignment succeeds
PuzzleBoardAlways when decode succeeds
Marker boardOnly when layout.cell_size > 0 and alignment succeeds

Use target_position directly as the object-space point for camera calibration (pass alongside the corresponding position as the image-space point).

score

A 0..1 quality score for this corner’s associated marker decode. Higher is better.

The score blends the border contrast of the surrounding marker border ring and a Hamming penalty based on the number of bit errors when matching to the dictionary. For chessboard corners (no marker), score reflects the ChESS corner response strength, normalised to 0..1.

Interpretation:

Score rangeMeaning
≥ 0.90High-confidence detection — use with confidence
0.75–0.90Acceptable — watch for occasional false matches
< 0.75Treat with caution; upstream sampling may be poor

Corners with score < min_border_score are filtered out before being returned, so scores below that threshold will not appear in the output.


ChArUco-Specific: CharucoDetectionResult

detect_charuco returns CharucoDetectionResult rather than a bare TargetDetection:

#![allow(unused)]
fn main() {
pub struct CharucoDetectionResult {
    pub corners:   Vec<CharucoCorner>,
    pub markers:   Vec<MarkerDetection>,
    pub alignment: GridAlignment,
}
}

markers

Raw list of decoded ArUco markers, one per cell that passed decoding. Each MarkerDetection carries:

  • id: decoded dictionary ID.
  • border_score: the contrast score for the border ring (maps to score in LabeledCorner for 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.880.97. Scores consistently below 0.80 suggest image blur, poor print quality, or an incorrect inset_frac.

Migration Guide — 0.10.0

This guide collects every breaking change in the 0.10.0 release for upstream consumers of the calib-targets workspace, with before/after snippets for Rust, the JSON config wire format, and the Python bindings.

The changes fall into six themes:

  1. Chessboard tuning is now opt-in (advanced)
  2. min_corner_strength is a stable top-level field
  3. Diagnostics moved behind a diagnostics cargo feature
  4. cell_size is back on ChessboardDetection
  5. #[non_exhaustive] + named constructors everywhere
  6. Chessboard algorithm renamed to SeedAndGrow

If you only read corners off a detection result and never construct config or result structs by literal, most of this does not affect you — the serialized JSON for a default detection is unchanged and detection behaviour is byte-identical.


1. Chessboard tuning is now opt-in

The ~40 per-stage chessboard tuning knobs that previously lived flat on DetectorParams (via a ChessboardTuning sub-struct flattened into the wire format) have moved behind an opt-in, semver-exempt advanced block.

  • ChessboardTuning was renamed AdvancedTuning. It is re-exported from the chessboard crate root and the calib_targets::chessboard facade. It is documented but explicitly unstable: its fields are NOT covered by semver and may be renamed, retyped, or removed between minor versions. It is #[non_exhaustive], so build it from AdvancedTuning::default() and mutate the knobs you need.

  • DetectorParams now carries four stable fieldsgraph_build_algorithm, min_labeled_corners, max_components, min_corner_strength — plus an opt-in advanced: Option<Box<AdvancedTuning>>. Attach overrides with with_advanced(...); read the effective tuning (configured or default) with effective_tuning(). With advanced unset, detection is byte-identical to the previous defaults.

Rust

#![allow(unused)]
fn main() {
// before
let mut params = DetectorParams::default();
params.tuning.cluster_tol_deg = 9.0;
params.tuning.seed_edge_tol = 0.18;

// after
use calib_targets::chessboard::{AdvancedTuning, DetectorParams};

let mut advanced = AdvancedTuning::default(); // #[non_exhaustive]: start from default
advanced.cluster_tol_deg = 9.0;
advanced.seed_edge_tol = 0.18;
let params = DetectorParams::default().with_advanced(advanced);

// read the knobs the detector will actually use (configured or default):
let tol = params.effective_tuning().cluster_tol_deg;
}

JSON config wire format

Every tuning knob except min_corner_strength now lives under a nested "advanced" object instead of at the top level. Old flat configs that set advanced knobs at the top level silently fall back to the defaults for those knobs — serde ignores the unknown top-level keys. Move them into an "advanced" block to carry them forward. The nested block is omitted entirely when no advanced tuning is set.

// before (flat)
{
  "graph_build_algorithm": "chessboard_v2",
  "min_labeled_corners": 8,
  "max_components": 3,
  "cluster_tol_deg": 9.0,
  "seed_edge_tol": 0.18
}

// after (advanced knobs nested)
{
  "graph_build_algorithm": "seed_and_grow",
  "min_labeled_corners": 8,
  "max_components": 3,
  "min_corner_strength": 0.0,
  "advanced": {
    "cluster_tol_deg": 9.0,
    "seed_edge_tol": 0.18
  }
}

Python

The Python ChessboardParams dataclass keeps the knobs flat for ergonomics, but to_dict() nests the advanced knobs under "advanced" to match the Rust wire format (and from_dict() reads them back from there). No code change is required to set a knob — but note:

  • The unused projective_line_tol_rel knob was removed. It was serialized into the advanced block but never read by the Rust detector, so it was a no-op. Drop the keyword from any ChessboardParams(...) call that set it. Serialized configs that still carry the key continue to deserialize (the extra key is ignored).
# before — projective_line_tol_rel had no effect and is now removed
params = ChessboardParams(cluster_tol_deg=9.0, projective_line_tol_rel=0.05)

# after
params = ChessboardParams(cluster_tol_deg=9.0)

FFI

The C ABI ct_chessboard_params_t keeps the stable fields directly and gates the advanced knobs behind a has_advanced flag plus a nested ct_chessboard_advanced_t. Initialise from ct_chessboard_params_default_values before flipping has_advanced so the advanced fields start from valid defaults, then regenerate against the updated header.


2. min_corner_strength is a stable top-level field

min_corner_strength (the Stage-1 ChESS-response pre-filter) was promoted out of the advanced knobs into the stable DetectorParams core. Its serialized key stays top-level "min_corner_strength", so that one key is wire-compatible with the previous flat layout — no migration needed for configs that set it. Setting it on a nested params.chessboard (ChArUco / PuzzleBoard / marker) keeps working.

#![allow(unused)]
fn main() {
// works the same before and after — now a documented stable field
let params = DetectorParams {
    min_corner_strength: 0.5,
    ..DetectorParams::default()
};
}

3. Diagnostics moved behind a diagnostics cargo feature

The chessboard detector previously assembled a full per-stage DebugFrame on every detect() / detect_all() call and then discarded it. That work is now skipped on the hot path, and the diagnostics surface is opt-in.

  • calib-targets-chessboard gains a diagnostics cargo feature (OFF by default). It gates the diagnostics module (DebugFrame, IterationTrace, StageCounts, the per-stage trace types, DEBUG_FRAME_SCHEMA) and the Detector::detect_with_diagnostics / detect_all_with_diagnostics entry points. Without the feature these names are absent from the public API.

  • The calib_targets facade gains a matching diagnostics feature (OFF by default) that forwards to calib-targets-chessboard/diagnostics and gates detect_chessboard_with_diagnostics.

  • The dataset feature implies diagnostics. The language bindings (Python, WASM, FFI) enable diagnostics unconditionally, so their diagnostic entry points are unchanged.

Behaviour on the detect() path is byte-identical — the same labelled ChessboardDetection.

Renamed entry point. The old detect_chessboard_debug / detect_chessboard_debug_with_config helpers are now the single detect_chessboard_with_diagnostics, which takes the ChESS DetectorConfig explicitly.

Rust

#![allow(unused)]
fn main() {
// before
let frame = detect_chessboard_debug(&img, &params);

// after — requires the `diagnostics` feature on `calib-targets`
use calib_targets::detect::{default_chess_config, detect_chessboard_with_diagnostics};

let frame = detect_chessboard_with_diagnostics(&img, &default_chess_config(), &params);
}
# Cargo.toml — turn the surface back on
calib-targets = { version = "...", features = ["diagnostics"] }

4. cell_size is back on ChessboardDetection

ChessboardDetection regained a stable cell_size: Option<f32> field, populated on the normal detect() path with the seed-derived grid pitch. The type stays #[non_exhaustive], so reading code is unaffected; code constructing it by literal across crates must route through the constructor + builder:

#![allow(unused)]
fn main() {
// constructing across crates (e.g. test fixtures)
let det = ChessboardDetection::new(corners).with_cell_size(31.4);

// reading
if let Some(pitch) = det.cell_size {
    // grid pitch in pixels
}
}

The field is mirrored across the bindings:

  • PythonChessboardDetectionResult.cell_size (float | None).
  • WASMcell_size: number | null on the chessboard result.
  • FFIct_chessboard_result_t.cell_size is now a ct_optional_f32_t (has_value == CT_TRUE carries the pitch). The generated header was regenerated; recompile against it.

5. #[non_exhaustive] + named constructors

Workspace-wide, the public config / spec / report / result types are now #[non_exhaustive] with named constructors. Reading fields is unaffected. External code can no longer build these types with a struct literal or match them exhaustively from another crate — route literal construction through the constructor (and, where present, with_* setters), and add .. to exhaustive patterns.

This is a pure API-surface change: detection behaviour, tuning defaults, and every serialized JSON shape are unchanged.

#![allow(unused)]
fn main() {
// before — struct literal across crates no longer compiles
let corner = LabeledCorner { position, score, grid: None, id: None, target_position: None };

// after — minimal `new` + builder setters
let corner = LabeledCorner::new(position, score).with_grid(grid);
}

Representative constructors (non-exhaustive list):

  • core: TargetDetection::new, LabeledCorner::new (+ with_grid / with_id / with_target_position).
  • chessboard: ChessboardDetection::new (+ with_cell_size), ChessboardCorner::new.
  • charuco: CharucoBoardSpec::new (+ with_marker_layout), CharucoDetectConfig::new, CharucoDetectReport::new.
  • puzzleboard: PuzzleBoardSpec::new (+ with_origin), PuzzleBoardDetectConfig::new, PuzzleBoardDetectReport::new.
  • marker: MarkerBoardSpec::new (+ with_cell_size), MarkerCircleSpec::new, MarkerBoardDetectConfig::new.
  • print: ChessboardTargetSpec::new, CharucoTargetSpec::new, PuzzleBoardTargetSpec::new, MarkerBoardTargetSpec::new.

When adding a field to one of these types later is a non-breaking change because they are #[non_exhaustive] — that is the point of the policy.


6. Chessboard algorithm renamed to SeedAndGrow

The chessboard grid-build algorithm formerly called ChessboardV2 is now SeedAndGrow — a name that describes what the pipeline does (find a self-consistent 4-corner seed, then grow the grid outward) instead of its development history. The companion Topological variant is unchanged, and SeedAndGrow is still the default, so code that relies on the default is unaffected. This is a clean break: the old chessboard_v2 spelling is gone everywhere, with no compatibility alias.

Rust

#![allow(unused)]
fn main() {
use calib_targets::chessboard::{DetectorParams, GraphBuildAlgorithm};

// before
let params = DetectorParams {
    graph_build_algorithm: GraphBuildAlgorithm::ChessboardV2,
    ..DetectorParams::default()
};

// after
let params = DetectorParams {
    graph_build_algorithm: GraphBuildAlgorithm::SeedAndGrow,
    ..DetectorParams::default()
};
}

JSON config wire format

The serialized value changed from "chessboard_v2" to "seed_and_grow". There is no alias, so a config that still sets "chessboard_v2" now fails to parse — update it. Configs that omit the key (the common case) are unaffected, since the default is seed_and_grow.

// before
{ "graph_build_algorithm": "chessboard_v2" }

// after
{ "graph_build_algorithm": "seed_and_grow" }

Python

# before
ChessboardParams(graph_build_algorithm="chessboard_v2")

# after
ChessboardParams(graph_build_algorithm="seed_and_grow")

FFI

The C ABI constant CT_GRAPH_BUILD_ALGORITHM_CHESSBOARD_V2 is now CT_GRAPH_BUILD_ALGORITHM_SEED_AND_GROW (its value is unchanged, 0). Update any references and recompile against the regenerated header.

WASM / TypeScript

The GraphBuildAlgorithm union type is now "topological" | "seed_and_grow".


Verifying your migration

  • A default detection’s serialized JSON is unchanged; if your pipeline round-trips config or result dicts, the only shape change is the nested "advanced" block (omitted when unset).
  • effective_tuning() with advanced unset is byte-identical to AdvancedTuning::default() — there is no silent behaviour drift from the split.
  • If a previously-tuned config stopped changing detection, you are almost certainly hitting the flat→nested advanced fallback (theme 1): move the knobs under "advanced".

Algorithms

This section documents the building-block algorithms the workspace’s detectors compose, one focused page per algorithm. Each page is target-independent: it describes the algorithm itself — its inputs, the math, and the invariants it guarantees — without committing to any one calibration target. The Pipelines section then shows how each target’s end-to-end detector chains these blocks together.

Read an algorithm page when you want to understand what a stage does and why it is correct; read a pipeline page when you want to understand how a particular target is detected end to end.

The blocks

AlgorithmCrate / moduleRole
ChESS corner detectionchess-corners (external front-end)The feature-input contract: sub-pixel saddle position + two undirected local axes per corner.
Axis clusteringprojective_grid::clusterRecover the two global grid-direction centres {Θ₀, Θ₁} from per-corner dual axes.
Topological grid finderprojective_grid::topologicalThe sole grid builder: Delaunay → axis-driven edge classify → quad merge → flood-fill walk → component merge → lattice fit.
Recovery & validationprojective_grid::sharedRecall boosters (grow / fill / extension), the shared precision pass (drop_set), and grid-result normalization.
Homography & lattice fitprojective_grid::geometryNormalized DLT projective fit; HomographyQuality as a diagnostic.
ArUco bit decodecalib-targets-arucoGrid-aware bit sampling in rectified space, with explicit bit order / polarity / borderBits.
PuzzleBoard edge-code decodecalib-targets-puzzleboardDecode interior edge-midpoint dots against the 501×501 master code.
ChArUco alignment & corner IDscalib-targets-charucoGrid-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::orientation was removed workspace-wide. The only orientation signal is Corner.axes: [AxisEstimate; 2], two undirected local lattice directions. Any circular mean over axis angles MUST accumulate (cos 2θ, sin 2θ) and halve the resulting atan2; 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 axesaxes: [AxisEstimate; 2], the two orthogonal grid directions visible in the corner’s immediate neighbourhood. Each AxisEstimate is an angle ∈ [0, π) plus a 1σ angular uncertainty (sigma).
  • Quality scalarsstrength (the ChESS response magnitude), contrast (local light/dark separation), and fit_rms (the residual of the corner-model fit). These feed the prefilter: a corner is kept when strength ≥ min_corner_strength and fit_rms ≤ max_fit_rms_ratio · contrast.

In the workspace’s shared types this corner is calib_targets_core::AxisEstimate carried on a detector-specific input type (e.g. calib_targets_chessboard::ChessCorner).

Orientation is axes-only

This is the load-bearing contract for everything downstream:

  • There is no single-orientation field. Corner::orientation was 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:

  • RingFit orders the two axis slots consistently by construction.
  • DiskFit can 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 a DiskFit swap 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 OrientedFeature shapes 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 two AxisObservations (angle, sigma) and a detector strength. Axes whose sigma is the no-info sentinel (≥ π) or non-finite are skipped; callers pre-filter to the features they want to vote (the chessboard passes only its Strong corners).
  • Output:
    • AxisClusterCenters { theta0, theta1 } in [0, π) with theta0 ≤ theta1.
    • A per-feature AxisAssignmentCanonical (axes[0] matches Θ₀), Swapped (axes[0] matches Θ₁), or NoCluster (neither axis is close enough to either centre).

The algorithm

  1. Circular histogram. Build a smoothed histogram on [0, π) with num_bins bins. For every feature and every axis k ∈ {0, 1}, add a vote at wrap_pi(axes[k].angle) weighted by strength / (1 + axes[k].sigma) — stronger, more-certain axes vote harder.
  2. Smoothing. Convolve with a [1, 4, 6, 4, 1] / 16 circular kernel so single-bin noise does not masquerade as a peak.
  3. 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 least peak_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.
  4. 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

Topological grid finder

Code: projective_grid::topological (reached via detect_grid_all — the sole grid builder, selected by LatticeKind + Evidence, with no algorithm enum). In-repo deep-dive: docs/algorithms/topological-grid-detection.md.

The topological grid finder is the sole grid builder in the workspace. Given a cloud of oriented features (positions + two undirected local axes each) and the two global grid directions from axis clustering, it recovers an integer (i, j) corner lattice without ever sampling the image again. Every target type — chessboard, ChArUco, PuzzleBoard, marker board — routes through this one path.

It is the Shu / Brunton / Fiala (2010) topological grid finder, with the paper’s image-color cell test replaced by an axis-alignment test so the core stays image-free and tolerant of perspective and radial distortion.

Historical note. An earlier SeedAndGrow builder once coexisted with this one behind a GraphBuildAlgorithm selector. It has been removed; GraphBuildAlgorithm is now a single-variant, #[non_exhaustive] enum (Topological) retained only so the config schema stays stable if a future alternative builder is added. There is no algorithm choice to make.

Vocabulary

  • Grid edge — a link between two corners that runs along the lattice (a true cell side).
  • Diagonal edge — a link that crosses a cell corner-to-corner. Delaunay introduces one per cell; the pipeline identifies and removes it.
  • Spurious edge — a link that is neither a cell side nor a cell diagonal (a triangulation artefact).
  • Quad — four corners forming one lattice cell, bounded by four grid edges.
  • Axis slot — each corner stores its two axes in fixed slots (axes[0], axes[1]); axes are undirected, compared modulo π.

Stages

The generic, image-free core runs these stages (full source under crates/projective-grid/src/detect/square/topological/):

  1. Axis cache + usability prefilter. Precompute each feature’s two axis angles and an informative flag per slot (an axis is informative when its sigma is below max_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 within cluster_axis_tol_rad of one global grid direction (modulo π).
  2. 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.
  3. Edge classification (Grid / Diagonal / Spurious). For each Delaunay half-edge a → b, compare the edge direction atan2(b − a) (modulo π) to each endpoint’s informative axes. The edge is a Grid edge when both endpoints see it within axis_align_tol_rad of 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 fixed axis ± π/4 rule — under a projective warp a projected diagonal is not the angle bisector in image space.
  4. 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.
  5. 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).
  6. 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).
  7. Per-component validation + projective fit (generic). A pattern-agnostic geometry gate (line collinearity, local-homography residual, edge-length band) plus a projective fit with a residual gate. The chessboard wrapper disables this stage (pushes its tolerances to +∞) because it owns its own mandatory geometry check downstream; the core is asked only for labelled components.
  8. Orchestration. Component solutions are sorted by labelled-corner count (ties broken by smallest source index, for determinism). Every unplaced feature is collected into a global rejected/unlabelled set. detect_grid returns the largest component; detect_grid_all returns 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 between projective-grid and the chessboard adapter.
  • Axis clustering — supplies the two global grid directions used by the usability prefilter.
  • Recovery & validation — the chessboard-specific component merge, parity alignment, recall boosters, and mandatory precision pass that run after this core.
  • The Grid Model — the public detection surface (Evidence, detect_grid / detect_grid_all, GridSolution).

Recovery & validation

Code: projective_grid::shared (merge_components_local, the fill/grow/extension boosters, the validate precision pass, and LabelledGrid::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:

  1. Recovery boosters — recover corners the topological walk missed.
  2. The precision pass (drop_set) — prove the labelled set, dropping anything that slipped through.
  3. 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 extension submodule) — homography-based outward extension, global or per-candidate local-H.
  • Weak-cluster rescue (caller-driven) — re-admit NoCluster corners 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) → pixel warp 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:

  1. Rebase the coordinate bbox minimum to (0, 0) so every label is non-negative (the hard non-negative-label invariant for overlay / calibration consumers).
  2. 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.
  3. 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

Homography & lattice fit

Code: projective_grid::geometry (Homography, estimate_projective, homography_from_4pt, apply_projective, HomographyQuality).

A planar calibration target maps to the image through a single projective transform (a homography) — up to lens distortion. Several stages need to fit that transform: the recovery & validation local-H residual check fits a 4-point homography per corner, and the final lattice fit recovers the model-plane-to-image transform reported in GridSolution::fit. This page describes that fit.

The fit

  • Normalized DLT. estimate_projective solves for the homography from N ≥ 4 model→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_4pt solves the exact minimal-case transform from four correspondences — used for the local per-corner predictions where exactly four neighbours bracket a corner.
  • Mapping. apply_projective maps 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 ModelGridSolution::fit and the LatticeFit / ResidualSummary output shapes.
  • calib-targets-core — the core Homography / RectifiedView rectification 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_rel of the square side (ChArUco uses < 1.0), with an extra inset_frac inset 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_score are 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.
  • Polarityblack = 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

PuzzleBoard edge-code decode

Code: calib-targets-puzzleboard (edge sampling + the (D4, origin) sweep). Based on Stelldinger 2024, arXiv:2409.20127.

A PuzzleBoard is a self-identifying chessboard: every interior edge carries a midpoint dot, and the dot pattern uniquely identifies any ≥ 4×4 fragment’s position on a fixed 501×501 master code. Edge-code decode turns the visible dots into an absolute position on that master, so a partial view still produces absolute corner IDs and object-space coordinates.

The master code

The board uses two embedded cyclic maps, committed as binary blobs so the runtime detector constructs nothing:

  • map A, shape (3, 167), for horizontal interior edges.
  • map B, shape (167, 3), for vertical interior edges.

Dots encode bits directly: white dot = 0, black dot = 1. Around a corner (i, j) the four incident interior edges read:

corner (i,j) ---- A(j,i) ---- corner (i+1,j)
     |                            |
   B(j,i)                      B(j,i+1)
     |                            |
corner (i,j+1) -- A(j+1,i) -- corner (i+1,j+1)

The maps are cyclic of period 501, so any sufficiently large window of edges pins a unique master origin — the paper’s uniqueness property.

Stages

  1. Edge sampling. For each interior edge of the detected grid, sample a disk of radius sample_radius_rel × edge_len (min 1 px) at the edge midpoint, derive local bright/dark references from the two adjacent cells, and classify the midpoint into bit ∈ {0, 1} with a confidence ∈ [0, 1] proportional to how far the midpoint sits from the reference mid-level.
  2. Confidence filter. Drop bits below min_bit_confidence; a low-confidence bit becomes “unknown” rather than a guessed 0/1.
  3. Minimum-edges gate. Require enough surviving edges for at least a min_window × min_window fragment (min_window² ≥ 4² is the paper’s uniqueness floor for the 501×501 code). A sparse grid fails here immediately.
  4. Origin sweep. Find the best (D4 rotation, master_origin_row, master_origin_col) hypothesis. This is a two-axis choice:
    • Search scopeFull enumerates all 8 × 501 × 501 hypotheses against the master maps; FixedBoard scans only 8 × (rows+1)² hypotheses against a declared PuzzleBoardSpec’s own bit pattern (much cheaper, and it sidesteps the per-view origin drift described below).
    • ScoringHardMajority majority-votes the bits and gates on a bit-error-rate threshold (max_bit_error_rate); SoftLogLikelihood sums log_sigmoid(κ × bit_confidence × ±1) per bit (clipped to a floor), picks the max, and tracks a (best − runner-up) margin for ambiguity gating. The soft mode is more robust on ambiguous / near-symmetric fragments.
  5. Best-component selection. When several disconnected grid components decode, rank them by edges-matched, then BER, then soft score. Conflict detection: two well-supported components that disagree on the master origin are an unrecoverable ambiguity and are refused rather than guessed.

The partial-view guarantee

For a given printed board, any subset of its corners decodes to the same master IDs a full-view decode would produce. This holds across single-camera captures that frame only part of a large board and across multi-camera rigs where each camera sees a different fragment — in both cases overlapping corners share master IDs without further stitching.

The per-view master origin is otherwise not fixed: it shifts with which print-corner the chessboard stage picked as local (0, 0), which depends on what the camera saw. FixedBoard sidesteps that by scoring against the declared board rather than the full master.

Decoder-design note

The naive hard-bit decoder + 501² × D4 exhaustive sweep + hard BER gate already clears precision and recall at zero wrong labels on the workspace regression set. A coherent-hypothesis matcher upgrade is deferred — do not pre-emptively rewrite without a concrete precision gap demonstrated on a new dataset.

Cross-references

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.

  1. 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), where sign = ±1 is the marker’s expected bit and κ (bit_likelihood_slope) sets the per-bit confidence. The per_bit_floor clip 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).

  2. 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).

  3. 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 clears alignment_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.

  4. 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’s CharucoBoard numbering),
  • a target_position in board units (mm when cell_size > 0),
  • the sub-pixel position from 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

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)     │    │           │    │           │
                 └───────────┘    └───────────┘    └───────────┘
  1. Input imageimage::GrayImage or a GrayImageView. The facade helpers in calib_targets::detect accept either.
  2. Corner front-end — the ChESS X-junction detector via the chess-corners crate produces a raw corner cloud (sub-pixel position + two undirected axes + strength / contrast / fit_rms). The workspace default is calib_targets::detect::default_chess_config().
  3. Grid recovery — every target then runs the same grid stack: axis clustering → the topological grid finderrecovery & validation. This is the chessboard pipeline, and it is the shared spine of all the others.
  4. 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.
  5. Output — every detector produces a TargetDetection wrapping a Vec<LabeledCorner>; higher-level detectors wrap that in their own result struct with extra metadata (marker decodes, alignment, IDs). See Understanding Results.

The pages

PipelineComposesSource of truth
Regular gridclustering + topological grid + validationdocs/algorithms/topological-grid-detection.md
Chessboardthe full grid stack, precision-anchoredcrates/calib-targets-chessboard/docs/PIPELINE.md
PuzzleBoardchessboard grid + edge-code decodecrates/calib-targets-puzzleboard/docs/PIPELINE.md
ChArUcochessboard grid + ArUco decode + alignmentcrates/calib-targets-charuco/docs/PIPELINE.md
Marker boardchessboard grid + 3-circle anchoringcrates/calib-targets-marker/docs/PIPELINE.md

One builder, everywhere

There is no grid-builder choice to make. GraphBuildAlgorithm is a single-variant, #[non_exhaustive] enum (Topological) retained only as a reserved config seam; the topological grid finder is the sole builder for every target, including ChArUco. A config that carries a legacy value is re-pinned to Topological on load.

Output types

Output types are standardised in calib-targets-core as TargetDetection with LabeledCorner values. The chessboard layer’s labelling carries the precision contract every target inherits: wrong (i, j) labels are unrecoverable for downstream calibration, so the grid stage may fail to detect a corner but must never deliver a wrong label. Higher-level crates enrich that output with additional metadata (marker detections, rectified views, per-corner IDs).

Regular grid pipeline

Composes: axis clusteringtopological grid finderrecovery & validation. Source of truth: docs/algorithms/topological-grid-detection.md. Public surface: The Grid Model.

The regular grid pipeline is the target-free end-to-end path: a cloud of oriented point features in, a labelled (i, j) lattice out, with no image and no calibration vocabulary. It is the standalone projective-grid crate, and it is the spine every target detector builds on. Reach for it directly when you have a grid that is not one of the workspace’s named targets — a laser-dot cloud, a scanned form, a photographed board game.

End-to-end stages

OrientedFeature<2>[]  (positions + two undirected axes each)
 →  axis clustering        recover global directions {Θ₀, Θ₁}   (optional hint)
 →  topological grid       Delaunay → classify → quads → walk    (the builder)
 →  validation + fit       line / local-H / residual gate
 →  GridSolution           labelled (i, j) component(s) + projective fit
  1. Axis clustering (optional). If the caller can supply the two global grid directions, they gate the topological usability prefilter. For the bare projective-grid entry points the caller may skip this and let the detector synthesize axes from neighbour geometry.
  2. 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.
  3. 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_px gate. 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.
  4. Output. A GridSolution per component — grid: LabelledGrid, fit: Option<LatticeFit>, rejected: Vec<RejectedFeature>.

Public surface

The detection input is the Evidence enum — it names exactly how much orientation the caller can supply (Positions, Oriented1, Oriented2, Oriented3). The native square shape is Oriented2; less-oriented kinds synthesize the missing axes up front. detect_grid returns the largest component; detect_grid_all returns all of them. A separate check_consistency entry point scores pre-labelled features against a single projective fit. The full surface — DetectionRequest, GridSolution, RejectedFeature, and the worked example — is documented in The Grid Model and the Regular Grid Detection example.

Hex lattices

The same pipeline detects a hexagonal point lattice on the topological path: the Delaunay triangles are the unit cells, so the diagonal/quad-merge stage is bypassed and the axial (q, r) walk runs directly, with the projective-fit back-half shared.

Failure modes

SymptomLikely stageWhat it means
GridError::InsufficientEvidenceinputToo few features to assemble a 2×2 seed cell.
GridError::DegenerateGeometryinputCoincident or collinear points; no usable lattice spread.
GridError::UnsupportedCombinationdispatchThe (lattice, evidence) pair has no algorithm (e.g. Hex + Oriented1). Returned rather than guessed.
Few entries, many Unlabelled rejectstopological walkNoisy or low-resolution axes — the classifier could not build enough confident grid edges.
ValidationDropped rejectsvalidationPlaced by the walk but failed line / local-H / edge-band; a gross mislabel was caught.
ResidualTooHigh rejectsfitReprojection residual over max_residual_px; loosen only if the geometry is genuinely distorted.

Tuning

For the bare grid crate, tuning is DetectionParams:

  • max_residual_px — the fit residual gate. Raise on genuinely distorted captures; it is the precision lever, so prefer the smallest value that still recovers the grid.
  • topological sub-config — the axis / quad / cell-size-band tolerances of the topological finder.
  • validate sub-config — the line / local-H tolerances of the active validation stage.

When this pipeline runs inside a target detector, these knobs are mostly set by the wrapper (the chessboard wrapper disables the validate stage and owns its own checks); see Tuning the Detector.

Cross-references

  • The Grid Model — the full public surface and a worked example.
  • Chessboard pipeline — the same spine with the chessboard precision discipline layered on.
  • docs/algorithms/topological-grid-detection.md — the generic core in full.

Chessboard pipeline

Composes the full grid stack: ChESS cornersaxis clusteringtopological gridrecovery & 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):

#StageIn → OutWhat it does
1prefilterChESS corners → usable-flagged cornersKeep a corner iff strength ≥ min_corner_strength and fit_rms ≤ max_fit_rms_ratio · contrast. Weak corners are kept as positions with no-information axes (so indices stay stable) but cannot vote.
2cluster_axesstrong corners’ axes → {Θ₀ ≤ Θ₁} + per-corner slot labelThe generic axis clustering (histogram + plateau peak picking + double-angle 2-means), then the DiskFit slot-coherence repair (below).
3topological_gridoriented features + cluster centres → labelled componentsThe topological grid finder (detect_grid_all); its own post-build validation / residual / recovery are disabled — the chessboard owns those downstream.
4recover_componentsmerged components → boosted, re-merged gridPer-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.
5final_geometry_checklabelled set → drop list + refuse flagMandatory, 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.
6outputsurviving set → ChessboardDetectionBuild 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. ChessboardDetection is Coord{u,v}-based; what moved is where normalization lives — the rebase + canonicalise + sort algorithm is now owned by projective_grid::LabelledGrid::normalize, with the output stage merely calling it.

Key invariants

These hold across every stage that can attach a label, and are what make a miss recoverable but a false positive impossible:

  • Two grid directions. Clustering recovers {Θ₀, Θ₁} (≈ 90° apart) as the only global axis prior. All axis means use the undirected (cos 2θ, sin 2θ) accumulation — there is no Corner::orientation, only Corner.axes: [AxisEstimate; 2].
  • Parity / edge-slot-swap. A corner’s four cardinal neighbours sit at the opposite axis-slot parity by construction. Every attachment checks that the candidate edge crosses a slot-swap boundary, so a diagonal or skipped-corner attachment is rejected structurally, not by a magnitude threshold.
  • Geometry check can only subtract. Stage 5 never adds or relabels; a corner that survives every stage has been proven to sit at a real intersection.
  • Non-negative labels. Output rebases the labelled bbox minimum to (0, 0).

DiskFit slot-coherence repair (Stage 2)

The ChESS detector’s DiskFit mode can uniformly pick the wrong antipodal dark sector, reversing a corner’s (axes[0], axes[1]) ordering and breaking the parity invariant globally. A live recall safety-net (slot_coherence) detects this with a gross-imbalance gate, BFS-2-colours the clustered corners at cell spacing, and swaps the two AxisEstimate slots of whichever corners disagree. A bipartite-quality gate aborts the pass unless the 2-colouring is essentially perfect, so it can only add recall, never a wrong label. Under RingFit the split is already ~50/50 and the pass is a no-op.

Multi-component dispatch

Detector::detect_all is the multi-board entry point: it returns several ChessboardDetections (up to max_components) when one image contains physically distinct grids. Within a single image, the topological facade already merges connected components, so a single physical board split into disjoint sub-grids (e.g. ChArUco rows separated by markers) is reunited in label space by the Stage-4 merge. The precision contract holds per emitted component. The workspace explicitly does not support multiple separate physical boards in one frame.

Failure modes

Identify the stage from the serializable topological trace (pipeline::trace_topological, layered over the production path) and the final-check GeometryCheckTrace drop counters, then consult:

SymptomLikely stageKnob to tryNotes
No detection, no grid directionsStage 2 (clustering)min_peak_weight_fraction, peak_min_separation_degThe two grid axes never separated — common on very-bad-light frames.
No cell size / no seedStage 3 (topological)detect_chessboard_best with sweep_default()No quad assembled. Builder tolerances are internal.
Very few cornersStage 4 (recover)attach_search_rel, attach_axis_tol_deg, step_tol, edge_axis_tol_degGrid grew but couldn’t extend — common on heavily distorted views.
Many dropped cornersStage 5 (geometry check)geometry_check_local_h_tol_relInvariants found outliers; check the drop reasons.
Wrong (i, j) labelsneverFile a bug. The precision contract has been violated; do not tune around it.

Tuning

DetectorParams splits into a stable core (graph_build_algorithm [single-variant], min_labeled_corners, max_components, min_corner_strength) plus an opt-in, non-semver advanced (AdvancedTuning) block of per-stage knobs. Leave advanced unset unless a specific input fails and you have evidence for the change. For challenging images use detect_chessboard_best with DetectorParams::sweep_default() (three configs varying only recall-affecting tolerances; all preserve the precision invariants). The full knob table is in Tuning the Detector and the chessboard crate chapter.

Cross-references

  • The Chessboard Detector — the full invariant stack, the topological-trace diagnostics surface, and a quickstart.
  • crates/calib-targets-chessboard/docs/PIPELINE.md — the canonical stage map this page mirrors.
  • The downstream pipelines that build on this spine: PuzzleBoard, ChArUco, Marker board.

PuzzleBoard pipeline

Composes: the chessboard grid stack + PuzzleBoard edge-code decode. Source of truth: crates/calib-targets-puzzleboard/docs/PIPELINE.md. Crate reference: calib-targets-puzzleboard.

A PuzzleBoard is a self-identifying chessboard: every interior edge carries a midpoint dot, and the dot pattern uniquely identifies any ≥ 4×4 fragment’s position on a 501×501 master code. The pipeline runs the full chessboard grid stack first, then samples the interior-edge dots and decodes them into absolute master positions — so a visible fragment still yields absolute corner IDs and object-space coordinates.

End-to-end stages

#StageIn → OutWhat it does
0chessboard grid detectChESS 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.
1edge samplinglabelled corners + image → observed edgesPer 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].
2bit-confidence filterobserved edges → high-confidence edgesDrop bits below min_bit_confidence; low-confidence bits become unknown.
3minimum-edges gatefiltered edges → pass / failRequire enough edges for a min_window × min_window fragment (min_window² ≥ 4², the uniqueness floor).
4origin sweepfiltered edges + master maps → (D4, origin) + scoreEnumerate (D4 rotation, master_origin) hypotheses — Full (8 × 501 × 501) or FixedBoard (8 × (rows+1)²) scope, with HardMajority (BER gate) or SoftLogLikelihood (margin gate) scoring. See the decode algorithm.
5best-component selectionper-component results → one decodeRank components by edges-matched, then BER, then soft score. Conflict detection: two well-supported components disagreeing on master origin → InconsistentPosition (refuse, don’t guess).
6emit detectionbest decode → resultRebase (i, j) to non-negative; sort by (j, i); assign absolute IDs (j·501 + i) and target_position (i·cell_size, j·cell_size).

What it inherits from the chessboard detector

The full chessboard topological pipeline runs on the input ChESS corners — prefilter, axis clustering, the topological grid walk, booster-driven component recovery, and the mandatory final geometry check. PuzzleBoard already defaulted to the topological builder, which is now the only builder; graph_build_algorithm is a single-variant reserved seam.

The partial-view guarantee

For a given printed board, any subset of its corners decodes to the same master IDs a full-view decode would produce — across single-camera captures that frame only part of a large board and across multi-camera rigs where each camera sees a different fragment. Overlapping corners share master IDs without further stitching. FixedBoard mode sidesteps the per-view master-origin drift by scoring against the declared board rather than the full master.

Failure modes

SymptomLikely stageWhat it means / knob to try
No grid componentsStage 0 (chessboard)Sparse / empty corner cloud — see the chessboard failure modes. Try --upscale on small boards.
NotEnoughEdgesStage 2–3Too few high-confidence edge bits survived. Lower decode.min_bit_confidence; check that interior dots are resolved at this image scale.
Every hypothesis over BERStage 4 (HardMajority)Board too small or too noisy. Raise decode.max_bit_error_rate, or switch to SoftLogLikelihood (more robust on ambiguous fragments).
Small / ambiguous decode marginStage 4 (SoftLogLikelihood)Near-symmetric fragment or few high-confidence bits. Capture a larger fragment; the margin gate is correctly refusing a coin-flip.
InconsistentPositionStage 5Two sub-grids disagree on the master origin — an unrecoverable ambiguity. Crop to a single board, or use FixedBoard with the known spec.
Wrong absolute IDsneverA wrong chessboard (i, j) would cause this — file a bug at the chessboard layer; the decode itself is exhaustive and gated.

Tuning

The grid side is the standard chessboard DetectorParams (under params.chessboard); the decode side is params.decode:

  • min_bit_confidence (default 0.5) — the confidence floor for an edge bit to count. Lower on blurry boards; too low admits noise bits.
  • max_bit_error_rate (default 0.3) — the HardMajority BER gate.
  • min_window (default 4) — the uniqueness floor; rarely changed.
  • search_modeFull (default) vs FixedBoard (cheaper, fixes per-view origin drift when the board is known).
  • scoring_modeHardMajority (default) vs SoftLogLikelihood (robust on ambiguous fragments; adds the margin gate).
  • search_all_components (default true) — 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

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

#StageIn → OutWhat it does
0chessboard grid detectChESS corners → Vec<ChessDetection>ChessDetector::detect_all on the topological builder. The min_corner_strength floor keeps marker-bit saddles out of the grid (below).
1grid smoothness pre-filtergrid corners + image → cleaned cornersPer-corner position vs midpoint-averaged neighbours; a deviation over grid_smoothness_threshold_rel × px_per_square triggers a local ChESS redetection or a drop.
2marker cell enumerationcorner map → Vec<MarkerCell>Per cell, require all four corners {(i,j),(i+1,j),(i+1,j+1),(i,j+1)}; skip incomplete cells.
3marker decode + alignmentcells + image → markers + alignmentScore 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.
4alignment validationmarkers + spec → inliersRequire ≥ min_marker_inliers (primary component) or ≥ min_secondary_marker_inliers (subsequent).
5ChArUco corner mappingcorners + alignment → IDed cornersMap each board-spec inner-corner position through the alignment; only inner-cell intersections get IDs (not marker corners).
6corner validationmapped corners + markers + image → validated cornersCheck each corner against its marker-predicted seed; deviation over corner_validation_threshold_rel × px_per_square → marker-constrained redetect or drop.
7emit detectionvalidated corners + alignment → resultSort typed ChArUco corners by ID; refuse below the caller’s threshold.

What it inherits from the chessboard detector — and the strength floor

ChArUco runs the full chessboard topological pipeline (prefilter, clustering, the grid walk, booster recovery, and the mandatory geometry check). The chessboard precision contract carries forward: a wrong (i, j) label here would corrupt every downstream marker match.

CharucoParams::for_board sets two chessboard knobs that adapt the shared detector to marker scenes:

  • min_corner_strength = 33.0 — an absolute ChESS-strength floor that cuts the weak corner responses on ArUco marker-bit saddles before the grid grows. Those corners are grid-consistent but lie inside the marker interior; cutting them early keeps the topological per-cell axis test from being poisoned by marker-internal X-corners. This floor — not marker presence — is the precision lever here. (It is the concern the historical ChArUco builder pin used to guard; the topological builder is now safe on marker scenes because of this floor.)
  • enable_final_edge_shape_check = false — ChArUco keeps the chessboard component recall-oriented because the marker-ID and board-alignment validation downstream is its precision gate.

chessboard.graph_build_algorithm is the single-variant topological seam; a config carrying a legacy value is re-pinned to Topological on load.

Failure modes

SymptomLikely stageWhat it means / knob to try
No grid / None from Stage 0Stage 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 4No 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 inliersStage 4Partial view or strong perspective — lower min_marker_inliers to what you reliably see.
Small soft-matcher marginStage 3 (board-level matcher)Ambiguous decodes / heavy bit noise — the margin gate is flagging a low-confidence alignment.
Corners drift off true intersectionsStage 6Weak alignment — verify the board pose / occlusion; check corner_validation_threshold_rel.
Wrong corner IDsneverA wrong chessboard (i, j) would cause this — file a bug at the chessboard layer.

Tuning

ChArUco config layers three surfaces:

  • Chessboard gridCharucoParams.chessboard is a DetectorParams (stable core + opt-in advanced). for_board pre-sets the marker-scene-safe min_corner_strength floor; do not lower it on marker boards.
  • Marker decodescan.* (ScanDecodeConfig): min_border_score (default 0.75; lower cautiously to 0.65 on blur), multi_threshold (default true), inset_frac (default 0.06; raise when borders bleed into the bit grid). marker_size_rel must match the printed board.
  • Alignment — the board-level matcher’s alignment_min_margin / bit_likelihood_slope, the downstream min_marker_inliers / min_secondary_marker_inliers floors (default 1 each — the matcher is its own gate), and corner_validation_threshold_rel (default 0.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

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

#StageIn → OutWhat it does
0chessboard grid detectChESS corners → ChessDetectionChessDetector::detectsingle best component (multi-component is not supported here).
1circle candidate detectioncorner 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.
2expected-circle matchingcandidates + spec → Vec<CircleMatch>For each of the 3 expected circles, find the nearest candidate within max_distance_cells (optional), matching by polarity.
3grid alignment estimationmatches → GridAlignment + inliersFit a dihedral transform + translation in (i, j)-space from the matched 3-circle layout; require ≥ min_offset_inliers consistent matches.
4per-corner offset mappingmatches + alignment → offsetsApply the alignment transform to each candidate cell coord; compute the delta from expected.
5emit detectiongrid + circles + alignment → resultEmit 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

SymptomLikely stageWhat it means / knob to try
No grid / None from Stage 0Stage 0 (chessboard)Sparse corner cloud or clustering failure — see the chessboard failure modes.
No / too few circle candidatesStage 1Circles 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 matchesStage 2Candidates outside max_distance_cells, or polarity mismatch vs the spec. Verify the three MarkerCircleSpec cells + polarities against the printed board.
Alignment None (too few inliers)Stage 3Fewer 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 emptyoutputlayout.cell_size is unset (or alignment failed) — target_position is only populated when both hold.
Wrong anchored IDsneverA wrong chessboard (i, j) would cause this — file a bug at the chessboard layer.

Tuning

MarkerBoardParams is layout + chessboard params + circle scoring + matching:

  • layout — the MarkerBoardSpec (rows, cols, the three MarkerCircleSpec cells + polarities, optional cell_size). The marker circles supply the geometry constraint, so the v1 expected_rows/cols and completeness_threshold no longer apply.
  • chessboard — a DetectorParams for 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 (default 6), max_distance_cells (optional), min_offset_inliers (default 1).
  • roi_cells — optional [i0, j0, i1, j1] to restrict the circle search.

Cell coordinates (i, j) in the spec refer to square cells by their top-left corner index; the cell center is at (i + 0.5, j + 0.5). Use the *_with_diagnostics entry points to inspect scored candidates, matches, and alignment_inliers when tuning.

Cross-references

  • calib-targets-marker — the crate API and key types.
  • Chessboard pipeline — the grid spine this detector anchors.
  • crates/calib-targets-marker/docs/PIPELINE.md — the canonical stage map this page mirrors.

Project Overview

calib-targets-rs is a single Cargo workspace with multiple publishable crates under crates/. The design is layered: calib-targets-core provides geometry and shared types, higher-level crates build on top, and the facade crate (calib-targets) is intended to be the main entry point.

Mesh-rectified grid

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:

ImageLabelled corners
testdata/large.png345
testdata/puzzleboard_reference/example1.png253
testdata/puzzleboard_reference/example2.png180
testdata/small2.png135
testdata/small0.png134
testdata/small3.png125
testdata/small4.png121
testdata/small5.png132
testdata/small1.png119
testdata/mid.png77
testdata/02-topo-grid/gptchess1.png60
testdata/02-topo-grid/GeminiChess1.png54
testdata/02-topo-grid/GeminiChess3.png42
testdata/02-topo-grid/GeminiChess2.png29
testdata/puzzleboard_reference/example3.png28

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 / native Oriented3 / 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):

CellAlgorithmTime
square_oriented2topological~0.6 ms
square_positionstopological (axis synthesis)~0.8 ms
hex_positionstopological (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, x increases right, y increases down.
  • Grid coordinates: i increases right, j increases 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 (not 2*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-core is the base and should not depend on higher-level crates.
  • calib-targets-chessboard depends on core for geometry and types.
  • calib-targets-aruco depends on core for rectified image access.
  • calib-targets-charuco depends on chessboard and aruco.
  • calib-targets-puzzleboard depends on chessboard and uses committed code-map blobs for absolute edge-code decoding.
  • calib-targets-marker depends on chessboard and core.
  • calib-targets-print depends on the target crates and owns printable-target rendering.
  • calib-targets is 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:

  1. calib-targets-core
  2. calib-targets-chessboard

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.

Rectified grid view 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 as axes: [AxisEstimate; 2]; there is no single-orientation field (Corner::orientation was removed workspace-wide).
  • Coord: integer grid indices (u, v) in board space, with u right and v down. Re-exported from projective-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 in calib-targets-core.
  • TargetDetection: a collection of labeled corners for one board instance.
  • TargetKind: enum for Chessboard, Charuco, or CheckerboardMarker.

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, x right, y down.
  • Grid coordinates: i right, j down, 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 standalone projective-grid crate.

For the canonical end-to-end stage map see the Chessboard pipeline; for the individual building blocks see the Algorithms section. This page is the crate’s invariant-and-API reference and goes deeper on the precision-by-construction design.

The chessboard detector takes a cloud of ChESS X-junction corners and produces an integer-labelled chessboard grid (i, j) → image position. It is precision-by-construction: every emitted label has been proven to sit at a real grid intersection by a stack of independent geometric invariants. Missing corners are acceptable; wrong corners are not.

On our private regression dataset (captured with non-negligible lens distortion and motion blur — uncommitted; see privatedata/ for how to reproduce locally) the detector achieves a high detection rate with zero wrong (i, j) labels — precision-by-construction.

A wrong label would corrupt downstream calibration; that is the constraint the algorithm refuses to break.

┌───────┐   ┌─────────┐   ┌─────────┐   ┌──────────┐   ┌─────────┐   ┌────────┐
│Corners│ ->│Prefilter│ ->│ Cluster │ ->│  Topo    │ ->│ Recover │ ->│ Geom   │
│  in   │   │(Stage 1)│   │  axes   │   │  grid    │   │  + boost│   │ check  │
└───────┘   └─────────┘   │(Stage 2)│   │(Stage 3) │   │(Stage 4)│   │(Stage 5)│
                          └─────────┘   └──────────┘   └─────────┘   └────────┘
                                                                          │
                                                                          v
                                                                     ┌────────┐
                                                                     │ Output │
                                                                     │(Stage 6)│
                                                                     └────────┘

1. Corner axes contract

The detector reads only one orientation signal per corner: ChessCorner.axes: [AxisEstimate; 2]. Convention (enforced workspace-wide and documented in CLAUDE.md):

  • axes[0].angle ∈ [0, π), axes[1].angle ∈ (axes[0].angle, axes[0].angle + π).
  • axes[1] − axes[0] ≈ π/2 — the two axes are orthogonal grid directions (NOT diagonals of unit squares).
  • The CCW sweep from axes[0] to axes[1] crosses a dark sector. This encodes parity: at parity-0 corners axes[0] ≈ Θ_horizontal (dark-entering), at parity-1 corners axes[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:

  1. Axis membership. Both C.axes[0] and C.axes[1] are within cluster_tol_deg of the two global grid-direction peaks {Θ₀, Θ₁}, each axis matching a different peak.
  2. Cluster label = axis-slot. cluster(C) = 0 iff C.axes[0] is closer to Θ₀; otherwise 1. Binary, per-corner.
  3. Parity. cluster(C) ≡ (i + j) mod 2 (modulo a global sign fixed by the seed quad).
  4. Edge orientation along the corner’s axes. For every in-graph edge C ↔ N with vector v = N.pos − C.pos, atan2(v) mod π is within edge_axis_tol_deg of exactly one of C.axes[*] AND of exactly one of N.axes[*]. (No ±π/4 offset — edges align with axes, not diagonals.)
  5. Edge axis-slot swap. Let ax_C ∈ {0, 1} be the slot of C matching the edge, and ax_N the slot of N. Require ax_C ≠ ax_N.
  6. Cell-size consistency. |v| ∈ [1 − step_tol, 1 + step_tol] × s.
  7. Line collinearity. For every labelled row / column through C with ≥ line_min_members members, C’s perpendicular residual to the fitted line is ≤ line_tol × s. Projective-line fits use a looser tolerance to absorb mild lens distortion.
  8. 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.
  9. 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 in docs/algorithms/topological-grid-detection.md.

Stage 1 — Pre-filter (inputs.rs)

Mark corner c usable iff:

  • c.strength ≥ min_corner_strength (default 0.0, off); and
  • c.contrast ≤ 0, or c.fit_rms ≤ max_fit_rms_ratio × c.contrast (default 0.5).

A corner that fails keeps its pixel position but has its axes replaced by the no-information sentinel (sigma = π), so it cannot vote on edges but the corner array is not renumbered (trace / index stability).

Stage 2 — Axis clustering (cluster/)

Recover the two global grid directions {Θ₀ ≤ Θ₁} from the strong corners’ axes with the generic axis clustering (circular histogram + plateau-aware peak picking + double-angle 2-means on (cos 2θ, sin 2θ)), and label each corner Canonical (axes[0] matches Θ₀), Swapped, or NoCluster.

Why double-angle. Axes are undirected — θ and θ + π are the same direction. Naïve circular mean over raw (cos θ, sin θ) produces zero when votes straddle the 0°/π seam. Doubling the angle wraps both halves together; the inverse halving gives a stable mean.

The DiskFit slot-coherence repair (slot_coherence.rs) then runs: when the upstream detector’s DiskFit mode uniformly reverses a corner’s (axes[0], axes[1]) ordering, a gross-imbalance gate fires, the clustered corners are BFS-2-coloured at cell spacing, and the two AxisEstimate slots of the disagreeing corners are swapped. A bipartite-quality gate aborts the pass unless the 2-colouring is essentially perfect, so it can only add recall, never a wrong label. Under RingFit it is a no-op.

Stage 3 — Topological grid (mod.rsprojective-grid)

Hand the oriented features (positions + dual axes) and the cluster centres (as an axis hint) to the topological grid finder (via detect_grid_all — the sole grid builder, no algorithm enum): Delaunay triangulation → axis-driven edge classification → triangle-pair → quad merge → flood-fill (i, j) walk → the facade’s merge_components_local. The facade’s own post-build validation / residual drop / recovery are disabled here (tolerances at +∞, recovery Off) — the chessboard owns those downstream.

Stage 4 — Recover components (recover.rs + boosters.rs)

Per labelled component: estimate the cell size from the labelled cardinal edges, then run the recovery boosters — interior gap fill + line extrapolation via fill_grid_holes, with a per-axis directional edge scale because a partially-grown component can be anisotropic before its boundaries fill in. Each addition re-runs the same axis / parity / edge-slot-swap invariants as the walk; the pass is capped by max_booster_iters. Optional weak-cluster rescue re-admits NoCluster corners within weak_cluster_tol_deg. Finally merge_components_local reunites components in label space.

Stage 5 — Final geometry check (geometry_check.rs)

Mandatory, and can only DROP (never add or relabel). It sequences the shared drop_set precision pass:

  • the shared validate (line collinearity + local-H residual) with looser geometry_check_* tolerances — catches gross mislabels (full-cell / diagonal ≈ 1.4-cell residual) without flagging accepted perspective drift;
  • the direct topological wrong-label check (interior skipped-corner edges, duplicate-pixel labels, frontier line-spacing smoothness);
  • the largest-cardinally-connected-component filter, dropping isolated leaks outside the main grid.

The detection is refused if survivors fall below min_labeled_corners.

Stage 6 — Output (output.rs)

Build a projective_grid::LabelledGrid from the surviving labelled set and call LabelledGrid::normalize() (rebase min → (0, 0); canonicalise so +u ≈ +x, +v ≈ +y; stable (v, u) sort — all owned by projective-grid). The normalized lattice Coord{u,v} is the workspace’s canonical grid-coordinate type, so it is copied straight onto each output corner with no adaptation step.


4. Why precision is by construction

The design constraint “wrong (i, j) labels are unrecoverable” is what shapes every non-obvious choice in the pipeline. Two examples:

Cell size is an OUTPUT, not an input. A naïve detector estimates a global cell size first, then uses it to set a search window. On ChArUco scenes the nearest-neighbor histogram is bimodal (marker-internal pairs at ~10 px vs true board pairs at ~55 px); even multimodal mean-shift can pick the wrong mode. The topological builder instead assembles cells from local axis topology — its quad filter uses a per-component edge-length band (relative to that component’s own median), never a global scalar — and the chessboard recovery stage then derives each component’s cell size from its own labelled cardinal edges. There is no global pitch to mispick. See the per-component cell-size band on the Topological grid finder page and the Cell-size gotcha in CLAUDE.md.

Edges align with axes, not diagonals. Some chessboard detectors model ChESS corners as having a single orientation θ and check that grid edges align with θ ± π/4. It reads the two axes directly and requires edges to align with one axis (per invariant 4). The edge check then becomes “does the edge match exactly one of the two axes within tolerance?” — robust to the axis-swap parity that ChESS X-junctions exhibit at adjacent corners. Skipping the ±π/4 offset removes a single-orientation dependence that the workspace already discarded (Corner::orientation was removed entirely).

Multi-component scenes are first-class. The same precision contract applies to Detector::detect_all, which peels off disconnected components of the same physical board (the typical ChArUco case where markers interrupt grid contiguity). Each component is rebased to its own (0, 0) origin; alignment to a global frame is the caller’s job.

We explicitly do NOT support scenes containing multiple separate physical boards. One target per frame is the contract.


5. Failure modes

When detection fails or returns fewer corners than expected, run the serializable trace (pipeline::trace_topological, see §7) and consult this table.

SymptomLikely stageKnob to tryNotes
No detection; trace shows few usable cornersStage 1–2 (prefilter / clustering)min_corner_strength ↓, max_fit_rms_ratio ↑, min_peak_weight_fraction, peak_min_separation_degEither the corners failed the prefilter or the two grid axes never separated. Most common on very-bad-light frames.
No detection; trace shows usable corners but NoComponentsStage 3 (topological grid)Try detect_chessboard_best with DetectorParams::sweep_default()No quad mesh assembled. Builder tolerances are internal; the sweep widens the upstream clustering / attachment tolerances.
Detection has very few cornersStage 4 (recover)attach_search_rel, attach_axis_tol_deg, step_tol, edge_axis_tol_degThe 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_relInvariants found outliers; inspect the per-reason dropped_* counters.
Wrong (i, j) labels emittedneverIf you ever see this, file a bug. The precision contract has been violated.

The rare unrecovered frame on our internal regression set is typically a very-bad-light capture whose Stage-2 clustering never converges.


6. Parameters

DetectorParams is #[non_exhaustive] and splits into a small stable coregraph_build_algorithm (single-variant Topological; retained as a reserved config seam), min_labeled_corners, max_components, min_corner_strength — plus an opt-in, unstable AdvancedTuning sub-struct (DetectorParams::advanced) holding the per-stage tuning knobs. Build with Default::default() and overwrite the stable fields, attach advanced overrides with DetectorParams::with_advanced(...), or call DetectorParams::sweep_default() for a 3-config preset (default, tighter, looser) suitable for detect_chessboard_best-style sweeps.

advanced is Option-wrapped and serialized as a nested "advanced" object — it is not flattened, and is omitted entirely when unset (in which case detection runs on the defaults). The four stable knobs stay top-level JSON keys. AdvancedTuning’s fields are not covered by semver and may change between minor versions. The Field column below shows the access path: top-level for the four stable knobs, advanced.<knob> for the rest.

FieldDefaultStagePurpose
graph_build_algorithmTopologicalGrid builder algorithm. Topological is the only value; the field is a reserved config seam.
max_components3Cap for detect_all.
min_labeled_corners85Minimum labelled corners to emit a ChessboardDetection.
min_corner_strength0.01Minimum ChESS strength. 0 disables. (Stable.)
advanced.max_fit_rms_ratio0.51Drop if fit_rms > k × contrast. ∞ disables.
advanced.num_bins902Axis-direction histogram bins on [0, π).
advanced.cluster_tol_deg12.02Per-axis tolerance from a cluster center.
advanced.peak_min_separation_deg60.02Minimum separation between the two peaks.
advanced.min_peak_weight_fraction0.022Minimum fraction of total vote weight per peak.
advanced.attach_search_rel0.354Candidate radius around predicted position (booster attachment).
advanced.attach_axis_tol_deg15.04Axis match at booster attachment.
advanced.attach_ambiguity_factor1.54Reject if 2nd-nearest within factor × nearest.
advanced.step_tol0.254Edge-length window when admitting attachments.
advanced.edge_axis_tol_deg15.04Edge axis tolerance at admission.
advanced.geometry_check_local_h_tol_rel0.205Local-H prediction tolerance in the final geometry check.
advanced.line_min_members35Minimum members to fit a row / column.
advanced.enable_weak_cluster_rescuetrue4Toggle for the weak-cluster rescue booster.
advanced.weak_cluster_tol_deg18.04Loosened cluster tolerance for rescue candidates.

The advanced. rows above are part of AdvancedTuning, which is opt-in and not covered by semver. (AdvancedTuning carries more per-stage knobs than shown — see crates/calib-targets-chessboard/src/params/.)

All spatial tolerances are multiplicative with respect to the cell size — the pipeline is scale-invariant once the per-component cell size is estimated.


7. Debugging via the topological trace

The diagnostic entry point is pipeline::trace_topological(corners, params) -> Result<TopologicalTrace, TopologicalTraceError>. It is layered over the production detect_grid_all facade (no separate timed implementation), so the trace stays consistent with what detect() actually does. TopologicalTrace (re-exported from projective_grid::topological::trace) carries:

  • params: TopologicalParams — the parameters the topological stage ran with.
  • corners: Vec<TopologicalCornerTrace> — every input corner with its index, source_index, position, per-axis axis_angles_rad / axis_sigmas_rad, and a usable flag (did it survive the sigma/axis prefilter).
  • components: Vec<TopologicalComponentTrace> — the labelled connected components, each a list of (u, v) -> source_index labels sorted by (v, u, source_index).
  • diagnostics: TopologicalTraceDiagnostics — summary counters (corners_in, corners_used, components, labels).

TopologicalTraceError is NotEnoughCorners { usable } (fewer than three usable corners for Delaunay) or NoComponents (production detection returned no labelled component) — these are the two ways the grid stage can come up empty.

For the drop accounting in the final geometry check, the pipeline’s GeometryCheckTrace records dropped plus per-reason counters (dropped_line_collinearity, dropped_local_h_residual, dropped_edge_invariant, dropped_disconnected), components_seen, and a detection_refused flag — the place to look when corners that should survive are being dropped.

The stable cell_size (the grid pitch in px) is carried on ChessboardDetection directly, populated on the normal detect() path.


8. Quickstart

use calib_targets_chessboard::{ChessCorner, Detector, DetectorParams};

fn detect(corners: &[ChessCorner]) {
    let params = DetectorParams::default();
    // `Detector::new` validates params and is fallible: it returns
    // `Err(ChessboardParamsError)` for an invalid combination. No combination
    // the public surface can express is rejected today; the fallible signature
    // is a reserved seam for future validations.
    let det = Detector::new(params).expect("valid params");
    if let Some(d) = det.detect(corners) {
        println!("labelled {} corners", d.corners.len());
        // `cell_size` (the seed-derived grid pitch in px) is populated on the
        // normal `detect()` path; `Option<f32>`, so `None` on edge cases.
        if let Some(pitch) = d.cell_size {
            println!("grid pitch ≈ {pitch:.1} px");
        }
        for c in &d.corners {
            // `grid` is non-optional; `input_index` points back into `corners`.
            println!(
                "(u, v) = ({}, {}) at ({:.1}, {:.1})  [input #{}]",
                c.grid.u, c.grid.v, c.position.x, c.position.y, c.input_index
            );
        }
    }
}

fn detect_multi(corners: &[ChessCorner]) {
    let det = Detector::new(DetectorParams::default()).expect("valid params");
    for (k, comp) in det.detect_all(corners).iter().enumerate() {
        println!("component {k}: {} corners", comp.corners.len());
    }
}

For a minimal, dependency-free onboarding program — a synthetic corner cloud detected and printed end to end — see crates/calib-targets-chessboard/examples/detect_chessboard.rs:

cargo run -p calib-targets-chessboard --example detect_chessboard

The per-image regression overlays for the testdata/ set are emitted by the driver script scripts/chessboard_regression_overlays.sh and are wired into a #[test] harness at crates/calib-targets-chessboard/tests/testdata_regression.rs.


9. Open questions

  • Degenerate axes (one axis with sigma = π) — current: the corner keeps its position but cannot vote on edges. Could a single-axis attachment pathway recover some recall on low-quality inputs?
  • Three-corner cells. The topological merge needs a complete cell (two triangles sharing a diagonal); one missing corner per cell starves the surrounding walk and the gap fill only recovers single interior holes. A richer local-geometry recovery could rebuild more partial cells.
  • Distortion-curved lines — current: projective-line fit when there are enough members, straight-fit fallback. A true polynomial fit could absorb more distortion at the cost of false-negative risk.
  • Delaunay under severe distortion — current: a Delaunay triangle can span more than one physical cell under combined perspective + radial distortion, leaving cells the diagonal-inference rule cannot resolve. A distortion-aware candidate-neighbour graph could help.

Contributions welcome.

calib-targets-aruco

calib-targets-aruco provides embedded ArUco/AprilTag-style dictionaries and decoding on rectified grids. It does not detect quads or perform image rectification by itself.

Mesh-rectified grid Rectified grid used for ArUco/AprilTag decoding.

For the algorithm — the grid-aware bit sampling model, the explicit bit order / polarity / borderBits conventions, 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::rotation is in 0..=3 such that observed == rotate(dict_code, rotation).
  • border_bits matches 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 from inset_frac.
  • A per-marker threshold (Otsu) is computed from sampled intensities.

Tuning knobs

  • inset_frac controls 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_score is the minimum “frame looks like a marker border” score required to accept a cell. Higher values reject ambiguous cells.
  • dedup_by_id collapses repeated decodes of the same dictionary ID across cells.
  • marker_size_rel is 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 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.

  1. Detect a chessboard grid from ChESS corners.
  2. Build per-cell quads from the detected grid.
  3. Decode markers per cell (no full-image warp).
  4. Align marker detections to a board specification and assign corner IDs.

Board specification

  • CharucoBoardSpec describes the board geometry:
    • rows, cols are square counts (not inner corners).
    • cell_size is the physical square size.
    • marker_size_rel is the marker size relative to a square.
    • dictionary selects the marker dictionary.
    • marker_layout defines the placement scheme.
  • CharucoBoard validates and precomputes marker placement.

Detector

  • CharucoParams::for_board provides a reasonable default configuration.
  • CharucoDetector::detect returns a CharucoDetectionResult with:
    • detection: labeled corners with ChArUco IDs, filtered to marker-supported corners.
    • markers: decoded marker detections in rectified grid coordinates (with optional corners_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_frac trades off robustness vs. sensitivity. The defaults in for_board use a slightly smaller inset (0.06) to improve real-image decoding.
  • min_marker_inliers controls how many aligned markers are required to accept a detection.

Status

The current implementation focuses on the OpenCV-style layout and is intentionally conservative about alignment.

For alignment details, see ChArUco Alignment and Refinement.

ChArUco Alignment

calib-targets-charuco aligns decoded marker IDs to a board layout and assigns ChArUco corner IDs. Alignment is discrete and fast: it tries a small set of grid transforms and selects the translation with the strongest inlier support.

Alignment pass

  • Each decoded marker votes for a board translation under each candidate transform.
  • The best translation wins (ties broken by inlier count).
  • Inliers are the markers whose (sx, sy) map exactly to the expected board cell for their ID.

Inlier filtering

After alignment is chosen, the detector keeps only inlier markers and assigns ChArUco corner IDs based on the aligned grid coordinates. The final alignment in the result is a GridAlignment that maps detected grid coordinates into board coordinates.

calib-targets-puzzleboard

detection overlay on a 10 x 10 PuzzleBoard

calib-targets-puzzleboard detects PuzzleBoard targets: checkerboards whose interior edge midpoints carry binary dots. The dots identify the board position inside a 501 x 501 master pattern, so a visible fragment can still produce absolute corner IDs and object-space coordinates.

PuzzleBoard is based on Stelldinger 2024, arXiv:2409.20127.

For the end-to-end stage map, failure modes, and tuning, see the PuzzleBoard pipeline; for the decoder itself see PuzzleBoard edge-code decode. This page is the crate API reference.

Target Model

PuzzleBoardSpec describes the printable board:

  • rows, cols: square counts, not inner-corner counts.
  • cell_size: physical square size.
  • origin_row, origin_col: top-left square in the 501 x 501 master pattern.

Detected inner corners are returned as LabeledCorner values with:

  • grid: absolute master corner coordinates (i, j).
  • id: j * 501 + i.
  • target_position: (i * cell_size, j * cell_size).

Bit Layout

The board uses two embedded cyclic maps:

  • map A, shape (3, 167), for horizontal interior edges.
  • map B, shape (167, 3), for vertical interior edges.

Dots encode bits directly: white dot = 0, black dot = 1.

corner (i,j) ---- A(j,i) ---- corner (i+1,j)
     |                            |
   B(j,i)                      B(j,i+1)
     |                            |
corner (i,j+1) -- A(j+1,i) -- corner (i+1,j+1)

The committed blobs are src/data/map_a.bin and src/data/map_b.bin. generate-puzzleboard-code-maps and verify-puzzleboard-code-maps are kept as repo tools so the runtime detector does no map construction.

Detection Pipeline

The flow is grid-first:

  1. Run ChESS corner detection.
  2. Assemble one or more chessboard grid components.
  3. Sample every visible interior edge midpoint and estimate a bit confidence.
  4. Drop bits below decode.min_bit_confidence.
  5. Decode against the master maps over all D4 rotations/reflections.
  6. Assign absolute IDs and target-space positions to inlier corners.

The default decode.min_window is 4, meaning the detector requires enough edge samples for a 4 x 4 square fragment after confidence filtering.

Rust Facade Example

use calib_targets::{detect, puzzleboard::{PuzzleBoardParams, PuzzleBoardSpec}};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let img = image::open("testdata/puzzleboard_small.png")?.to_luma8();
    let spec = PuzzleBoardSpec::new(10, 10, 12.0)?;
    let params = PuzzleBoardParams::for_board(&spec);
    let result = detect::detect_puzzleboard(&img, &params)?;
    println!("{} corners", result.corners.len());
    Ok(())
}

For threshold-sensitive images, use:

#![allow(unused)]
fn main() {
use calib_targets::{detect, puzzleboard::{PuzzleBoardParams, PuzzleBoardSpec}};
let img = image::GrayImage::new(1, 1);
fn run(img: &image::GrayImage) -> Result<(), Box<dyn std::error::Error>> {
let spec = PuzzleBoardSpec::new(10, 10, 12.0)?;
let configs = PuzzleBoardParams::sweep_for_board(&spec);
let result = detect::detect_puzzleboard_best(img, &configs)?;
let _ = result;
Ok(()) }
}

Search Modes

The default PuzzleBoardSearchMode::Full scans all 501 × 501 × 8 (D4, origin) candidates against the full master code. When the caller already knows which board they printed, PuzzleBoardSearchMode::FixedBoard matches observations directly against that declared board’s own bit pattern under 8 × (rows+1)² candidate shifts:

#![allow(unused)]
fn main() {
use calib_targets::{detect, puzzleboard::{PuzzleBoardParams, PuzzleBoardSearchMode, PuzzleBoardSpec}};
let img = image::GrayImage::new(1, 1);
fn run(img: &image::GrayImage) -> Result<(), Box<dyn std::error::Error>> {
let spec = PuzzleBoardSpec::new(50, 50, 1.0)?;
let mut params = PuzzleBoardParams::for_board(&spec);
params.decode.search_mode = PuzzleBoardSearchMode::FixedBoard;
let _ = detect::detect_puzzleboard(img, &params)?;
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.json
  • testdata/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.

Marker-board detection overlay 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

  1. Chessboard detection: run calib-targets-chessboard to obtain grid-labeled corners (partial boards are allowed).
  2. 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.
  3. Candidate filtering: keep the strongest circle candidates per polarity.
  4. Circle matching: match candidates to the expected layout (cell coordinates + polarity).
  5. Grid alignment estimation: derive a dihedral transform + translation from detected grid coordinates to board coordinates when enough circles agree.

Key types

  • MarkerBoardDetector: main entry point.
  • MarkerBoardSpec: rows/cols plus the three expected circles (cell coordinate + polarity).
  • MarkerBoardParams: layout + chessboard params + circle score + match settings.
  • MarkerBoardDetectionResult:
    • detection: TargetDetection labeled as CheckerboardMarker.
    • alignment: optional GridAlignment from detected grid coords to board coords.
  • MarkerBoardDiagnostics (opt-in, from the *_with_diagnostics entry 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_position is populated).
  • circles: three MarkerCircleSpec entries with cell (top-left corner indices) and polarity.

MarkerBoardParams configures detection:

  • layout: the MarkerBoardSpec to detect.
  • chessboard: DetectorParams for the underlying corner-grid step. The chessboard detector is scale-invariant, so the v1 expected_rows/cols and completeness_threshold knobs 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).
  • alignment maps 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_version
  • target: chessboard, charuco, marker_board, or puzzle_board
  • page: size, orientation, and margin in millimeters
  • render: 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.json
  • testdata/printable/marker_board_a4.json
  • testdata/printable/puzzleboard_small.json
  • testdata/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::printable when you want the published Rust facade crate.
  • Use calib-targets-print when you want the dedicated published printable-target crate.
  • Use the calib-targets CLI (cargo install calib-targets or pip 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).

Mesh-rectified grid Facade examples cover detection and rectification workflows.

Single-config detection

Each detect_* function takes a single params struct. The chessboard detector uses the workspace’s default_chess_config() for ChESS corner detection automatically; ChArUco / PuzzleBoard / marker board params embed a DetectorParams under params.chessboard.

#![allow(unused)]
fn main() {
use calib_targets::detect;
use calib_targets::chessboard::DetectorParams;

let img = image::open("board.png").unwrap().to_luma8();
let params = DetectorParams::default();
let result = detect::detect_chessboard(&img, &params);
}

Multi-config sweep

For challenging images (uneven lighting, Scheimpflug optics), try multiple parameter configs and keep the best result:

#![allow(unused)]
fn main() {
use calib_targets::detect;
use calib_targets::charuco::{CharucoBoardSpec, CharucoParams};
use calib_targets::aruco::builtins;

let img = image::open("charuco.png").unwrap().to_luma8();
let board = CharucoBoardSpec::new(22, 22, 1.0, 0.75, builtins::DICT_4X4_1000)
    .with_marker_layout(calib_targets::charuco::MarkerLayout::OpenCvCharuco);
let configs = CharucoParams::sweep_for_board(&board);
let result = detect::detect_charuco_best(&img, &configs);
}

sweep_for_board() returns three configs with different ChESS thresholds (default, high, low). detect_charuco_best tries each and returns the result with the most markers (then most corners).

PuzzleBoard follows the same facade shape. Its sweep also includes a hard-weighted fallback for high-distortion fragments:

#![allow(unused)]
fn main() {
use calib_targets::detect;
use calib_targets::puzzleboard::{PuzzleBoardParams, PuzzleBoardSpec};

let img = image::open("puzzleboard.png").unwrap().to_luma8();
let spec = PuzzleBoardSpec::new(10, 10, 12.0).unwrap();
let configs = PuzzleBoardParams::sweep_for_board(&spec);
let result = detect::detect_puzzleboard_best(&img, &configs);
}

Features

  • image (default): enables calib_targets::detect.
  • tracing: enables tracing output across the subcrates.
  • diagnostics (off): forwards to the diagnostics feature of the chessboard, ChArUco, and puzzleboard subcrates, gating their serializable trace surfaces (the chessboard trace_topological / GeometryCheckTrace, and the ChArUco / puzzleboard per-component decode diagnostics). The detectors build no per-stage trace on the hot detect_* paths unless this is enabled (the dataset feature on calib-targets-chessboard implies it). ChArUco and puzzleboard diagnostics are gated behind the same feature as chessboard rather than being always-on.

See the Migration Guide for the full breaking-change list when upgrading from an earlier release.

Examples

Examples live under crates/*/examples/ and are built per crate. The facade examples under calib-targets take an image path directly; the lower-level crate examples synthesize their own inputs or accept a JSON config file (defaults point to testdata/ or tmpdata/).

To run an example from the workspace root:

# Standalone projective-grid — synthesizes its own oriented features:
cargo run -p projective-grid --example hello_grid

# Image-in / detection-out via the facade crate:
cargo run -p calib-targets --example detect_chessboard -- testdata/mid.png

The standalone projective-grid crate ships three onboarding examples that need no image files — hello_grid (the minimal detect-a-grid quickstart), detect_square_oriented2 (a larger detection run), and check_square_consistency (scoring caller-supplied labels). The image-free chessboard detector has its own minimal onboarding program, cargo run -p calib-targets-chessboard --example detect_chessboard.

Python examples live under crates/calib-targets-py/examples/ and use the calib_targets module. After maturin develop, run them with an image path, for example:

python crates/calib-targets-py/examples/detect_charuco.py testdata/small2.png
python crates/calib-targets-py/examples/detect_puzzleboard.py testdata/puzzleboard_small.png

See the sub-chapters for what each example produces and how to interpret the outputs.

Regular Grid Detection Example

Reference: crates/projective-grid/examples/hello_grid.rs — the minimal, image-free story for the standalone projective-grid crate: a handful of oriented feature points go in, a labelled (i, j) grid comes out, with no image and no other workspace crate.


Quick run

cargo run -p projective-grid --example hello_grid

The example synthesizes its own input (no image files needed): a small square lattice with a mild perspective shear, so the cloud looks like a board photographed at a slight angle.


Walkthrough

1. Build oriented features

projective-grid knows nothing about images — for the square detector it works on OrientedFeature<F, 2> values: a position plus two roughly-orthogonal local axis directions. The example builds a 3×3 grid by hand. In a real application these positions and axes would come from a corner detector, a blob detector with a local-orientation estimate, or a laser-dot extractor.

Each feature pairs a PointFeature (an image-frame position plus a stable, caller-owned source_index) with two LocalAxis directions (an angle in radians plus an optional angular uncertainty sigma):

#![allow(unused)]
fn main() {
use nalgebra::Point2;
use projective_grid::{LocalAxis, OrientedFeature, PointFeature};

let mut features: Vec<OrientedFeature<f32, 2>> = Vec::new();
for j in 0..3 {
    for i in 0..3 {
        // Image-frame position (origin top-left, x right, y down).
        // The `+ j * 6.0` term shears each successive row, so this is a
        // genuine projective grid, not a perfectly axis-aligned one.
        let x = 60.0 + i as f32 * 40.0 + j as f32 * 6.0;
        let y = 50.0 + j as f32 * 40.0;

        // `source_index` is a stable handle; the solution reports it back
        // so a recovered `(i, j)` label maps to the exact input feature.
        let point = PointFeature::new(features.len(), Point2::new(x, y));

        // Two undirected, roughly-orthogonal axes: horizontal (0 rad)
        // and vertical (pi/2 rad), each with a small angular sigma.
        let axes = [
            LocalAxis::new(0.0, Some(0.02)),
            LocalAxis::new(std::f32::consts::FRAC_PI_2, Some(0.02)),
        ];
        features.push(OrientedFeature::new(point, axes));
    }
}
}

2. One call: detect_grid

Wrap the features as Evidence::Oriented2, bundle them into a DetectionRequest for a Square lattice, and call detect_grid. Grid dimensions are unknown (None); the detector infers the extent.

#![allow(unused)]
fn main() {
use projective_grid::{
    detect_grid, DetectionParams, DetectionRequest, Evidence, LatticeKind,
};

let request = DetectionRequest::new(
    LatticeKind::Square,
    Evidence::Oriented2(&features),
    None, // grid dimensions unknown; the detector infers the extent
    DetectionParams::default(),
);
let solution = detect_grid(request)?;
assert_eq!(solution.grid.entries.len(), 9);
}

DetectionParams::default() carries a max_residual_px fit gate and runs the topological grid builder — the sole builder, so there is no algorithm to select. It runs a Delaunay triangulation over the corner cloud, classifies edges by axis match, merges triangle pairs into cells, and floods integer coordinates across the mesh, then fits a projective transform. See the Topological grid finder algorithm page for the full method.

3. Handle the Result

Detection returns Result<GridSolution, GridError>. GridError is #[non_exhaustive], so callers always need a wildcard arm. The variants worth matching:

  • UnsupportedCombination { task, lattice, evidence } — the requested (lattice, evidence) pair has no algorithm yet. Today only (Square, Oriented2) is solved; everything else (a Hex lattice, or Positions / Oriented1 / Oriented3 evidence) returns this rather than a guessed answer.
  • InsufficientEvidence — too few features to assemble a 2×2 seed.
  • DegenerateGeometry — coincident or collinear points; no usable lattice spread.
  • InconsistentInput(String) — input slices disagree or carry duplicate source_index handles.

4. Read the result

A successful detection is a GridSolution:

  • grid: LabelledGrid — the recovered component. grid.entries is one GridEntry per placed feature; grid.bbox is the inclusive coordinate bounding box; grid.dimensions echoes any caller-supplied GridDimensions.
  • fit: Option<LatticeFit> — the fitted model-plane-to-image projective transform (model_to_image) plus a residual summary (residuals.count, residuals.mean_px, residuals.max_px).
  • rejected: Vec<RejectedFeature> — features this component could not place, each with a RejectionReason (Unlabelled, ValidationDropped, or ResidualTooHigh).

Each GridEntry carries:

  • coord: Coord — the (i, j) label as coord.u / coord.v, rebased so the labelled bounding box starts at (0, 0).
  • source_index: usize — back into the input slice.
  • image_position: Point2<F> — the feature’s image-frame pixel-center position.
  • residual_px: Option<F> — reprojection residual in pixels, present when a fit was computed.
#![allow(unused)]
fn main() {
for entry in &solution.grid.entries {
    // coord.u = i, coord.v = j; source_index maps back to the input.
    println!(
        "(i={}, j={}) <- feature {} at ({:.1}, {:.1})",
        entry.coord.u,
        entry.coord.v,
        entry.source_index,
        entry.image_position.x,
        entry.image_position.y,
    );
}
}

Running it labels all nine features (0,0) through (2,2) with a sub-pixel fit residual.


Going further

  • Multiple componentsdetect_grid_all returns a DetectionReport whose solutions vector holds one GridSolution per recovered component, ordered by labelled count descending. Use it when the lattice fragments into islands (for example by occlusion) and the secondary components matter. The topological algorithm may yield several components.
  • Checking caller-supplied labels — when (i, j) labels already exist (for instance from a marker decode), check_consistency scores them against a single projective fit instead of recovering them from scratch. The runnable version is crates/projective-grid/examples/check_square_consistency.rs.
  • A larger detection runcrates/projective-grid/examples/detect_square_oriented2.rs exercises the same Evidence::Oriented2 path 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.

Chessboard detection overlay 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:

  1. Decodes the image with image::open(...).to_luma8().
  2. Calls calib_targets::detect::detect_chessboard(&img, &DetectorParams::default()).
  3. Prints the detected Detection — labelled corner count, cell size, the two grid-direction angles, and every (i, j) → pixel_position pair.

If detection fails (None), rerun with the _best helper, which tries three pre-tuned configs (default + tighter + looser) and returns whichever produced the most labelled corners:

cargo run --release -p calib-targets --example detect_chessboard_best -- testdata/mid.png

Instrumentation

The chessboard crate’s diagnostic entry point is calib_targets_chessboard::trace_topological(corners, params), which returns a serializable TopologicalTrace layered over the production detect_grid_all path (so the trace matches what detect() actually does). It records every input corner with its usable flag, the labelled connected components ((u, v) -> source_index), and summary counters; the final-check drop accounting lives in the pipeline’s GeometryCheckTrace. See The Chessboard Detector §7 for the field-by-field walkthrough.

The crates/calib-targets-py/examples/overlay_chessboard.py script draws labelled corners in gold with their (i, j) text, grid edges, cluster-direction tangent lines, and the faint grey input-corner cloud as context.


Direct crate-level usage

If you need control over the ChESS corner front-end (e.g., custom ChessConfig), bypass the facade:

#![allow(unused)]
fn main() {
use calib_targets::detect::{default_chess_config, detect_corners};
use calib_targets_chessboard::{Detector, DetectorParams};
use image::ImageReader;

let img = ImageReader::open("board.png").unwrap().decode().unwrap().to_luma8();
let chess_cfg = default_chess_config();
let corners = detect_corners(&img, &chess_cfg);

let params = DetectorParams::default();
let detector = Detector::new(params);

if let Some(detection) = detector.detect(&corners) {
    println!("{} labelled corners", detection.corners.len());
}
}

Detector::detect_all(&corners) returns every same-board component found in the scene (see the chessboard chapter for the multi-component contract).

Global Rectification Example

File: crates/calib-targets-chessboard/examples/rectify_global.rs

This example detects a chessboard and computes a single global homography to produce a rectified board view. The output includes:

Global rectification output 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:

Mesh rectification output 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.