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 on a small board.

What it is:

  • A small, composable set of crates for chessboard, ChArUco, PuzzleBoard, PuzzlePole (cylindrical), 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::ChessboardParams;
use calib_targets::image::ImageReader; // re-exported; version always matches

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

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

Python bindings

Python bindings are built with maturin:

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

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

MSRV: Rust 1.91 (stable).

Interactive Playground

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

What it does

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.

Tutorials

Start-to-finish walkthroughs. Each one assumes no prior knowledge of the library and ends with something that runs.

  • Getting Started — the complete workflow: choose a target, generate it, print it, detect it in Python and in Rust.
  • Build and detect a PuzzlePole — the cylindrical target end to end, from choosing a diameter to 2D↔3D correspondences. Needs no printer: it renders the pole for you.

If you already know what you want and need the steps for one specific task, the How-to section is the shorter path. If you want to know how something works rather than how to use it, read Algorithms.

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

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. The rest of this tutorial uses it.

If you only need basic intrinsic calibration and the board will always be fully visible, the plain chessboard is simpler still — there is nothing to configure.

For the full comparison across all five families, and how to pick the size, see How to choose a target.


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.15"

The image crate ships with calib-targets (default image feature) and is re-exported as calib_targets::image, so you do not need a separate image dependency — importing through the re-export keeps your GrayImage type version-matched to the helpers. (A direct image = "0.25" dependency still works.)

use calib_targets::charuco::{CharucoBoardSpec, CharucoParams, MarkerLayout};
use calib_targets::detect;
use calib_targets::image::ImageReader;

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

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

    let params = CharucoParams::for_board(board);

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

Build and detect a PuzzlePole

A PuzzlePole is the PuzzleBoard pattern wrapped round a cylinder, from Zach & Stelldinger, PuzzlePoles: Cylindrical Fiducial Markers Based on the PuzzleBoard Pattern (arXiv:2511.19448). It is the only target here that stays identifiable from a full 360° — walk round it and it keeps telling you where you are — and the only one whose detected corners carry a 3-D object point rather than a point on a plane.

This tutorial goes from nothing to a set of 2D↔3D correspondences ready for a PnP solver. You do not need a printer to finish it: step 4 renders a pole synthetically, so you can see the whole loop before committing anything to paper or tube.

1. Choose a pole

Two numbers describe one:

  • circumference, in pieces around the cylinder — not free, see below;
  • axial extent, in pieces along it — free, at 4 or more.

The circumference is constrained because the wrap has to be seamless: the strip’s last row of puzzle pieces must be the same row as its first, or the join would create a local code that appears nowhere in the pattern and the decoder would have to guess. Only seven circumferences do that on the shipped code maps.

Pieces aroundDiameter at 20 mm piecesStrips available
1276 mm6
18115 mm3
24153 mm3
30191 mm9
36229 mm5
42267 mm6
48306 mm3

The diameter is a consequence of the circumference and the piece size, never an input: diameter = pieces × piece_size / π. If the tube is what you already have, invert it with PuzzlePolePeriod::piece_size_for_diameter — every period can wrap every diameter, and what changes is how many pixels a code dot gets. Print and wrap a PuzzlePole covers that choice properly.

This table is not transcribed from the paper. It is re-derived from the shipped map bytes by pole::periods, and a test re-derives it on every run — which is how we know the paper’s period-36 entry does not close on these maps and 327 does.

2. How many different poles are there

More than you will use. Three independent choices multiply:

  • Seven circumferences — seven physical diameters, at a given piece size.
  • 35 seamless strips in total, the “Strips available” column above: a period can start at several different master rows, and each start row is a different pattern, not a rotation of the same one.
  • Disjoint axial windows. A pole is cut from a column window of the 501-column master, and windows that do not overlap share no corner at all. A pole n pieces tall occupies n + 1 columns, so one strip yields ⌊501 / (n + 1)⌋ poles no two of which are cut from the same pattern. Their emitted ids do still coincide — a corner id is ring × columns + column within its own pole, with no term for the window — so record which pole a frame shows if you mix them.

That last count reproduces the paper’s: 6 pieces tall gives 71 poles, 20 gives 23. Enumerate them rather than computing offsets by hand:

use calib_targets_puzzleboard::PuzzlePoleSpec;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let family = PuzzlePoleSpec::distinct_poles(24, 12, 20.0)?;
assert_eq!(family.len(), 38);   // 501 / 13 columns
Ok(())
}

One caveat worth stating plainly: each of those is a distinct target, and the detector is configured for one pole at a time. Detecting several different poles in a single frame means running the detector once per spec, and how well a fragment of one pole is rejected by a detector configured for another has not been measured here.

3. Generate the strip

calib-targets gen puzzlepole \
  --out-stem pole24 \
  --circumference-squares 24 \
  --axial-squares 12 \
  --square-size-mm 20 \
  --page-size custom --page-width-mm 280 --page-height-mm 560

That writes pole24.{json,svg,png,dxf}. The strip is 26 pieces tall, not 24: the two extra are the overlap, trimmed through the middle of the end pieces when you wrap, because a code dot sits on the joint. The JSON carries the resolved corner points and their ids, so it is also the ground truth for whatever you build next.

Printing, trimming and mounting are their own page — see Print and wrap a PuzzlePole. The rest of this tutorial needs no physical pole.

4. See it before you print

The workspace can render a pole analytically: every pixel casts a ray at a cylinder and the pattern is evaluated at the surface point it lands on. No mesh, no texture, no resampling — so the picture is a statement about geometry, and nothing in it is an artefact of the renderer.

cargo run -p calib-targets-puzzleboard --example render_puzzlepole -- out
# out/puzzlepole_view.png      what a camera sees
# out/puzzlepole_detected.png  the same frame, decoded

That is a 24-piece pole at 20 mm, seen from 18° above the horizontal. Note what the curvature does: the rows around the circumference bow, the pieces compress towards each edge, and the pattern runs off the silhouette rather than ending at a border. None of that is a problem the planar detector had to solve.

Now the same frame with the decode drawn on it:

Every dot is a corner the detector found and identified. The colour runs round the circumference, so following the ramp shows the wrap closing on itself; the cyan line is the seam, the join where the strip’s two ends meet. That it decodes straight through the seam is the whole point — a pole that only worked away from its join would be a worse chessboard.

Pass an azimuth and elevation to look from anywhere:

cargo run -p calib-targets-puzzleboard --example render_puzzlepole -- out 90 25

5. Detect it

The facade takes an image and a spec and hands back identified corners:

use calib_targets::detect;
use calib_targets::puzzleboard::{PuzzlePoleParams, PuzzlePoleSpec};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let img = image::open("out/puzzlepole_view.png")?.to_luma8();

let spec = PuzzlePoleSpec::new(24, 12, 20.0)?;
let found = detect::detect_puzzlepole_best(&img, &PuzzlePoleParams::sweep_for_pole(&spec))?;

println!("{} corners identified", found.corners.len());
Ok(())
}

Each corner carries three positions, and the distinction matters:

FieldMeaning
positionwhere it landed in the image, in pixels
surface_positionwhere it sits on the unrolled strip, in mm
object_positionwhere it sits in the pole’s 3-D frame, in mm

object_position is the one PnP wants. surface_position is what LabeledCorner::target_position carries, so a pole detection still fits every carrier and every binding that expects a planar target.

The same thing in Python:

import calib_targets as ct

pole = ct.PuzzlePoleSpec.canonical(24, 12, 20.0)
found = ct.detect_puzzlepole_best(image, ct.PuzzlePoleParams.sweep_for_pole(pole))
print(len(found.corners), "corners identified")

Steps 3 and 5 are both runnable as they stand, without writing any of this:

python crates/calib-targets-py/examples/generate_printable_puzzlepole.py \
    --out-stem out/pole24 --circumference-squares 24 --axial-squares 12 \
    --square-size-mm 20

python crates/calib-targets-py/examples/detect_puzzlepole.py out/puzzlepole_view.png \
    --doc out/pole24.json --overlay out/seen.png --csv out/pairs.csv

--doc reads the pole back out of the document that produced the print, which is worth preferring to retyping four numbers: a spec that disagrees with the printed strip by one column still decodes, and hands you corner ids that are confidently wrong.

6. Hand it to a PnP solver

This library does not solve PnP — it gives you the correspondences and stops there, because the pose solver you already use is better than one bolted on here.

use calib_targets::detect;
use calib_targets::puzzleboard::{PuzzlePoleParams, PuzzlePoleSpec};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let img = image::open("out/puzzlepole_view.png")?.to_luma8();
let spec = PuzzlePoleSpec::new(24, 12, 20.0)?;
let found = detect::detect_puzzlepole_best(&img, &PuzzlePoleParams::sweep_for_pole(&spec))?;
for (image_point, object_point) in found.correspondences() {
    // image_point: Point2<f32> in pixels
    // object_point: Point3<f32> in millimetres on the cylinder
    let _ = (image_point, object_point);
}
Ok(())
}

In Python the pairs come off the detection directly:

import cv2
import numpy as np

pairs = found.correspondences()
image_points = np.array([p for p, _ in pairs], dtype=np.float64)
object_points = np.array([q for _, q in pairs], dtype=np.float64)

ok, rvec, tvec = cv2.solvePnP(
    object_points, image_points, camera_matrix, dist_coeffs,
    flags=cv2.SOLVEPNP_SQPNP,
)

SOLVEPNP_SQPNP rather than the iterative default: the latter assumes the object points are coplanar, and on a cylinder they emphatically are not. That gives you a pose from a single view, at any azimuth.

detect_puzzlepole.py --pnp --fx … --cx … --cy … does exactly this and reports the camera’s place in the pole’s own frame — distance from the axis, azimuth, height — which is checkable against a tape measure in a way an rvec is not. On the rendered frame above, whose camera is known, it recovers the standoff to within a millimetre of it — which is the end-to-end check that the object points really are in millimetres on a cylinder, and a test asserts it.

7. What there is to configure

Start with PuzzlePoleParams::for_pole(spec) and change nothing. If a view fails, sweep_for_pole is the multi-config version used above; it crosses the chessboard front-end sweep with a second, more permissive scoring pass, which matters more on a pole than on a flat board because every dot near the limb is compressed.

The one knob that is genuinely pole-specific is the window floor, and it is a pair where a flat board has a single number:

KnobDefaultWhat it demands
decode.min_circumference_span5corner rows visible around the pole
decode.min_axial_span8corner columns visible along it

Both are measurements, not preferences. A 5 × 8 corner window is unambiguous on every supported pole at any placement, and one corner less is not; the axial floor cannot go below 5 for a structural reason — a 4-corner extent yields two columns of map_a, and a 3×2 block of a sub-perfect map is never unique. Lowering either admits placements that provably alias, which costs you a wrong id — and a wrong id is unrecoverable downstream where a miss is merely inconvenient. See research/puzzleboard-rings/report/puzzlepole-floor.md for the sweep.

Everything else — corner strength, the ChESS front end, the grid builder — is the planar PuzzleBoard’s, documented under Tune the detector.

How-to

Task-shaped answers. Each page assumes you know what you are trying to do and want the shortest correct path to it.

For a start-to-finish walkthrough instead, see Tutorials; for the reasoning underneath, Algorithms.

How to choose a target

Six target families ship here, and the differences that matter are not subtle. This page is the decision, not the tour — each row links to the reference page for the family.

The short version

Calibrating one camera, board fully visible, no other constraints? Use a chessboard. It is the simplest thing that works and there is nothing to configure.

Need pose from any direction, or from a target that cannot be kept facing the camera? Use a PuzzlePole — it is the only family here that is not flat.

Anything else? Read the table.

The table

FamilyCorner IDsPartial viewsNeedsReference
Chessboardnone✗ whole boardnothingpipeline
ChArUcoabsolute✓an ArUco dictionarypipeline
PuzzleBoardabsolute✓ down to a small fragmentnothingpipeline
Marker boardabsolute✓a custom circle layoutpipeline
PuzzlePoleabsolute, 3-D✓ from any azimutha tube to wrap it onhow-to
Regular gridnone✗nothingpipeline

Corner IDs is the property that decides most cases. Without them a detection is a set of corners in some order, so the board has to be fully visible for its corners to mean anything. With them every corner carries an absolute identity, so a partial view is still useful and the board’s orientation is never ambiguous.

Choosing between the identified families

They differ in how the identity is encoded, which is what drives the resolution each one needs.

ChArUco puts an ArUco marker inside every white square. Each marker is a self-contained code, so a single readable marker locates the board — but a marker needs enough pixels to resolve its bit grid, so ChArUco wants the most resolution of the three. Pick it when you already have ArUco in the stack, or when you want a format other tools recognise.

PuzzleBoard distributes the code across neighbouring cells: one bit per edge, read as is the dot between these two corners black or white. A bit therefore costs one dot rather than a whole marker grid, which is why it stays decodable at markedly lower resolution and from smaller fragments. Pick it when the board will be small in frame, partly occluded, or oblique. It is the best-covered family in this book — see the decode and the code maps.

PuzzlePole is the PuzzleBoard pattern wrapped round a cylinder, so it is the same code read the same way — but on a surface with no back. Every other family here degrades as it turns away from the camera and stops working altogether near edge-on; a pole presents the same face at every azimuth, and its corners come with 3-D object points instead of points on a plane. Pick it when the target must be viewed from several directions at once, when it cannot be aimed at the camera, or when you want a single-view pose without a rig. The cost is that it must be wrapped accurately on a tube of the right diameter, and that only seven circumferences wrap seamlessly.

Marker board is a plain checkerboard with three circular markers placed to break its symmetry. It carries far less information than the others — just enough to fix the frame — but it prints as an ordinary chessboard with three dots, which suits setups where the full pattern is impractical.

Then choose the size

Two independent numbers, and both are constrained by the camera rather than by taste:

  • Square size. Big enough that a square spans a comfortable number of pixels at your working distance. If corners come out noisy, this is usually the reason before any parameter is.
  • Board extent. Big enough to fill a useful fraction of the frame across the poses you intend to shoot. A board that only ever covers the middle third constrains the lens model poorly no matter how clean its corners are.

Printing them covers the mechanics — page size, DPI, and why scale-to-fit is the single most common way to invalidate a calibration.

Tuning the Detector

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

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

Start here: use the built-in defaults

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

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

let params = ChessboardParams::default();
}

For ChArUco:

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

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

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

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

Challenging images: multi-config sweep

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

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

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

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

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

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

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


Symptom → parameter table

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

SymptomParameter to adjust
detect_chessboard returns Err(NoDetection)min_corner_strength ↓, cluster_tol_deg ↑, min_peak_weight_fraction ↓, or try detect_chessboard_best
Partial board, many 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::ChessboardParams

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

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

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

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

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

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

Stage 1 — pre-filter

A corner’s axes are admitted when strength ≥ min_corner_strength and max(σ₀, σ₁) ≤ advanced.topological.axis_align_tol_rad. Corners that fail either half are kept as positions (so corner indices stay stable) but carry no-information axes and cannot classify Delaunay edges.

FieldDefaultGuidance
min_corner_strength33.0The only magnitude gate on the corner set. Lower it and marker-bit / noise saddles enter grid construction; raise it on scenes with many spurious saddles at the cost of recall on soft corners.

There is no second pre-filter knob. The axis half of the gate is derived from axis_align_tol_rad (default 15°, a Stage 3 tolerance) rather than configured separately: an axis whose own 1σ uncertainty is wider than the alignment window cannot answer the question the cell test poses, so it must not vote. Tying the two together is deliberate — loosening the cell test automatically loosens admission by exactly the same amount.

Removed in 0.11.0. max_fit_rms_ratio (fit_rms ≤ ratio × contrast) is gone: chess-corners 1.0 no longer reports contrast or fit_rms. See the Migration Guide.

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 ChessboardParams::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.45–0.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.10–0.12 when the cell boundary visibly bleeds into the bit area (common with thick printed borders or strong blur).

marker_size_rel

Source: Board specification — must match the printed board exactly.

Guidance: Ratio of the ArUco marker side to the chessboard square side. A mismatch here causes systematic decoding failures even when all other parameters are correct. Verify against the printed board or the JSON spec used to generate it.


Quick checklist

  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 Err(NoDetection)): run calib_targets_chessboard::trace_topological (gated behind the chessboard crate’s off-by-default diagnostics feature) — 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 reports no board

The facade detect_chessboard returns Err(DetectError::NoDetection) when no board is found — a miss carries no specific cause, only that some stage declined to emit a detection. To diagnose, run the chessboard crate’s serializable topological trace, calib_targets_chessboard::trace_topological, which is layered over the production path (so it reflects what detect() actually does) and reports per-corner usability plus the labelled components:

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

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

trace_topological (and the TopologicalTrace type) are gated behind the chessboard crate’s off-by-default diagnostics feature. Enable it with calib-targets-chessboard’s diagnostics feature — or the facade’s diagnostics feature — to call it; the hot detect_* path builds no trace.

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(8.0) to drop the noise floor below the workspace default of 15.0. Since chess-corners 1.0 the threshold is a plain absolute response floor (f32); the Threshold enum and its relative mode are gone — ChESS reads only an absolute floor now.

  2. Corners found but few usable? The prefilter is rejecting most corners — either on strength or because their axes are too uncertain to vote (max(σ₀, σ₁) > axis_align_tol_rad). Lower min_corner_strength, 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 ChessboardParams::sweep_default() (widens the clustering and attachment tolerances).

  4. Components found but detect_chessboard still reports no board? 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.65–0.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 returns Err(NoDetection)Use detect_chessboard_best; inspect the trace_topological components and the final-check GeometryCheckTrace.dropped_* counters

Getting more help

How to print and wrap a PuzzlePole

A PuzzlePole is the PuzzleBoard pattern wrapped around a cylinder. Because the pattern closes, the target is recognisable from any direction — which is the thing no planar target can do.

This page covers making one. It comes from Zach & Stelldinger, PuzzlePoles: Cylindrical Fiducial Markers Based on the PuzzleBoard Pattern (arXiv:2511.19448).

1. Pick a diameter — you do not get a free choice

The pattern only closes at a few circumferences, so a pole’s diameter is quantised. Pick the combination that lands nearest the tube you can actually buy, then buy that tube — not the other way round.

Diameter in mm, for a circumference of p pieces at a given piece size:

period p8 mm10 mm15 mm20 mm25 mm30 mm
123138577695115
18465786115143172
246176115153191229
307695143191239286
3692115172229286344
42107134201267334401
48122153229306382458

The rule is diameter = p × piece / π. Nothing else is supported: a circumference that is not in this table does not close, and the library refuses it rather than rounding to a neighbour.

Bias towards a larger p when you can. More pieces around the circumference means more of the code is visible from one side, and a camera sees at most about a third of the circumference before the pattern is too foreshortened at the limb to read.

The second dimension — how tall the pole is — is free. It is axial_pieces × piece from the lowest to the highest corner.

What fits on one sheet

The strip is printed p + 2 pieces tall, so the page bounds the piece size — and that turns out to bound the diameter far more than the period does. The largest pole each period gives on a single A4 portrait sheet at a 10 mm margin:

periodlargest piecestrip heightresulting ⌀
1219 mm266 mm73 mm
1813 mm260 mm75 mm
2410 mm260 mm76 mm
308 mm256 mm76 mm
367 mm266 mm80 mm
426 mm264 mm80 mm
485 mm250 mm76 mm

So one A4 sheet is a roughly 75 mm pole whichever period you choose, and the period is really a trade between piece size — how many pixels a code dot gets, so how far away it still reads — and how much of the circumference is visible at once. A bigger pole needs a bigger sheet, not a different period.

If you already have the tube

The more common situation is the reverse: you have a tube and need a target that fits it. diameter = p × piece / π rearranges, so

#![allow(unused)]
fn main() {
use calib_targets::puzzleboard::PuzzlePolePeriod;

// A 75 mm tube: what piece size does each circumference need?
for squares in [12, 18, 24, 30, 36, 42, 48] {
    let period = PuzzlePolePeriod::canonical(squares).expect("a supported circumference");
    println!("{squares:2} pieces -> {:.1} mm", period.piece_size_for_diameter(75.0));
}
// 12 -> 19.6   18 -> 13.1   24 -> 9.8   30 -> 7.9   36 -> 6.5   42 -> 5.6   48 -> 4.9
}

Every period wraps every diameter; what changes is how large a piece is, and so how many pixels a code dot gets. Take the largest piece the tube and the sheet both allow.

2. Generate the strip

calib-targets gen puzzlepole \
  --out-stem pole \
  --circumference-squares 18 \
  --axial-squares 8 \
  --square-size-mm 13 \
  --page-size a4

That writes pole.json, pole.svg, pole.png and pole.dxf. The SVG is what you print; the JSON is the spec, which you can re-render or hand to another tool.

From Rust:

use calib_targets::generate::puzzlepole_document;
use calib_targets::printable::write_target_bundle;

fn main() -> Result<(), Box<dyn std::error::Error>> {
// 18 pieces around, 8 along, 13 mm pieces -> a 75 mm cylinder on A4.
let doc = puzzlepole_document(18, 8, 13.0)
    .expect("18 is a supported circumference");
write_target_bundle(&doc, "pole")?;
Ok(())
}

If the strip does not fit, the generator says so with both measurements:

board does not fit page: board 120.000x390.000 mm, printable area 190.000x277.000 mm

Use a smaller piece size or a larger sheet — but do not scale it to fit.

3. Print at 100 %

Turn off scale to fit, shrink oversized pages, and fit to printable area. This matters more here than for a flat board: the diameter is derived from the piece size, so a print at 96 % is a pole of the wrong diameter, and every 3D point the library reports for it will be wrong by that factor in a way nothing downstream can detect.

Measure a piece with a ruler after printing. It should be the number you asked for.

4. Trim and wrap

Two details, both of which matter:

Trim through the middle of the first and last pieces, not along a piece boundary. A code dot sits on every joint between pieces, so cutting at a boundary would slice a dot in half and lose a bit of the code. Cutting mid-piece leaves every dot whole, and the two half-pieces recombine into one when the ends meet.

Lay the last piece over the first. After trimming, the strip carries one piece more material than the circumference. That spare piece is a duplicate of the first, so laying it on top reproduces exactly what it covers and the pattern stays continuous across the joint.

That the overlap is invisible is not a coincidence, and it is the whole reason the construction needs the periods in the table above rather than any old spacing: the strip repeats two consecutive rows of the master pattern, and two is exactly what a one-piece overlap costs.

Glue the strip to a rigid tube of the diameter from step 1. A tube that is slightly the wrong size shows up as a visible step at the joint — if the ends do not meet cleanly, check the printed piece size before blaming the tube.

5. What the pole’s coordinates mean

Every corner on a pole has two indices and two positions:

  • j, the axial index — which corner row along the cylinder, counted from the end you designated the origin. It drives +Z.
  • k, the cyclic index — which corner row around the circumference, counted from the seam. It drives the angle, θ = 2πk / p, increasing counter-clockwise seen from +Z.

The seam is k = 0 and θ = 0: the joint you just glued. The frame is right-handed with its origin on the axis at j = 0.

The unrolled surface position — where a corner sits on the flat strip, before you wrapped it — is also reported, and it is the pole’s analogue of a flat board’s target position.

The Grid Model

Code: projective-grid.

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

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

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

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

The crate root contains the ordinary detection facade. Detector builders use the curated composition paths projective_grid::expert::lattice::{predict_grid_position, GridTransform} and projective_grid::expert::geometry::estimate_homography; Coord and LatticeKind remain at the root. GridTransform is the single affine integer-grid mapping (matrix * coordinate + translation) shared by lattice symmetries and target alignments. It never acts on image pixels.


The model

Three small pieces define the public surface.

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

Two tasks.

  • Detection — detect_grid / detect_grid_all: recover labels from raw evidence when you do not know which feature is which cell.
  • Consistency — check_consistency: you already have a proposed (i, j) label per feature (e.g. from a marker decode) and want to know whether those labels are geometrically consistent under a single projective fit. This is a separate entry point with its own request and report types; it does not go through 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 axis❌ UnsupportedCombination
Oriented2&[OrientedFeature<2>]✅ native (topological)❌ UnsupportedCombination
Oriented3&[OrientedFeature<3>]❌ UnsupportedCombination✅ native (topological)

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


Worked example

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

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

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

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

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

    // Wrap as Oriented2 evidence and ask for a square lattice.
    let request = DetectionRequest::new(
        LatticeKind::Square,
        Evidence::Oriented2(&features),
    );

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

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


The square algorithm: Topological

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

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

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

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

Single vs. multi-component results

detect_grid returns the largest recovered component as one GridDetection. When the lattice is split into islands (for example by occlusion) and the secondary components matter, call detect_grid_all: it returns a Vec<GridDetection> ordered by labelled-count descending.


Inputs

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

  • point: PointFeature — position (image-frame pixel center) and a stable, caller-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) is the ordinary entry point. Optional GridDimensions and stable parameters use the named with_dimensions / with_params builders. Stage-specific controls live in the opt-in projective_grid::expert::DetectionTuning namespace.


Outputs

A successful detection is a GridDetection:

FieldMeaning
grid()Accepted entries, lattice family, inclusive bbox, and optional dimensions.
fit()Mandatory model-plane-to-image transform and residual summary.

Each GridEntry carries:

  • coord: Coord — the (i, j) label 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.

Rejected features and exact stage traces are intentionally separate from the ordinary result. Detector builders and evidence campaigns enable the diagnostics feature and use projective_grid::diagnostics::detect_grid*.


Checking caller-supplied labels

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

This is the only entry point that consumes coordinate hypotheses; detection’s Evidence enum does not include an unsupported placeholder variant.


Conventions

  • Coordinates. Image pixels: origin top-left, x right, y down. Grid i (coord.u) increases right, j (coord.v) increases down.
  • Undirected axes. A 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.

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::CharucoCharucoDetection::target_detection()
TargetKind::PuzzleBoardPuzzleBoardDetection::target_detection()
TargetKind::CheckerboardMarkerMarkerBoardDetection::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 board.cell_size > 0 and alignment succeeds

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

score

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

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

Interpretation:

Score 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: CharucoDetection

detect_charuco returns CharucoDetection rather than a bare TargetDetection:

#![allow(unused)]
fn main() {
pub struct CharucoDetection {
    pub corners:   Vec<CharucoCorner>,
    pub markers:   Vec<MarkerDetection>,
    pub alignment: GridAlignment, // alias of the canonical GridTransform
}
}

markers

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

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

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.

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.

Migrating to 0.15.0

Breaking, and confined to the marker-board detector, its printable model, and the bindings that mirror them. If you do not use calib-targets-marker or MarkerBoardTargetSpec, nothing here applies and the upgrade is a version bump.

This is a minor rather than a patch for one reason: fields were removed from public structs. #[non_exhaustive] stops a downstream crate constructing or exhaustively destructuring them, but it never stopped reading a field, and #[serde(deny_unknown_fields)] means a stored JSON config naming a removed field now fails to deserialize rather than being ignored.

Why the marker board changed

A board rendered with inner_square_rel above ~0.3 could come back with the frame 90 or 180 degrees rotated while the detector reported three clean circle matches and no error (#96). Every corner then carried a wrong (i, j) label. Two things had to be fixed — the circle scorer had no test that a candidate was actually round, so the white inset drawn inside every black square scored as a marker disk; and frame resolution ranked hypotheses by contrast rather than by agreement, so two bright false candidates could outvote three real ones. The CHANGELOG has the full account.

The printed disk diameter moved

CircleScoreParams::diameter_frac is removed. It stated the same physical quantity as the printable spec’s circle_diameter_rel, and the two could disagree: a board printed at 0.45 was probed at the default 0.5, putting every radius the scorer uses 11% off. The board now says it once.

// Before
let params = MarkerBoardParams::for_board(board);
params.circle_score.diameter_frac = 0.45;
// After — on the board, which is what it describes.
let board = board.with_circle_diameter_rel(0.45);
let params = MarkerBoardParams::for_board(board);

In JSON (Python, WASM, stored configs), move circle_score.diameter_frac to board.circle_diameter_rel. Leaving it in place is a deserialization error, not a silent ignore. In C, ct_circle_score_params_t loses diameter_frac and ct_marker_board_layout_t gains circle_diameter_rel.

Circle matching is a frame sweep, so its distance knob is gone

CircleMatchParams::max_distance_cells and CircleMatch::distance_cells are removed, and CircleMatch::with_match drops its distance_cells argument.

Both encoded the same defunct idea: matching a circle by the distance between a board cell index and a detected cell index, which presumes the translation the alignment is supposed to establish. Resolution now enumerates every (rotation, translation) and verifies it, so a match is an exact integer coincidence and a distance could only ever have reported zero. Delete the field; there is nothing to replace it with.

min_offset_inliers defaults to 3

Was 1. The three circles exist for exactly one reason — to break the board’s 4-fold rotational symmetry — and fewer than all three cannot do it: any single circle is consistent with all four rotations, and two leave no redundancy to check a wrong pairing against.

This costs nothing on a board where all three markers are visible. It costs recall on one where a marker is occluded or blown out, which used to return a frame that was unverified rather than correct. If you need the old behaviour, set it explicitly and understand that you are trading the “never a wrong label” half of the detection contract for recall:

params.match_params.min_offset_inliers = 2;

A new failure, and a new rejection

  • MarkerBoardDetectError::AlignmentAmbiguous { inliers, runner_up } — two board frames explain the detected circles equally well, so neither is returned. The enum is #[non_exhaustive]; code with a wildcard arm compiles unchanged and now sees a case it previously received as a confidently wrong answer.
  • PrintableTargetError::InvisibleCircle { i, j } — a marker circle whose polarity matches the square underneath. The checkerboard’s colour phase is fixed (square (0, 0) is black), so a white disk is only visible on an even i + j and a black one only on an odd one; anything else rendered nothing at all. A spec that previously “worked” this way was producing a board with fewer markers than it claimed.

Corrected without action on your part

  • MarkerBoardTargetSpec::default_circles returned three invisible circles on any board whose centre cell has odd parity — a 6x8 board among them, where it emitted the exact polarity inverse of every shipped fixture. It now anchors the L on an even-parity cell. MarkerBoardSpec::default() on the detector side carried the same contradiction and now matches. If you passed either through unchanged, your board starts working.
  • The inner-square inset is no longer painted under a marker circle. It is drawn in the same white a disk needs, so it merged with one and — once inner_square_rel reached circle_diameter_rel — swallowed it. Regenerate any printed board that carries both.
  • A fully detected board no longer drops the id and target_position of its last row and column (13 of 48 corners on a 6x8). A circle layout names squares, so the resolved frame labelled corners 1..=cols — outside the board’s own index range, with a labelled bounding-box minimum of (1, 1) rather than (0, 0). Labels are inner-corner indices now, and target_position measures from the board’s outer corner, matching the printable spec’s resolved_points. If you stored marker-board corner ids or target positions from an earlier release, they are shifted by one cell in both axes relative to what 0.15.0 produces.

Diagnostics gained fields

CircleCandidate::squareness (what the new shape gate measured: 0 for a disk, 0.48..0.62 for a square inset), and MarkerBoardDiagnostics gains alignment_runner_up_inliers and alignment_ambiguous. Additive on an opt-in surface with a documented looser stability promise.

C ABI 4.0.0 -> 5.0.0

Three struct-layout-breaking changes: ct_marker_board_layout_t gains circle_diameter_rel, ct_circle_score_params_t loses diameter_frac, and ct_circle_match_params_t loses max_distance_cells. Relink, do not just recompile — a consumer built against 4.0.0 passes structs of the wrong size.


Migrating to 0.14.0

Additive on every published surface. No type is removed or renamed, the C ABI stays at 4.0.0, and detection results are unchanged for every board printed with the default one-cell marker border — which is every board the previous releases could describe.

There is exactly one behaviour change, below.

scan.border_bits is derived from the board

CharucoBoardSpec now carries border_bits, the width in marker cells of the black ring printed around each marker payload (OpenCV’s borderBits). It is a physical property of the printed target, so the board spec is where it lives, and CharucoDetector::new now overwrites params.scan.border_bits with the board’s value on every construction path.

If you were reaching past the board spec to describe a wide-ring board:

// Before — the only way to say "this board has a two-cell ring".
let mut params = CharucoParams::for_board(spec);
params.scan.border_bits = 2;
// After — say it on the board, which is what it describes.
let spec = spec.with_border_bits(2);
let params = CharucoParams::for_board(spec);

The old form now silently resolves back to the board’s value (1 unless you set it), so it must be migrated rather than left in place.

Why unconditionally, rather than repairing an “unset” scan value: the scan default is 1, which is indistinguishable from a caller who meant 1. A conditional repair would therefore leave CharucoParams::for_board as the only construction path whose scan config agreed with its own board — reassigning params.board, or deserialising a config that names the board and omits scan (the shape the Python bindings and any stored JSON config take), would keep sampling the ring of whatever board the params were seeded from.

Two fields describing one physical fact is the actual defect; deriving one from the other removes it.

What this does not mean

The previous behaviour was not a decode failure. Measured on a synthetic render, the board-level matcher aligns a border_bits = 2 — even = 3 — board correctly while sampling it with a one-cell ring, because the inset sampling grid lands inside the payload either way. What was genuinely wrong is everything computed from the ring: the min_border_score gate scored cells that are not the printed border, and diagnostics reported a ring the board does not have. The tolerance is a coincidence of one inset value and should not be relied on.

New, no action required

  • CharucoBoardSpec::border_bits / with_border_bits, mirrored on the printable CharucoTargetSpec and the Python CharucoBoardSpec. Defaults to 1; border_bits = 0 is now rejected with CharucoBoardError::InvalidBorderBits (a marker with no ring cannot be told from the white square under it).
  • render_target_bundle_json(doc) on the WASM surface takes a full PrintableTargetDocument — page size, orientation, margin, and every spec field the fixed-arity render_*_bundle helpers do not name. Those helpers are unchanged; handing the new function the page one of them would have synthesised returns a byte-identical SVG.
  • PrintableMarkerCircleSpec in the WASM TypeScript declarations. The printable model’s circle ({ i, j, polarity }) needs a name distinct from the detector’s MarkerCircleSpec ({ cell, polarity }): two Rust crates may share a type name, one .d.ts module may not.

TypeScript-only corrections

Three declarations in typescript-extras.d.ts described something the Rust side does not. Each is a narrowing of what type-checks, and in each case what stops type-checking never worked at runtime:

  • MarkerLayout no longer offers "bottom_left". The Rust enum has exactly one variant, so that value was always rejected at the boundary.
  • CharucoBoardSpec gains border_bits and marker_layout becomes optional, matching the serde shape.
  • The printable circles field is a 3-tuple rather than an array, matching Rust [MarkerCircleSpec; 3], which already rejected any other length at runtime.

If your code type-checked against the old declarations for a marker board, it did not compile at all — the duplicate MarkerCircleSpec merged into an interface requiring the fields of both shapes. Nothing to migrate; it starts working.

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 axes — axes: [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).
  • A quality scalar — strength, the ChESS response magnitude.

Together these are exactly what the prefilter reads. A corner’s axes are admitted into grid construction when strength ≥ min_corner_strength and neither axis is more uncertain than the axis-alignment tolerance the grid builder will test it against (max(σ₀, σ₁) ≤ topological.axis_align_tol_rad) — see the chessboard pipeline. Before 0.11.0 the second half of that rule was a fit-residual ratio (fit_rms ≤ max_fit_rms_ratio · contrast); contrast and fit_rms no longer exist upstream, and the σ-based rule is the direct replacement.

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

Orientation is axes-only

This is the load-bearing contract for everything downstream:

  • There is no single-orientation field. Corner::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 AxisAssignment — Canonical (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: crates/projective-grid/src/topological/ (reached through detect_grid_all, or through the curated expert::square component seam for pattern-specific builders). In-repo deep-dive: docs/algorithms/topological-grid-detection.md.

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

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

Historical note. An earlier SeedAndGrow builder once coexisted with this one. It and the algorithm selector have been removed; there is no algorithm choice in the current public configuration.

Vocabulary

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

Stages

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

  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. Generic component merge. Disconnected walk patches are reunited in label space using local geometry and cell scale, without relying on a global homography.
  8. Public validation + projective fit, or detector-builder handoff. The ordinary facade canonicalizes labels, runs pattern-agnostic line/local-H validation, and fits the mandatory projective transform. Pattern-specific builders may instead stop after component merge through expert::square::assemble_oriented2_components; the chessboard takes this branch so parity/recovery see the original axis-slot frame and then applies its own mandatory geometry check.
  9. Orchestration. Component detections are sorted by labelled-corner count (ties broken by smallest source index, for determinism). Every unplaced feature is collected into a global rejected/unlabelled set. detect_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, GridDetection).

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 by GridDetection::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 Model — GridDetection::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.
  • Polarity — black = 1.
  • border_bits — the number of whole black border cells, matching the OpenCV definition (typically 1).

Note the polarity is the inverse of OpenCV’s own bytesList, where a set bit means white. When cross-checking against OpenCV, read markers through cv2.aruco.generateImageMarker rather than bytesList: the latter is a CV_8UC4 matrix whose four rotations sit consecutively in flat memory, so the obvious numpy slice bytesList[id][:, rot] silently returns bytes from two different rotations. The symptom is a marker whose top half is correct and whose bottom half comes from the 180° variant — an easy false alarm to raise against a generator that is in fact correct.

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 — detector/edge_sampling.rs (reading the dots) and detector/decode/ (recovering the position). Based on Stelldinger 2024, arXiv:2409.20127.

A PuzzleBoard is a self-identifying chessboard: every interior edge carries a dot at its midpoint, and the pattern of dots is designed so that a fragment of the board identifies where on the board it is. Decode turns the dots a camera happened to see into an absolute position on a fixed 501 × 501 master pattern, so even a partial view yields absolute corner IDs and object-space coordinates — no need to see a corner of the board, no need for the whole target to be in frame.

This chapter is about the decoder specifically. The grid that feeds it comes from the chessboard pipeline; the end-to-end flow is in PuzzleBoard pipeline.

What is printed

Two cyclic binary maps are superposed on the board:

mapshapegovernslookup
A3 × 167vertical edgesmap_a[mr mod 3][mc mod 167]
B167 × 3horizontal edgesmap_b[mr mod 167][mc mod 3]

A horizontal edge at master cell (mr, mc) is the edge between square (mr, mc) and the square below it; a vertical edge is the edge between (mr, mc) and the square to its right. Both maps tile cyclically, which is what makes the 501 × 501 master storable in 126 bytes.

The dot colour is the bit, and the polarity is worth stating precisely because it is easy to get backwards:

bit 0 = black dot, bit 1 = white dot.

The dot sits on the boundary between a light and a dark square, so its visible half-moon always falls on the square of the opposite colour, and the intensity at the exact edge midpoint reads the dot directly. The sampler compares that intensity against bright/dark references taken from the two adjacent squares, which is what makes the read robust to vignetting and local exposure.

Why 3 × 167

Each map is a sub-perfect map: over its cyclic domain, all 501 of its 3 × 3 windows are pairwise distinct. Superposing the two makes every 4 × 4 window of squares — 3 × 4 horizontal bits plus 4 × 3 vertical ones, 24 bits in all — distinct across every one of the 501 × 501 master positions.

501 = 3 · 167 is not incidental. Because gcd(3, 167) = 1, a master row mr is uniquely determined by the pair (mr mod 3, mr mod 167), and likewise for columns. That is the Chinese Remainder Theorem, and the decoder leans on it twice: once to store the pattern compactly, and once to avoid searching it. See Code map construction for how the maps are built and where the shipped ones come from.

What the decoder is given

Edge sampling emits one observation per interior edge it could read:

struct PuzzleBoardObservedEdge {
    row: i32,            // anchor corner, in the fragment's own frame
    col: i32,
    orientation: EdgeOrientation,  // Horizontal | Vertical
    bit: u8,             // 0 = black, 1 = white
    confidence: f32,     // 0 = ambiguous, 1 = crisp
}

An observation is produced only when the corners bounding both adjacent squares were detected. That rule matters more than it looks: it means the fragment’s outermost corners host no dots, and therefore every lookup cell an observation refers to lies on the printed board. The fixed-board search depends on that.

Confidence is not decoration — it weights every score below, and bits under min_bit_confidence (default 0.15) are dropped entirely rather than guessed.

The hypothesis space

A fragment knows neither where it sits on the master nor which way up it is: the camera may have seen the board rotated or mirrored. So a hypothesis is a pair — one of the 8 D4 transforms, and a master origin:

8 transforms  ×  501 rows  ×  501 columns  =  2,008,008 hypotheses

Scoring one means predicting the bit at every observed edge and counting agreement. Done naively that is 8 · 501² · N bit comparisons — for a 1200-edge fragment, 2.4 billion. The decoder does not do that.

Step 1: the score depends only on residues

Because the maps are cyclic, the predicted bit at an origin depends on that origin only through mr mod 167, mr mod 3, mc mod 3, mc mod 167. There are only 501 distinct horizontal classes and 501 vertical ones, and an origin’s score splits into one term from each:

score(mr, mc) = H[mr mod 167][mc mod 3] + V[mr mod 3][mc mod 167]

So instead of scoring each origin against each observation, the decoder builds the two tables once per transform — O(501 · N) — after which every origin costs two lookups and an add. That is the single most important step: O(8 · 501² · N) becomes O(8 · (501 · N + 501²)).

Step 2: CRT removes the 501² as well

The remaining origin walk is 2 M table reads. For the hard scorer it disappears entirely. Since 501 = 3 · 167 with gcd(3, 167) = 1, the four residues are mutually independent and each ranges over its full domain, so

argmax over origins of ( H[·] + V[·] )  =  ( argmax over H ,  argmax over V )

and the winner is recovered from the two per-table argmaxes by CRT inversion: mr = (334·va + 168·ha) mod 501. The scan drops to O(501).

The separation needs an integer key, and that is a real constraint rather than an implementation detail. With integers, a table entry below the maximum is at least one below it, so it provably cannot reach the maximum sum. With f32, rounding breaks that implication: two origins built from different table values can land on the same sum. The hard scorer ranks on an integer bit-match count and separates safely; the soft scorer ranks on an f32 log-likelihood sum and therefore keeps the 501² walk, stripped down to two reads and a compare per origin.

A pathological all-tied input (an empty or degenerate observation set) can make the per-table argmax sets large; the hard path falls back to a direct table scan for the affected transform, so worst-case cost never exceeds the pre-CRT version.

Scoring: hard and soft

Both scorers consume the same tables.

HardWeighted ranks by (bits matched, summed confidence of matched bits), lexicographically, and rejects anything whose bit-error rate exceeds max_bit_error_rate (default 0.30). Integer-keyed, so it gets the CRT collapse.

SoftLogLikelihood — the default — scores each bit as log σ(±κ · confidence), clipped below by a per-bit floor so one catastrophically wrong bit cannot dominate, and sums. A crisp bit contributes strongly; a marginal one barely moves the score. This is materially better on noisy or small fragments, and it is the same transfer function the ChArUco board matcher uses. It additionally requires the winner to clear alignment_min_margin over the runner-up.

The uniqueness gate

Winning is not enough. A decode is accepted only if

margin > k_winner

where margin = best_matched − runner_up_matched and k_winner = edges_observed − best_matched is the winner’s own mismatch count. Equivalently: the winner’s net score must strictly beat the runner-up’s matched count.

This is parameter-free — it compares two counts — and it separates two failure modes that any single magnitude threshold conflates:

  • A clean exact read has k_winner = 0 and passes at any margin ≥ 1, so the code’s exact-uniqueness design is honoured at any fragment size.
  • A noisy ambiguous read fails: if a wrong origin matches nearly as many bits (small margin) while the winner itself mismatches many (large k_winner), the winner is not meaningfully closer to a perfect read than its competitor, and the decode declines.

Declining is the right answer. A missing label costs a calibration nothing; a wrong absolute label is unrecoverable.

The runner-up is taken across all eight transforms, not just within the winning one, precisely because a fragment too small to break the board’s D4 symmetry must not be allowed to invent an orientation.

How big a fragment do you need?

The paper’s headline is that a 4 × 4 fragment is unique. That is true across positions at a fixed orientation — and the decoder does not have a fixed orientation. Over D4 × position, measured on clean, noise-free windows at seven planted origins:

Mind the unit. This table sizes windows in squares, as the paper does. min_window is a corner span, and a window of s × s squares spans s + 1 corners — so the 6 × 6 row below is what min_window = 7 means. Both columns are given to keep the two readings from being confused.

window (squares)corner spanedge bitsdecodedrejected as D4-aliased
3 × 34120 / 77
4 × 45240 / 77
5 × 56405 / 72
6 × 67607 / 70
7 × 78847 / 70

Every clean 4 × 4 window tested had a perfect alias under some other transform. Clean uniqueness begins at 6 × 6 squares — a 7-corner span, which research/puzzleboard-rings then verified exhaustively at all 251 001 master positions under the four rotations.

Hence min_window = 7 by default, and the edge-count pre-filter that accompanies it is required_edges(7) = 60 — the interior edge count of exactly that span. (Before 0.13 the pre-filter read min_window as squares and demanded 84, one full ring more than the span gate intended, so fragments at exactly the documented minimum were rejected before being decoded.)

The span gate is applied to the corner span on both axes, because a wide-but-short strip can meet an edge-count floor while carrying too little code distance on its thin axis.

Noise pushes the floor up: the code’s minimum Hamming distance is 1 at 4 × 4, so a single flipped bit can turn a fragment into a perfect read of a different location. A 300k-trial sweep over random origins and error patterns put the smallest window with zero false accepts at 7 × 7 squares — 84 edge bits — at both 30 % and 40 % bit-error rates.

That measurement predates the gates now standing between a noisy read and a returned detection, and it is no longer what sets the floor. Window size is a blunt instrument for the job: what actually rules out a false accept is the uniqueness gate, a bounded-distance proof that the winner is the only codeword within its error radius. Two additions make that proof carry the noise case at a 7-corner span — the period-3 majority vote, which repairs a minority of misread dots before any gate sees them, and the PuzzleBoardDetectError::NotEnoughLogicalBits floor, which refuses the decode when too few distinct bits survive for the proof to mean anything. Without that second guard the proof stays formally valid while becoming vacuous, and wrong-origin decodes reappear at high corruption.

Reproduce the table with:

cargo test --release -p calib-targets-puzzleboard --lib -- \
    window_uniqueness_report --ignored --nocapture

Restricting the search to a declared board

When the caller knows which board they printed, PuzzleBoardSearchMode::FixedBoard restricts the origin to that board’s rectangle. Because a printed board is a sub-rectangle cut from the master, its bit at board cell (r, c) is exactly the master bit at (origin + r, origin + c) — so this is the same scoring problem, restricted, and it reuses the same class tables.

Restricting the origins also restricts the residue classes they can reach, so the precompute itself does less work. That is why declaring the board is cheaper than not declaring it rather than merely bounded — until the board grows past the maps’ 167-long period, where there is nothing left to restrict.

The scan considers only shifts under which every observation lands on the board. Because a dot is only sampled where the surrounding corners were detected, every observation does lie on the printed board, so a shift placing one outside it cannot describe the physical scene. Excluding those keeps each hypothesis at two table lookups, and keeps impossible placements out of the uniqueness gate where they could only ever suppress a correct decode.

Two guarantees follow: the decode cannot return a position the board does not cover, and any subset of the board decodes to the same master IDs a full view would — so fragments from different frames or different cameras stitch without further work.

Complexity

With N observed edges, w the observed window in squares, and L_r × L_c the shift rectangle a declared board admits:

stagecost
Edge samplingO(N · r²), r = dot sample radius
Class precompute, per transformO(min(501, reachable classes) · N)
Origin scan — hard, full masterO(501) via CRT
Origin scan — soft, full masterO(501²)
Origin scan — fixed boardO(L_r · L_c)
Full master, hardO(8 · 501 · N)
Full master, softO(8 · (501 · N + 501²))
Fixed boardO(8 · (reachable · N + L_r · L_c))

Measured decode-only cost (synthetic observations, no image pipeline), in milliseconds:

windowedgesfull/hardfull/softfixed 25×25fixed 130×130fixed 501×501
7 × 7840.332.190.080.733.94
13 × 133122.093.900.201.885.23
25 × 2512007.9710.290.075.8010.83

Reproduce with:

cargo test --release -p calib-targets-puzzleboard --lib -- \
    decode_scaling_report --ignored --nocapture

Multiple components, and the origin conflict

A view can yield several disconnected grid components. Each decodes independently, and they are ranked by edges matched, then bit-error rate, then the scorer’s own tie-breaks.

If two well-supported components disagree on the master origin, that is an unrecoverable ambiguity — some part of the labelling must be wrong, and nothing in the image says which. The detector refuses the frame rather than picking one.

Cross-references

PuzzleBoard code maps and registration

Code: calib-targets-puzzleboard/src/code_maps.rs (the maps), tools/import_author_maps.rs and tools/generate_code_maps.rs (where they come from), detector/pipeline.rs (registration).

The decode chapter takes the master pattern as given. This chapter answers the two questions underneath it: how is a pattern with that uniqueness property constructed, and once decoded, how does an origin become an absolute corner ID and an object-space coordinate.

The construction

The requirement

We need a binary array whose every window is distinct — a perfect map, the two-dimensional analogue of a De Bruijn sequence. The PuzzleBoard needs two of them, one per edge orientation, and it needs them cyclic, so a board can be cut from anywhere on the master.

The paper’s choice is a (3, 167; 3, 3)₂ sub-perfect map: shape 3 × 167, window 3 × 3, binary. “Sub-perfect” because a true perfect map of window 3 × 3 would hold all 2⁹ = 512 windows; this one holds the 501 that fit its domain, and asks only that they be pairwise distinct.

The letter trick

Reading a 3 × 167 array by columns, each column is 3 bits — call it a letter l ∈ {0..7} via l = b₀ | b₁≪1 | b₂≪2. The array becomes a length-167 string over an 8-letter alphabet, and a 3 × 3 window becomes a 3-letter substring.

Now the cyclic requirement bites. The map has only 3 rows, so shifting the window down by one row wraps — and on letters, a row shift is the permutation

σ : (b₀, b₁, b₂) → (b₁, b₂, b₀)

σ has order 3, with fixed points 0 (all zero) and 7 (all one). So the windows starting at the three row offsets of a given column position are t, σ(t), σ²(t) — the σ-orbit of that triple.

For all 501 windows (167 column positions × 3 row shifts) to be distinct, two conditions suffice:

  1. No triple is a σ-fixed point — otherwise its three row shifts coincide, and three windows collapse into one.
  2. No two column positions share a σ-orbit — otherwise a window at one position equals a row-shifted window at the other.

The 512 possible triples split into 8 singleton orbits (the σ-fixed ones) and 168 orbits of size 3. We need 167 of them, one per column position. That budget — 167 needed out of 168 available — is what makes the search easy.

Finding a valid sequence

tools/generate_code_maps.rs does stochastic hill-climbing: start from a random length-167 letter sequence; define the energy as the number of invalid column triples (σ-fixed ones plus orbit duplicates); propose single-letter mutations and accept those that lower it; restart on a local minimum. With one orbit of slack it converges in milliseconds.

The companion paper (arXiv:2405.03309) gives a closed-form construction, but no reference implementation — local search is simply the cheaper route to the same contract.

Verification is independent of construction: verify_cyclic_window_unique enumerates all 501 cyclic windows of a map and asserts pairwise distinctness, and master_4x4_windows_unique does the end-to-end check over all 501 × 501 master positions. Both run as ordinary unit tests.

Provenance of the shipped maps — and why it matters

The maps in src/data/ are not generated. They are imported.

tools/import_author_maps.rs takes the reference implementation’s arrays from PStelldinger/PuzzleBoard (CC0 1.0): map_a is the author’s code1 verbatim, and map_b is rot90(code2[::-1, ::-1]), which re-expresses code2 so its fundamental period is stored as 167 × 3. src/data/map_metadata.json records the import, the derivation, and the packing.

This is a deliberate interoperability decision. A board printed from the reference tooling decodes here, and a board printed from this crate decodes with the reference Python decoder — because both sides agree on the same code.

A regenerated pair satisfies exactly the same uniqueness property and is, in every mathematical sense, just as good. It is also a different code: boards printed with it will not decode against the reference implementation. The generator is a research and validation tool, not the shipping path. If you run it, you have forked the pattern.

Registration: from origin to absolute IDs

Decode returns a GridAlignment — a D4 transform plus a translation — that maps the fragment’s local corner coordinates onto the master. Turning that into usable output takes three steps.

1. Apply the alignment

For each labelled corner at local (u, v):

(raw_i, raw_j) = alignment.apply(u, v)

2. Wrap into the master

wrap_master reduces both coordinates into [0, 501). This is not housekeeping — it is load-bearing. Four of the eight D4 transforms have negative entries on the diagonal, so a corner far from the origin can map to a negative raw coordinate. A negative value still yields the correct id (the ID computation reduces modulo 501 anyway) but the wrong target_position, since that one is a plain multiplication by the cell size. Wrapping first keeps both consistent:

let (master_i, master_j) = wrap_master(raw_i, raw_j);

3. Emit the ID and the object-space point

id              = master_j · 501 + master_i
target_position = (master_i · cell_size,  master_j · cell_size)

which preserves the invariant a consumer can rely on:

target_position.x == (id % 501) · cell_size
target_position.y == (id / 501) · cell_size

id is what makes multi-view calibration work without correspondence search: two cameras that see overlapping parts of the board report the same id for the same physical corner, so their observations join on it directly.

Origin conflicts

A view can produce several disconnected grid components, each decoding independently. If two well-supported components disagree on the master origin — compared modulo the cyclic master by origins_conflict — then at least one labelling is wrong and the image contains no evidence of which. The detector refuses the frame rather than emitting a plausible-looking mixture.

Per-view origin drift is expected

The master origin reported for a view depends on which print-corner the chessboard stage happened to call local (0, 0), which depends on what the camera framed. Two views of the same board routinely report different origins. That is not an inconsistency: after registration both produce the same absolute IDs for the same physical corners, which is the only thing downstream consumers should compare.

Cross-references

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 image — image::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, each with a 1σ angular uncertainty + a strength response magnitude). 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 finder → recovery & 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 or selector to configure: the topological grid finder is the sole builder for every target, including ChArUco. Legacy config keys for removed builder selectors are rejected as unknown fields.

Output types

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

Regular grid pipeline

Composes: axis clustering → topological grid finder → recovery & validation. Source of truth: docs/algorithms/topological-grid-detection.md. Public surface: The Grid Model.

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

End-to-end stages

OrientedFeature<2>[]  (positions + two undirected axes each)
 →  axis clustering        recover global directions {Θ₀, Θ₁}   (optional hint)
 →  topological grid       Delaunay → classify → quads → walk    (the builder)
 →  validation + fit       line / local-H / residual gate
 →  GridDetection          labelled (i, j) component(s) + projective fit
  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 GridDetection per component: an accepted LabelledGrid plus a mandatory LatticeFit. Rejections and stage snapshots are an opt-in diagnostics contract.

Public surface

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

Hex lattices

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

Failure modes

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 ordinary callers, DetectionParams intentionally has one stable knob:

  • max_residual_px — the fit residual gate. Raise on genuinely distorted captures; it is the precision lever, so prefer the smallest value that still recovers the grid.

Stage-specific topological, validation, and recovery controls are available only by attaching projective_grid::expert::DetectionTuning. This keeps the default configuration compact while preserving an explicit seam for detector builders and reproducible evidence campaigns.

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

Cross-references

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

Chessboard pipeline

Composes the full grid stack: ChESS corners → axis clustering → topological grid → recovery & validation. Source of truth: crates/calib-targets-chessboard/docs/PIPELINE.md. Crate reference: The Chessboard Detector.

The chessboard detector takes a cloud of ChESS X-junction corners and produces an integer-labelled grid (i, j) → image position. It is the shared spine of every other target pipeline, and it is precision-anchored: every stage that can attach a label runs an axis / parity / edge invariant, and the mandatory final geometry check drops anything that slipped through. Wrong (i, j) labels are unrecoverable for downstream calibration; missing corners are acceptable — that asymmetry is the whole contract.

The six stages

The orchestrator is pipeline::detect_all_topological. The canonical stage map (mirror of the crate docs/PIPELINE.md):

#StageIn → OutWhat it does
1prefilterChESS corners → usable-flagged cornersKeep a corner iff strength ≥ min_corner_strength and max(σ₀, σ₁) ≤ topological.axis_align_tol_rad. Weak corners are kept as positions with no-information axes (so indices stay stable) but cannot vote.
2cluster_axesstrong corners’ axes → {Θ₀ ≤ Θ₁} + per-corner slot labelThe generic axis clustering: histogram + plateau peak picking + double-angle 2-means.
3topological_gridoriented features + cluster centres → labelled componentsThe topological grid finder through expert::square::assemble_oriented2_components; this builder seam stops after generic component merge and preserves the axis-slot frame for chessboard parity/recovery.
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.

Why the prefilter reads σ (Stage 1)

The axis half of the prefilter is derived, not configured: it reuses advanced.topological.axis_align_tol_rad, the tolerance the grid builder’s cell test applies to those same axes. The argument is dimensional rather than empirical — an axis estimate whose own 1σ uncertainty exceeds the alignment window cannot answer the question the cell test asks of it, so letting it vote adds noise to the classification, not information. Because the gate is the tolerance it protects, the two cannot drift apart, and there is no separate constant to tune.

This replaces the pre-0.11.0 fit-residual ratio (fit_rms ≤ max_fit_rms_ratio · contrast), which is not expressible on chess-corners 1.0 — contrast and fit_rms no longer exist. Note that the builder’s own max_axis_sigma_rad is a looser backstop applied inside grid construction and is unrelated to admission.

Key invariants

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

  • Two grid directions. Clustering recovers {Θ₀, Θ₁} (≈ 90° apart) as the only global axis prior. All axis means use the undirected (cos 2θ, sin 2θ) accumulation — there is 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).

Multi-component dispatch

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

Failure modes

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

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) labelsnever—File a bug. The precision contract has been violated; do not tune around it.

Tuning

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

Cross-references

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

PuzzleBoard pipeline

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

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

End-to-end stages

#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-window gatefiltered edges → pass / failRequire the fragment to span min_window corners on both axes (default 7). That span carries 30 distinct code bits — the same information the paper’s 4 × 4 fragment carries — and is unique at every master position under the rotations-only search.
3bperiod-3 consensusfiltered edges → voted code bitsBoth maps repeat every three rows or columns, so collapse the dots onto the distinct bits they read, by confidence-weighted majority vote. Origin- and orientation-invariant, so it runs before any hypothesis. Ties are erasures, never guesses; too few surviving bits → NotEnoughLogicalBits.
4origin decodefiltered edges + master maps → (D4, origin) + scoreRecover the (D4 transform, master origin) via the cyclic class tables — Full (whole master) or FixedBoard (declared board’s rectangle) scope, scored by HardWeighted (BER gate) or SoftLogLikelihood (margin gate, default). Both are re-gated for uniqueness. See the decode algorithm.
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 the only builder and has no selector field.

The partial-view guarantee

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

Failure modes

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 (HardWeighted)Board too small or too noisy. Raise decode.max_bit_error_rate, or switch to SoftLogLikelihood (more robust on ambiguous fragments).
Small / ambiguous decode 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 ChESS corner front-end is params.chess (a DetectorConfig, consumed by the facade’s whole-image entry points); the grid side is the standard chessboard ChessboardParams (under params.chessboard); the decode side is params.decode:

  • min_bit_confidence (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 BER gate.
  • min_window (default 7) — the uniqueness floor; rarely changed, and lowering it trades misses for the risk of a wrong absolute label.
  • search_mode — Full (default) vs FixedBoard (cheaper, fixes per-view origin drift when the board is known).
  • scoring_mode — SoftLogLikelihood (default) vs HardWeighted (robust on ambiguous fragments; adds the margin gate).
  • symmetry_mode — Rotations (default; the four 90° rotations, correct for an ordinary camera) vs RotationsAndReflections (all eight dihedral transforms; enable only when the optical path mirrors the image).
  • 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.)
  • the chessboard’s final wrong-label geometry check stays enabled, exactly as for the standalone chessboard detector. Marker decoding is not a substitute: the board alignment searches D4 × integer translation, a rigid relabelling of whatever lattice the chessboard produced, so it cannot see or repair a labelling that is wrong within a component.

There is no chessboard grid-builder selector; legacy selector keys are rejected as unknown configuration fields.

Failure modes

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 grid — CharucoParams.chessboard is a ChessboardParams (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 decode — scan.* (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::detect — single 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 and accept when the disk-vs-ring level difference clears min_contrast and the shape gate is silent: the intensity on a ladder of probe rings must carry no 4-fold angular modulation beyond what an ellipse would explain. The level test alone accepts any centred blob of roughly the right size.
2–3board-frame resolutioncandidates + spec → GridTransform + Vec<CircleMatch>Hypothesis-and-verify, not matching. Every (rotation, translation) pinned by one expected-circle-to-candidate seed is enumerated and scored by how many expected circles it explains exactly. Accepted only when the best frame explains ≥ min_offset_inliers circles and strictly beats every other frame. Only the four rotations are searched — a reflection is not something a camera can produce from the printed side of an opaque board.
4corner-frame shiftalignment → alignmentA circle layout names squares, so the resolved frame labels corners 1..=cols; one shift puts them on the inner-corner indexing the printable spec and the corner ids use.
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 / Err(ChessboardNotDetected)Stage 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), low contrast, or not actually round — the squareness reading on each candidate says which. Adjust circle_score.min_contrast, check board.circle_diameter_rel matches what was printed, and check roi_cells is not excluding them.
Err(AlignmentFailed) (too few circles agree)Stage 2–3Fewer than min_offset_inliers of the layout’s circles were found, or polarity mismatches the spec. Verify the three MarkerCircleSpec cells + polarities against the printed board.
Err(AlignmentAmbiguous)Stage 2–3Two board frames explain the circles equally well. Either the layout is rotationally symmetric — pick cells whose rotations differ — or the scorer produced spurious same-polarity candidates. The circle_candidates dump tells the two apart.
Grid found but target_position emptyoutputboard.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 board layout + chessboard params + circle scoring + matching:

  • board — the MarkerBoardSpec (rows, cols, the three MarkerCircleSpec cells + polarities, circle_diameter_rel, optional cell_size). The marker circles supply the geometry constraint, so the v1 expected_rows/cols and completeness_threshold no longer apply. circle_diameter_rel is the one place the printed disc size is stated — every radius the scorer probes is relative to it.
  • chessboard — a ChessboardParams for the underlying grid step.
  • circle_score (CircleScoreParams) — patch_size, ring_thickness_frac, ring_radius_mul, min_contrast, samples, center_search_px.
  • match_params (CircleMatchParams) — max_candidates_per_polarity (default 6), min_offset_inliers (default 3, the whole layout). Lowering min_offset_inliers buys recall on a board with an occluded marker at the cost of the guarantee that the frame is never wrong.
  • roi_cells — optional [i0, j0, i1, j1] to restrict the circle search.

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

Cross-references

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

Project Overview

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

Workspace layout

  • calib-targets-core: shared geometry types and utilities.
  • calib-targets-chessboard: chessboard detection from corner clouds.
  • calib-targets-aruco: embedded dictionaries and decoding on rectified grids.
  • calib-targets-charuco: grid-first ChArUco detector and alignment.
  • calib-targets-puzzleboard: self-identifying chessboard detector with absolute corner IDs from edge dots.
  • calib-targets-marker: checkerboard marker detector (chessboard + circles).
  • calib-targets: facade crate, currently hosting examples and future high-level APIs.

Strengths

  • Clear crate boundaries with a small, geometry-first core.
  • Chessboard detection pipeline is implemented end-to-end with debug outputs.
  • Mesh-warp rectification supports lens distortion without assuming a single global homography.
  • Examples and regression tests exist for all workflows.

Gaps and early-stage areas

  • Public APIs are not yet stable.
  • ArUco decoding assumes rectified grids and does not perform quad detection.
  • Performance/benchmarks are not yet a focus.

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.

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 real-world captures carrying non-negligible lens distortion and motion blur, the detector achieves a high detection rate with zero wrong (i, j) labels — precision-by-construction.

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

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

1. Corner axes contract

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

  • axes[0].angle ∈ [0, π), axes[1].angle ∈ (axes[0].angle, axes[0].angle + π).
  • axes[1] − axes[0] ≈ π/2 — the two axes are orthogonal grid directions (NOT diagonals of unit squares).
  • The CCW sweep from axes[0] 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 33.0); and
  • max(c.axes[0].sigma, c.axes[1].sigma) ≤ axis_align_tol_rad (default 15°, the Stage 3 axis-alignment tolerance).

The second condition is derived, not a knob of its own: an axis whose 1σ uncertainty is wider than the window the cell test will judge it against carries no usable information for that test. It replaces the pre-0.11.0 fit_rms ≤ max_fit_rms_ratio × contrast rule, which chess-corners 1.0 made inexpressible by removing both scalars.

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

Stage 2 — Axis clustering (cluster/)

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

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

Stage 3 — Topological grid (mod.rs → projective-grid)

Hand the oriented features (positions + dual axes) and the cluster centres (as an axis hint) to the topological grid finder (via expert::square::assemble_oriented2_components): Delaunay triangulation → axis-driven edge classification → triangle-pair → quad merge → flood-fill (i, j) walk → the facade’s merge_components_local. The expert seam stops at that merge and preserves the topology axis-slot frame; chessboard parity/recovery and its final validation run downstream.

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

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

Stage 5 — Final geometry check (geometry_check.rs)

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

  • the shared validate (line collinearity + local-H residual) with 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 ChessboardDetector::detect_all, which peels off disconnected components of the same physical board (the typical ChArUco case where markers interrupt grid contiguity). Each component is rebased to its own (0, 0) origin; alignment to a global frame is the caller’s job.

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


5. Failure modes

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

SymptomLikely stageKnob to tryNotes
No detection; trace shows few usable cornersStage 1–2 (prefilter / clustering)min_corner_strength ↓, min_peak_weight_fraction, peak_min_separation_degEither the corners failed the prefilter (weak response, or axes too uncertain to vote) or the two grid axes never separated. Most common on very-bad-light frames.
No detection; trace shows usable corners but NoComponentsStage 3 (topological grid)Try detect_chessboard_best with ChessboardParams::sweep_default()No quad mesh assembled. Builder tolerances are internal; the sweep widens the upstream clustering / attachment tolerances.
Detection has very few 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 emittednever—If you ever see this, file a bug. The precision contract has been violated.

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


6. Parameters

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

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

FieldDefaultStagePurpose
max_components3—Cap for detect_all.
min_labeled_corners85Minimum labelled corners to emit a ChessboardDetection.
min_corner_strength33.01Minimum ChESS strength. 0 disables. (Stable.)
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 ChessboardAdvancedTuning, which is opt-in and not covered by semver. (ChessboardAdvancedTuning carries more per-stage knobs than shown — see crates/calib-targets-chessboard/src/params/.)

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


7. Debugging via the topological trace

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

  • params: TopologicalParams — the parameters the topological stage ran with.
  • corners: Vec<TopologicalCornerTrace> — every input corner with 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, ChessboardDetector, ChessboardParams};

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

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

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

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

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


9. Open questions

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

Contributions welcome.

calib-targets-aruco

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

Rectified grid used for ArUco/AprilTag decoding.

For the algorithm — the grid-aware bit sampling model, the explicit bit order / polarity / 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:

Which OpenCV pattern? The modern one — OpenCV’s default since 4.6. The top-left square is black, markers sit on the white squares ((col + row) odd), and marker IDs run row-major over those squares from 0, so marker 0 is at column 1 of the top row. This is what cv2.aruco.CharucoBoard produces out of the box; it is not setLegacyPattern(True), which flips the parity and puts a white square and marker 0 at (0, 0). Boards this crate generates and detects use the modern pattern throughout, and crates/calib-targets-print/tests/opencv_charuco_conformance.rs pins it against captured OpenCV output.

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 CharucoDetection 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 uses the semantic GridAlignment alias for the canonical affine GridTransform; it maps detected grid coordinates into board coordinates with one matrix plus translation.

calib-targets-puzzleboard

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

PuzzleBoard is based on Stelldinger 2024, arXiv:2409.20127.

The crate also ships the PuzzlePole — the same pattern wrapped round a cylinder, from Zach & Stelldinger 2025, arXiv:2511.19448 — in its pole module. It is here rather than in a crate of its own because it is the same decoder with one period changed. Start at Build and detect a PuzzlePole.

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

Target Model

PuzzleBoardSpec describes the printable board:

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

Detected inner corners are returned as LabeledCorner values with:

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

Bit Layout

The board uses two embedded cyclic maps:

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

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

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

The committed blobs are src/data/map_a.bin and src/data/map_b.bin. They are imported from the reference implementation (PStelldinger/PuzzleBoard, CC0) by import-author-puzzleboard-maps, so boards interoperate with it; generate-puzzleboard-code-maps can build an alternate, non-interoperable pair for research, and verify-puzzleboard-code-maps checks the uniqueness property. The runtime detector constructs nothing. See Code maps and registration.

Detection Pipeline

The flow is grid-first:

  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 the candidate orientations — the four 90° rotations by default (symmetry_mode = Rotations), or all eight D4 transforms under RotationsAndReflections.
  6. Assign absolute IDs and target-space positions to inlier corners.

The default decode.min_window is 7: after confidence filtering the fragment must span at least 7 corners on both axes — 6 × 6 squares. A 4 × 4 window is unique across master positions only at a fixed orientation, and a fragment gives no cue to how the board was printed, so the decoder searches candidate orientations too — over orientation × position, clean uniqueness begins at exactly this span. Noise is handled by the uniqueness gate and the period-3 majority vote rather than by a larger window. See the decode chapter.

Rust Facade Example

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

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let img = image::open("testdata/puzzleboard_small.png")?.to_luma8();
    let spec = PuzzleBoardSpec::new(10, 10, 12.0)?;
    let params = PuzzleBoardParams::for_board(spec);
    let result = detect::detect_puzzleboard(&img, &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 considers every (D4, origin) candidate against the full master code — without enumerating them, since the code’s cyclic structure collapses the search (see the decode chapter). When the caller already knows which board they printed, PuzzleBoardSearchMode::FixedBoard restricts the origin to that board’s rectangle, which is both faster than the full search at every board size below the master’s own and guarantees the decode cannot return a position outside the printed board:

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

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: board layout + chessboard params + circle score + match settings.
  • MarkerBoardDetection:
    • detection: TargetDetection labeled as CheckerboardMarker.
    • alignment: optional GridAlignment semantic alias for the canonical affine GridTransform, from detected grid coordinates to board coordinates.
  • MarkerBoardDiagnostics (opt-in, from the diagnose / diagnose_with_corners 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: circles consistent with the best board frame.
    • alignment_runner_up_inliers: circles consistent with the best competing frame.
    • alignment_ambiguous: a second frame explained the circles just as well, so no alignment was returned.

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.
  • circle_diameter_rel: printed disc diameter as a fraction of the square side (default 0.5). Every radius the circle scorer probes is relative to this, so it is the one place the disc size is stated — it matches circle_diameter_rel on the printable spec.

MarkerBoardParams configures detection:

  • board: the MarkerBoardSpec to detect.
  • chessboard: ChessboardParams 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.
  • 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.
  • min_offset_inliers: circles that must agree on one board frame before it is returned. Defaults to 3 — the whole layout.

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 rotation and a translation, and it is the frame the returned corner labels are already expressed in.
  • A circle’s polarity is fixed by the square underneath it: white on an even i + j, black on an odd one. Any other combination draws a disc the same colour as its square, and calib-targets-print rejects it.
  • The frame search covers only the four rotations, not the eight dihedral transforms. A camera imaging the printed side of an opaque board cannot produce a reflection, so including them would only let physically unreachable hypotheses compete with the true one.
  • Resolving the frame is all-or-nothing by design. A single circle is consistent with all four rotations, and two leave no redundancy, so anything short of the full layout agreeing — or a second frame agreeing equally well — comes back as a typed error rather than a guess.

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, puzzle_board, or puzzlepole
  • 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
  • <stem>.dxf

The normalized .json file records the exact document that was rendered. SVG, PNG and DXF are emitted from the same internal scene description, so they all describe the same board geometry. The DXF is the chrome-on-glass photolithography handoff: it carries only the black regions of the board, as closed boundary polylines in a Y-up millimetre frame.

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

The cylindrical target

puzzlepole is the one target here that is not printed flat and used flat. It is a PuzzleBoard strip cut to wrap a cylinder, and it differs from every other spec in two ways worth stating here rather than only in the how-to:

  • The size is not free. The pattern closes at only a few circumferences, so the resulting diameter is quantised at circumference_squares * square_size_mm / π. An unsupported circumference is rejected rather than rounded — there is no nearest fit that still meets itself.
  • The sheet is bigger than the target. The strip is printed circumference_squares + 2 pieces around, two more than wrap, so it can be trimmed mid-piece at both ends and still leave one piece of glue overlap.

Everything else is the same document, the same bundle, and the same renderer: the strip is a sub-rectangle of the master pattern, so it goes through the PuzzleBoard drawing path rather than a parallel one.

See Print and wrap a PuzzlePole for the diameter table, the trim lines, and the coordinate frame.

The inner white square

Chessboard, ChArUco and marker-board targets accept an optional inner_square_rel: a white square inset, centred inside every black square, whose side is that fraction of the square side. It is what a board destined for laser calibration usually wants, and a document that carries it round-trips through this library unchanged.

"target": {
  "kind": "chessboard",
  "inner_rows": 6,
  "inner_cols": 8,
  "square_size_mm": 20.0,
  "inner_square_rel": 0.4
}

Complete fixtures for all three target families live alongside the others:

  • testdata/printable/chessboard_inner_square.json
  • testdata/printable/charuco_inner_square.json
  • testdata/printable/marker_board_inner_square.json

The same field is available as a flag on every init and gen subcommand that accepts it, in both the Rust and the Python CLI:

calib-targets gen chessboard \
  --out-stem tmpdata/printable/chessboard \
  --inner-rows 6 --inner-cols 8 --square-size-mm 20 \
  --inner-square-rel 0.4

The value must lie in [0, 1). 1.0 and above are rejected, because the inset would erase the square it is cut from. Omitting the field and passing 0 both mean “no inset”, and a document without one serializes exactly as it did before the field existed.

The inset is drawn inside a square, so it moves no corner intersection: the resolved target points are identical with and without it, and detection is too. Measured across inner_square_rel 0.0 through 0.9, the chessboard, ChArUco and marker-board detectors return the same corner counts, grid extents, marker ids, board frame and marker rotations. ChESS corners fire on saddle / X-junctions and an inset corner is an L-corner, so no supported range narrower than [0, 1) applies.

Three limits are worth stating plainly. On a ChArUco board the inset applies only to the plain black checker squares, never to an ArUco marker’s bit cells. On a marker board it likewise skips the three circle cells: the inset is drawn in the same white a marker disk needs, so leaving it in merges the two — and once inner_square_rel reaches circle_diameter_rel it swallows the disk whole. Puzzleboard targets do not support it at all.

The marker-board claim above is the one that had to be earned. Until 0.15.0 the inset was painted under the circles and the detector’s circle scorer measured only a light-versus-dark level difference, with no test that the bright region was actually round — so every white inset on the board scored as a marker disk, and the detector could resolve the board frame 180 degrees rotated while still reporting three clean circle matches (issue #96). Both halves are fixed, and crates/calib-targets-print/tests/marker_board_roundtrip.rs renders the sweep and detects it back on every commit.

In the DXF, an inset square becomes two closed polylines — the square and its hole, the hole wound opposite to the square — rather than a white shape layered on a black one, so a fab that fills the boundary polylines cuts the inset out instead of flooding over it.

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());
    println!("{}", written.dxf_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).

Facade examples cover detection and rectification workflows.

Single-config detection

Each detect_* function takes the image plus a params struct. Where the ChESS corner front-end is configured depends on the target shape — and the asymmetry is deliberate:

  • Plain chessboards are corner-cloud consumers, so detect_chessboard takes the front-end explicitly: detect_chessboard(&img, &chess_cfg, &params). Pass detect::default_chess_config() unless you need to tune it. Because the chessboard detector is reusable on any corner cloud (custom upstreams, pre-detected corners), the facade keeps its corner config separable.
  • ChArUco, PuzzleBoard, and marker boards own their whole-image pipeline, so the front-end travels on the params bundle as params.chess (defaulting to default_chess_config()); these entry points take no separate front-end argument. Their grid-step tuning lives under a separate params.chessboard field (a ChessboardParams).
#![allow(unused)]
fn main() {
use calib_targets::detect;
use calib_targets::chessboard::ChessboardParams;

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

Detector structs and free functions are one surface

Each detect_* free function is the detector one-liner: construct the detector from params, then call detect. Reach for whichever fits — the free function when a single call is all you need, the detector struct when you configure once and detect many times, or when you want the corner-reuse entry points below.

CharucoDetector, PuzzleBoardDetector, and MarkerBoardDetector each expose the same five operations, mirrored by five free functions per target (charuco, puzzleboard, marker_board):

  • detect(&view) / detect_t(img, params) — run the ChESS corner pass (from params.chess) and the whole pipeline.
  • detect_with_corners(&view, &corners) / detect_t_with_corners(img, corners, params) — skip the corner pass and use corners you already have.
  • diagnose(&view) / diagnose_t(img, params) (diagnostics feature) — like detect, plus a diagnostics report.
  • diagnose_with_corners(&view, &corners) / diagnose_t_with_corners(img, corners, params) (diagnostics feature) — like detect_with_corners, plus diagnostics.
  • detect_t_best(img, configs) — try several configs, keep the richest result.

detect_corners on a detector runs exactly the ChESS pass that detector’s own params.chess configures, so corners you inject match what detect would have produced on its own. The one case that genuinely wants detect_with_corners: running a single corner pass across several target detectors on the same image, rather than paying for it once per detector.

let corners = charuco_detector.detect_corners(&view);
let a = charuco_detector.detect_with_corners(&view, &corners)?;
let b = puzzle_detector.detect_with_corners(&view, &corners)?;

ChessboardDetector keeps its original, corner-cloud-only shape described above — it has no chess field to run a corner pass from, by design: it is embedded inside all three composite params types, so a nested corner-detector config there would be dead in exactly the way a chess field on ChessboardParams itself would be. Configure the corner pass for a chessboard through detect_chessboard’s explicit chess_cfg argument instead.

Multi-config sweep

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

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

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

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

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

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

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

Features

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

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

Examples

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

To run an example from the workspace root:

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

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

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

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

python crates/calib-targets-py/examples/detect_charuco.py testdata/small2.png
python crates/calib-targets-py/examples/detect_puzzleboard.py testdata/puzzleboard_small.png
python crates/calib-targets-py/examples/detect_puzzlepole.py testdata/puzzlepole_view.png \
    --overlay seen.png --csv pairs.csv

The PuzzlePole pair is the most tool-like of them: generate_printable_puzzlepole.py writes the wrap strip on a page sized to fit it, and detect_puzzlepole.py reads that document back with --doc so the printed pole and the detector cannot disagree about which pole it is. See Build and detect a PuzzlePole.

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

Regular Grid Detection Example

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


Quick run

cargo run -p projective-grid --example hello_grid

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


Walkthrough

1. Build oriented features

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

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

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

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

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

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

2. One call: detect_grid

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

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

let request = DetectionRequest::new(
    LatticeKind::Square,
    Evidence::Oriented2(&features),
);
let detection = detect_grid(request)?;
assert_eq!(detection.grid().entries().len(), 9);
}

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

3. Handle the Result

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

  • UnsupportedCombination { task, lattice, evidence } — the requested (lattice, evidence) pair has no algorithm. The unsupported cells are (Square, Oriented3) and (Hex, Oriented1/Oriented2).
  • InsufficientEvidence — too few features to assemble 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 GridDetection:

  • grid(): &LabelledGrid — the recovered component. entries() is one GridEntry per placed feature; bbox() is the inclusive coordinate bounding box; dimensions() echoes any caller-supplied GridDimensions.
  • fit(): &LatticeFit — the mandatory fitted model-plane-to-image projective transform (model_to_image) plus a residual summary (residuals.count, residuals.mean_px, residuals.max_px).

Rejected features and exact stage snapshots are available separately through the opt-in diagnostics feature, so they do not expand the mandatory result.

Each GridEntry carries:

  • coord: Coord — the (i, j) label 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<f32> — the feature’s image-frame pixel-center position.
  • residual_px: Option<f32> — reprojection residual in pixels, present when a fit was computed.
#![allow(unused)]
fn main() {
for entry in detection.grid().entries() {
    // coord.u = i, coord.v = j; source_index maps back to the input.
    println!(
        "(i={}, j={}) <- feature {} at ({:.1}, {:.1})",
        entry.coord.u,
        entry.coord.v,
        entry.source_index,
        entry.image_position.x,
        entry.image_position.y,
    );
}
}

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


Going further

  • Multiple components — detect_grid_all returns a Vec<GridDetection>, ordered by labelled count descending. Use it when the lattice fragments into islands (for example by occlusion) and the secondary components matter. The topological algorithm may yield several components.
  • Checking caller-supplied labels — when (i, j) labels already exist (for instance from a marker decode), check_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 run — crates/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.

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, &ChessboardParams::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. Both trace_topological and the TopologicalTrace type are gated behind the chessboard crate’s off-by-default diagnostics feature.

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


Direct crate-level usage

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

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

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

let params = ChessboardParams::default();
let detector = ChessboardDetector::new(params);

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

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

Global Rectification Example

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

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

Global rectification output from the small test image.

  • A rectified grayscale image.
  • A JSON report with homography matrices and grid bounds.

The code defaults to tmpdata/rectify_config.json, but a ready-made config exists in testdata/rectify_config.json (input: testdata/small.png, rectified output: tmpdata/rectified_small.png, report: tmpdata/charuco_report_small.json).

Run it with:

cargo run -p calib-targets-chessboard --example rectify_global -- testdata/rectify_config.json

If rectification succeeds, the rectified image is written to tmpdata/rectified.png unless overridden in the config.

Mesh Rectification Example

File: crates/calib-targets-aruco/examples/rectify_mesh.rs

This example detects a chessboard, performs per-cell mesh rectification, and scans the rectified grid for ArUco markers. It writes:

Per-cell mesh rectification output from the small test image.

  • A mesh-rectified grayscale image.
  • A JSON report with rectification info and marker detections.

The code defaults to testdata/rectify_mesh_config_small0.json, and that config is a good starting point (input: testdata/small0.png, mesh output: tmpdata/mesh_rectified_small0.png, report: tmpdata/rectify_mesh_report_small0.json).

Run it with:

cargo run -p calib-targets-aruco --example rectify_mesh -- testdata/rectify_mesh_config_small0.json

This is a good reference for the full grid -> rectification -> marker scan pipeline.

Data and tools

The repository ships fixtures and scripts that make a detection reproducible — useful when you are debugging a failure of your own and want a known-good baseline to compare against, and necessary if you intend to change a detector.

Everything on this page lives in the source repository, not in the published crates.

Fixtures — testdata/

Public test images and the configs that go with them. These are what the integration tests run against, so a claim in this book is generally backed by one of them.

PathWhat it is
testdata/small.png, mid.png, large.pngChArUco boards at three scales; small is partially visible
testdata/small0.png … small5.pngThe same board across six viewpoints
testdata/puzzleboard/Three photo-realistic PuzzleBoard renders — oblique, foreshortened, and a small rotated fragment — with exact corner ground truth in manifest.json
testdata/02-topo-grid/Chessboards that stress the topological grid builder, with regression_manifest.json
testdata/markerboard.png, markerboard_crop.pngMarker board, whole and cropped
testdata/printable/*.jsonPrintable-document fixtures — the published schema, not example data

testdata/printable/ deserves the emphasis: those files are the exact document shape the CLI reads and the WASM surface accepts, so they are a contract. A field rename on the Rust side has to fail there rather than silently break every host application that builds one by hand.

Some suites need data that is not in the repository — the authors’ own PuzzleBoard reference photographs, for instance, are 53 MB and untracked. Those tests self-skip when the data is absent, so a clean checkout is always green.

Overlays — tools/plot_*_overlay.py

Each takes a detection-report JSON and draws the result over the source image:

python tools/plot_chessboard_overlay.py report.json -o overlay.png
python tools/plot_charuco_overlay.py    report.json -o overlay.png
python tools/plot_marker_overlay.py     report.json -o overlay.png

Overlays are not a nicety. A detector failure is diagnosed by looking at which corners were labelled what — a corner count or a reprojection residual will not distinguish a miss from a mislabel, and mislabels are the failure that matters. Render the overlay before forming a theory.

For PuzzleBoard there is a one-command path that needs no report file:

python crates/calib-targets-py/examples/puzzleboard_detection_overlay.py

Synthetic targets

tools/synth_marker_target.py renders a marker board through a chosen perspective with configurable blur, noise and radial distortion, and writes the exact ground truth alongside it. crates/calib-targets-puzzleboard/tools/synth_puzzleboard_photo.py does the same for PuzzleBoard and is what produced testdata/puzzleboard/; its scenarios are deterministic, so regenerating them reproduces the committed images.

Synthetic data answers a question real images cannot: it separates the detector is wrong from the ground truth is wrong.

OpenCV comparison

tools/compare_opencv_baseline.py measures recall and runtime against OpenCV on the two public report images. It refuses to run against private data, which is deliberate — every number in the published performance report has to be reproducible from a clean checkout.

Generated output stays local

Overlays, bench results, sweep CSVs and profiling dumps are never committed. Write them under bench_results/ or tmpdata/, both of which are ignored. The full rule, and why it is a rule, is in docs/development/conventions.md.