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-cornerscrate.
What it is not:
- A replacement for your corner detector or image pipeline.
- A full calibration stack (no camera calibration or PnP here).
Recommended reading order:
- Project Overview and Conventions
- Algorithms — the building blocks every detector shares
- Pipelines — each target’s end-to-end detection flow
- Crate chapters, starting with calib-targets-core and calib-targets-chessboard
Quickstart
Install the facade crate (the image feature is enabled by default):
cargo add calib-targets image
Minimal chessboard detection:
use calib_targets::detect;
use calib_targets::chessboard::ChessboardParams;
use calib_targets::image::ImageReader; // re-exported; version always matches
fn main() -> Result<(), Box<dyn std::error::Error>> {
let img = ImageReader::open("board.png")?.decode()?.to_luma8();
let params = ChessboardParams::default();
let result = detect::detect_chessboard(&img, &detect::default_chess_config(), ¶ms);
println!("detected: {}", result.is_ok());
Ok(())
}
Python bindings
Python bindings are built with maturin:
pip install maturin
maturin develop
python crates/calib-targets-py/examples/detect_chessboard.py path/to/image.png
The calib_targets module exposes detect_chessboard, detect_charuco,
detect_puzzleboard, 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
| Surface | Description |
|---|---|
| Image input | Drop or browse a file; or pick a bundled public sample (chessboard, ChArUco, marker board, PuzzleBoard); or generate a synthetic target on-the-fly in WASM (Generate tab). |
| Target family | Switch between corner detection and the four target detectors (Chessboard, ChArUco, Marker board, PuzzleBoard). |
| Board geometry | For ChArUco, marker, and PuzzleBoard targets: configure rows, cols, and (ChArUco) ArUco dictionary directly in the panel. |
| Core params | Override min_corner_strength, min_labeled_corners, and max_components — the three params shared across all detector families. |
| Multi-config sweep | Toggle detect_*_best to run the built-in 3-config preset and keep the best result. |
| Overlays | Red corners, light-blue grid edges, yellow origin ring, and green far-corner ring drawn in image pixel coordinates. Toggled per-layer. |
| Zoom / pan | Scroll to zoom (up to 32×, pixel-crisp above 4×), drag to pan, double-click to fit. Hover a corner for an (i, j) / id / score tooltip. |
| Synthetic generation | render_*_png WASM functions produce a full-resolution target PNG; loading it auto-configures the matching detector. |
Running locally
If the embedded iframe fails to load (older browsers without
WebAssembly or ES modules support), build and run the demo standalone:
scripts/build-wasm.sh # populates demo/pkg/
cd demo && bun install && bun run dev # http://localhost:5173
To use the same WASM module from your own web app:
npm install @vitavision/calib-targets
import init, {
default_chess_config,
default_chessboard_params,
detect_chessboard,
rgba_to_gray,
} from "@vitavision/calib-targets";
await init();
const gray = rgba_to_gray(rgba, width, height);
const result = detect_chessboard(
width, height, gray,
default_chess_config(),
default_chessboard_params(),
);
The full TypeScript surface — default_*_params(...),
*_sweep_*(...), render_*_png(...), and list_aruco_dictionaries() —
is documented in the package README and ships as .d.ts declarations
alongside the WASM module.
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:
- Choose the right calibration target for your use case.
- Generate a printable target file.
- Print it correctly.
- Write detection code in Python or Rust.
No prior knowledge of the library is assumed.
Step 1: Choose your target type
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, ¶ms)?;
println!(
"corners: {}, markers: {}",
result.corners.len(),
result.markers.len()
);
// Collect point pairs
for c in &result.corners {
if let Some(tp) = c.target_position {
let obj = [tp[0], tp[1], 0.0_f32];
let img = c.position;
// pass (obj, img) to your calibration solver
let _ = (obj, img);
}
}
Ok(())
}
Next steps
| Topic | Where |
|---|---|
| How detection works underneath | The Grid Model |
| Detection parameters explained | Tuning the Detector |
| Detection fails or gives errors | Troubleshooting |
| What every output field means | Understanding Results |
| Full printable-target reference | calib-targets-print |
| ChArUco pipeline internals | ChArUco crate |
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 around | Diameter at 20 mm pieces | Strips available |
|---|---|---|
| 12 | 76 mm | 6 |
| 18 | 115 mm | 3 |
| 24 | 153 mm | 3 |
| 30 | 191 mm | 9 |
| 36 | 229 mm | 5 |
| 42 | 267 mm | 6 |
| 48 | 306 mm | 3 |
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 + 1columns, so one strip yields⌊501 / (n + 1)⌋poles no two of which are cut from the same pattern. Their emittedids do still coincide — a corner id isring × columns + columnwithin 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:
| Field | Meaning |
|---|---|
position | where it landed in the image, in pixels |
surface_position | where it sits on the unrolled strip, in mm |
object_position | where 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:
| Knob | Default | What it demands |
|---|---|---|
decode.min_circumference_span | 5 | corner rows visible around the pole |
decode.min_axial_span | 8 | corner 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.
Related
- Print and wrap a PuzzlePole — the physical half.
- PuzzleBoard edge-code decode — how the code is read, which is the same machinery with one period changed.
- Choose a target — when a pole is and is not the right answer.
How-to
Task-shaped answers. Each page assumes you know what you are trying to do and want the shortest correct path to it.
- Choose a target — which of the six families, and what size.
- Tune the detector — the symptom-to-parameter table, and the per-parameter reference behind it.
- Troubleshoot a failure — each error variant mapped to a diagnostic checklist.
- Print and wrap a PuzzlePole — the cylindrical target: picking a diameter, trimming, wrapping, and what its coordinates mean.
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
| Family | Corner IDs | Partial views | Needs | Reference |
|---|---|---|---|---|
| Chessboard | none | ✗ whole board | nothing | pipeline |
| ChArUco | absolute | ✓ | an ArUco dictionary | pipeline |
| PuzzleBoard | absolute | ✓ down to a small fragment | nothing | pipeline |
| Marker board | absolute | ✓ | a custom circle layout | pipeline |
| PuzzlePole | absolute, 3-D | ✓ from any azimuth | a tube to wrap it on | how-to |
| Regular grid | none | ✗ | nothing | pipeline |
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.
| Symptom | Parameter to adjust |
|---|---|
detect_chessboard returns Err(NoDetection) | min_corner_strength ↓, cluster_tol_deg ↑, min_peak_weight_fraction ↓, or try detect_chessboard_best |
| Partial board, many holes | attach_search_rel ↑, attach_axis_tol_deg ↑ |
| Scene has multiple chessboard components | use detect_chessboard_all (cap with max_components) |
| Fast perspective / wide-angle lens | edge_axis_tol_deg ↑, geometry_check_local_h_tol_rel ↑ |
Corners falsely labelled (wrong (i, j)) | Do not tune — file a bug. precision contract forbids this. |
NoMarkers on blurry ChArUco | min_border_score ↓, multi_threshold: true |
AlignmentFailed (low inlier count) | min_marker_inliers ↓ |
DecodeFailed on PuzzleBoard | decode.min_bit_confidence ↓, decode.max_bit_error_rate ↑ |
Per-parameter reference: chessboard::ChessboardParams
ChessboardParams is a #[non_exhaustive] struct split into two surfaces:
- a stable core of three fields covered by semver —
min_labeled_corners,max_components, andmin_corner_strength(see Output gates and Stage 1 below); - an opt-in
advancedsub-struct (Option<Box<ChessboardAdvancedTuning>>) holding the ~40 per-stage knobs.ChessboardAdvancedTuningis NOT covered by semver — leave it unset unless a specific input fails and you have evidence for the change.
Attach overrides with ChessboardParams::with_advanced(tuning) and read the
effective tuning with effective_tuning(). ChessboardAdvancedTuning is
#[non_exhaustive], so build it from ChessboardAdvancedTuning::default() and
mutate the knobs you need:
#![allow(unused)]
fn main() {
use calib_targets::chessboard::{ChessboardAdvancedTuning, ChessboardParams};
let mut advanced = ChessboardAdvancedTuning::default();
advanced.cluster_tol_deg = 16.0;
advanced.attach_search_rel = 0.5;
let params = ChessboardParams::default().with_advanced(advanced);
}
All knobs in the Stage 2-8 tables below are advanced knobs set on the
advanced block; min_corner_strength (Stage 1) and the output gates are
stable top-level fields. See the chessboard chapter for
the full invariant-to-parameter mapping and
crates/calib-targets-chessboard/src/params/ for defaults.
Stage 1 — pre-filter
A corner’s axes are admitted when strength ≥ min_corner_strength and
max(σ₀, σ₁) ≤ advanced.topological.axis_align_tol_rad. Corners that fail
either half are kept as positions (so corner indices stay stable) but carry
no-information axes and cannot classify Delaunay edges.
| Field | Default | Guidance |
|---|---|---|
min_corner_strength | 33.0 | The only magnitude gate on the corner set. Lower it and marker-bit / noise saddles enter grid construction; raise it on scenes with many spurious saddles at the cost of recall on soft corners. |
There is no second pre-filter knob. The axis half of the gate is derived
from axis_align_tol_rad (default 15°, a Stage 3 tolerance) rather than
configured separately: an axis whose own 1σ uncertainty is wider than the
alignment window cannot answer the question the cell test poses, so it must
not vote. Tying the two together is deliberate — loosening the cell test
automatically loosens admission by exactly the same amount.
Removed in 0.11.0.
max_fit_rms_ratio(fit_rms ≤ ratio × contrast) is gone: chess-corners 1.0 no longer reportscontrastorfit_rms. See the Migration Guide.
Stages 2-3 — grid-direction clustering
| Field | Default | Guidance |
|---|---|---|
num_bins | 90 | Histogram resolution (π / n per bin). Rarely adjusted. |
cluster_tol_deg | 12.0 | Per-axis absolute tolerance vs cluster centre for a corner to be labelled. Raise to 16 on noisy axes; tighter risks unclustering legitimate corners. |
peak_min_separation_deg | 60.0 | Minimum angle between the two returned peaks. Guards against twin-peak collisions. |
min_peak_weight_fraction | 0.02 | Fraction of total axis-vote weight a peak must carry. Lower on dense boards where each real peak only carries a few percent; higher rejects spurious noise peaks. |
Stage 5 — seed
Seed-finding tolerances are internal to the topological grid builder and
are not exposed as public tuning knobs. If seeding consistently fails, use
detect_chessboard_best with ChessboardParams::sweep_default() which
varies the upstream clustering and attachment tolerances.
Stage 6 — grow
| Field | Default | Guidance |
|---|---|---|
attach_search_rel | 0.35 | KD-tree search radius around each prediction (fraction of cell_size). Raise to 0.45–0.55 on images with noticeable perspective; tighter rejects more holes. |
attach_axis_tol_deg | 15.0 | Candidate’s axes must match both cluster centres within this tolerance. |
attach_ambiguity_factor | 1.5 | If the second-nearest candidate is within factor × nearest, attachment is skipped (the position is marked ambiguous). |
step_tol | 0.25 | Edge-length window at attachment ([1 − step_tol, 1 + step_tol] × s). |
edge_axis_tol_deg | 15.0 | Induced-edge axis alignment at attachment. |
Stage 7 — validate
| Field | Default | Guidance |
|---|---|---|
geometry_check_local_h_tol_rel | 0.20 | Local 4-point homography residual tolerance for the final geometry check. |
line_min_members | 3 | Minimum row/column length for a line fit to be attempted. |
Stage 8 — recall boosters
Per-stage toggle (an advanced knob): enable_weak_cluster_rescue
(default true). Leave it on unless the weak-cluster booster is producing
false positives for you. Line extrapolation, gap fill, and component merge
run unconditionally and are no longer configurable.
Output gates
min_labeled_corners and max_components are stable top-level fields.
| Field | Default | Guidance |
|---|---|---|
min_labeled_corners | 8 | Detection rejected below this labelled count. Raise for validation boards with an expected floor. |
max_components | 3 | Cap for detect_all. Raise if a scene legitimately fragments into more pieces of the same board (rare). |
Per-parameter reference: ScanDecodeConfig / ChArUco
These parameters live inside CharucoParams.
min_border_score
Default: 0.75 for ChArUco.
Guidance: Minimum contrast score for the black border ring around a marker. Lower
cautiously to 0.65 for very blurry images. Values below 0.60 risk accepting
non-marker regions.
multi_threshold
Default: true.
Guidance: When enabled, the decoder tries several Otsu-style binarization thresholds until a dictionary match is found. This handles uneven lighting and motion blur at the cost of a small speed penalty. Disable only when speed is critical and lighting is controlled.
inset_frac
Default: 0.06 for ChArUco.
Guidance: Fraction of the cell size inset from the cell boundary before sampling
the marker interior. Raise to 0.10–0.12 when the cell boundary visibly bleeds into
the bit area (common with thick printed borders or strong blur).
marker_size_rel
Source: Board specification — must match the printed board exactly.
Guidance: Ratio of the ArUco marker side to the chessboard square side. A mismatch here causes systematic decoding failures even when all other parameters are correct. Verify against the printed board or the JSON spec used to generate it.
Quick checklist
- Start with defaults; run with
RUST_LOG=debugto see corner counts and per-stage counters. - If no corners are found: loosen
min_corner_strength, check image resolution and contrast. - If corners found but no grid (
detect_chessboardreturnsErr(NoDetection)): runcalib_targets_chessboard::trace_topological(gated behind the chessboard crate’s off-by-defaultdiagnosticsfeature) — fewusablecorners means the prefilter / clustering is too tight (try loweringmin_corner_strengthor the advancedmin_peak_weight_fraction),Err(NoComponents)means the topological builder assembled no grid (trydetect_chessboard_best), and components found but a refused detection points at the final geometry check (inspectGeometryCheckTrace.dropped_*; try a wider config). See Troubleshooting for the full chain. - If grid found but no ChArUco markers: enable
multi_threshold, lowermin_border_score. - If alignment fails: verify board spec (rows, cols, dictionary,
marker_size_rel). - If you observe wrong
(i, j)labels, that’s a precision- contract bug — file an issue rather than tuning around it. The detector is engineered to drop corners before it labels them wrong.
See also: Troubleshooting for per-error checklists and the Chessboard Detector chapter for the full invariant stack.
Troubleshooting
This chapter maps each error variant to a diagnostic checklist. For parameter descriptions, see Tuning the Detector.
Reading the debug log
Enable debug logging before anything else:
RUST_LOG=debug cargo run --example detect_charuco -- testdata/small2.png
Or from code:
#![allow(unused)]
fn main() {
tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init();
}
Key log lines and what they tell you:
| Log line | Meaning |
|---|---|
input_corners=N | N ChESS corners passed the strength filter |
chessboard stage failed: ... | Grid assembly error; reason follows |
marker scan produced N detections | N cells decoded a valid marker ID |
alignment result: inliers=N | N markers matched the board spec |
cell (x,y) failed decode | That cell did not match any dictionary entry |
cell (x,y) passed threshold but no dict match | Binarization ok, wrong dictionary |
If you do not see these lines, confirm RUST_LOG=debug is set in the shell that runs
the binary, not in a parent process.
detect_chessboard reports no board
The facade detect_chessboard returns Err(DetectError::NoDetection) when
no board is found — a miss carries no specific cause, only that some stage
declined to emit a detection. To diagnose, run the chessboard crate’s serializable
topological trace, calib_targets_chessboard::trace_topological,
which is layered over the production path (so it reflects what detect()
actually does) and reports per-corner usability plus the labelled
components:
use calib_targets::detect::{default_chess_config, detect_corners};
use calib_targets_chessboard::{trace_topological, ChessboardParams};
let img: image::GrayImage = todo!();
let corners = detect_corners(&img, &default_chess_config());
match trace_topological(&corners, &ChessboardParams::default()) {
Ok(trace) => {
let usable = trace.corners.iter().filter(|c| c.usable).count();
println!("corners_in: {}", trace.diagnostics.corners_in);
println!("corners_used: {usable}");
println!("components: {}", trace.components.len());
println!("total labels: {}", trace.diagnostics.labels);
}
Err(e) => println!("topological stage produced no grid: {e}"),
}
trace_topological(and theTopologicalTracetype) are gated behind the chessboard crate’s off-by-defaultdiagnosticsfeature. Enable it withcalib-targets-chessboard’sdiagnosticsfeature — or the facade’sdiagnosticsfeature — to call it; the hotdetect_*path builds no trace.
Checklist:
-
No ChESS corners found?
corners.is_empty()(andtrace.diagnostics.corners_in == 0). The ChESS detector saw nothing — check image resolution / contrast; overridecalib_targets::detect::default_chess_config()with a customDetectorConfigif necessary — e.g.DetectorConfig::chess().with_threshold(8.0)to drop the noise floor below the workspace default of15.0. Since chess-corners 1.0 the threshold is a plain absolute response floor (f32); theThresholdenum and its relative mode are gone — ChESS reads only an absolute floor now. -
Corners found but few
usable? The prefilter is rejecting most corners — either onstrengthor because their axes are too uncertain to vote (max(σ₀, σ₁) > axis_align_tol_rad). Lowermin_corner_strength, and check the axis clustering tolerances (cluster_tol_degdefault12.0→ try16.0;min_peak_weight_fractiondefault0.02→ try0.01). A perfectly rectilinear board with axes on the π-wrap boundary is handled by plateau-aware peak picking. -
Usable corners but
Err(NoComponents)? The topological builder assembled no quad mesh. Trydetect_chessboard_bestwithChessboardParams::sweep_default()(widens the clustering and attachment tolerances). -
Components found but
detect_chessboardstill reports no board? The final geometry check refused the detection (survivors belowmin_labeled_corners) or only tiny components survived. Try a wider config viadetect_chessboard_best; if the scene legitimately holds multiple boards, usedetect_chessboard_alland handle each component separately. -
Multiple same-board components in the scene (ChArUco markers break contiguity): this is expected. Use
detect_chessboard_all; each piece comes back with its own locally-rebased(i, j).
NoMarkers
All ChESS corners were found and the chessboard grid was assembled, but no ArUco/AprilTag marker was decoded inside any cell.
Checklist:
-
Correct dictionary? The
dictionaryfield in the board spec must match the one used when printing. A mismatch producescell (x,y) passed threshold but no dict matchin the log for every cell. -
Correct
marker_size_rel? If the sampled region is the wrong fraction of the cell, the bit cells will be misaligned. Verify against the board spec. -
Blurry image?
- Enable
multi_threshold: true(already the default for ChArUco). - Lower
min_border_scoreto0.65–0.70.
- Enable
-
Uneven lighting?
multi_thresholdhandles this automatically. If already enabled, check whether the board surface has specular reflections — these cannot be corrected by thresholding alone. -
Wrong scale? If
px_per_squareis far from the actual pixel size, the projective warp used for cell sampling will produce a very small or very large patch. AdjustCharucoParams.px_per_square.
AlignmentFailed { inliers: N }
Markers were decoded, but fewer than min_marker_inliers of them matched the board
specification in a geometrically consistent way.
Checklist:
-
inliers = 0: No decoded marker ID appears in the board layout at all.- Board spec mismatch: wrong
rows,cols,dictionary, ormarker_layout. - Marker IDs may be correct but the layout offset is wrong (e.g. the board was
generated with a non-zero
first_markerid).
- Board spec mismatch: wrong
-
inlierssmall but non-zero:- Board is partially visible — lower
min_marker_inliersto the number of markers you reliably expect to see. - Strong perspective distortion — raise the chessboard-side attachment
tolerances (
attach_axis_tol_deg,edge_axis_tol_deg) so more corners enter the grid, or usedetect_charuco_bestwith a sweep.
- Board is partially visible — lower
-
inliersnear threshold:- One or two spurious decodings are pulling the fit off. Raise
min_border_scoreslightly to reject low-confidence markers.
- One or two spurious decodings are pulling the fit off. Raise
Common image problems
| Problem | Recommended fix |
|---|---|
| Strong blur | Lower min_border_score to 0.65, enable multi_threshold |
| Uneven / gradient lighting | multi_threshold (already default) |
| Strong perspective / wide-angle | Raise edge_axis_tol_deg / attach_axis_tol_deg / geometry_check_local_h_tol_rel on the chessboard side |
| Partial occlusion | Use detect_chessboard_all; for ChArUco, lower min_marker_inliers |
| Multiple same-board components | detect_chessboard_all; cap via max_components |
| Very small ChArUco board in frame | Raise CharucoParams.px_per_square to match actual square size |
| Specular reflections on board | Pre-process with local contrast normalisation (CLAHE); if pre-processing is off the table, lower min_peak_weight_fraction so clustering can cope with the reduced corner count |
Grid components found but detection returns Err(NoDetection) | Use detect_chessboard_best; inspect the trace_topological components and the final-check GeometryCheckTrace.dropped_* counters |
Getting more help
- Open an issue on GitHub
and attach the debug log (with
RUST_LOG=debug), image, and board spec. - See Tuning the Detector for full parameter reference.
- See Understanding Results for field meanings and score thresholds.
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 p | 8 mm | 10 mm | 15 mm | 20 mm | 25 mm | 30 mm |
|---|---|---|---|---|---|---|
| 12 | 31 | 38 | 57 | 76 | 95 | 115 |
| 18 | 46 | 57 | 86 | 115 | 143 | 172 |
| 24 | 61 | 76 | 115 | 153 | 191 | 229 |
| 30 | 76 | 95 | 143 | 191 | 239 | 286 |
| 36 | 92 | 115 | 172 | 229 | 286 | 344 |
| 42 | 107 | 134 | 201 | 267 | 334 | 401 |
| 48 | 122 | 153 | 229 | 306 | 382 | 458 |
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:
| period | largest piece | strip height | resulting ⌀ |
|---|---|---|---|
| 12 | 19 mm | 266 mm | 73 mm |
| 18 | 13 mm | 260 mm | 75 mm |
| 24 | 10 mm | 260 mm | 76 mm |
| 30 | 8 mm | 256 mm | 76 mm |
| 36 | 7 mm | 266 mm | 80 mm |
| 42 | 6 mm | 264 mm | 80 mm |
| 48 | 5 mm | 250 mm | 76 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.
Related
- Choose a target — whether a pole is the right answer at all.
- PuzzleBoard — the pattern a pole is cut from.
- Printing targets — the general printing guidance.
The Grid Model
Code:
projective-grid.
This is the foundational model every detector in the workspace shares.
Before you tune a chessboard, ChArUco, or PuzzleBoard detector it pays to
understand the layer beneath them all — the projective-grid crate. Given a
cloud of 2D feature points — optionally carrying one, two, or three local axis
directions per point — it recovers an (i, j) → point labelling: which integer
grid cell each feature occupies under perspective, together with a fitted
projective transform from model-plane coordinates to image pixels.
The input-feature kinds introduced here (plain points versus oriented features) and the recovery algorithm below are exactly the vocabulary the Tuning the Detector chapter’s parameters act on — read this first, and the tuning knobs stop being a flat list of names.
The crate is deliberately small and image-free. There are no
image, pixel-buffer, or camera types anywhere in its public surface,
and no target-specific identifiers (marker IDs, ring IDs, calibration
metadata). It is target-agnostic: the same lattice recovery serves
a chessboard detector, a laser-dot cloud, a scanned form, or a
photographed board game. The detection surface is single-precision
(f32); the standalone projective geometry kernel stays generic over
f32 / f64 via the Float trait. The other workspace detectors sit above this
crate — they run a corner detector, convert its output into generic
point or oriented features, and call in here for the labelling.
The crate ships independently on crates.io and is used directly for non-calibration tasks: rectifying a photograph of a board game, fitting a locally-planar lattice to a laser-dot cloud, extracting a grid from a scanned document, or building a new detector for a pattern the workspace doesn’t yet ship.
The crate root contains the ordinary detection facade. Detector builders use
the curated composition paths
projective_grid::expert::lattice::{predict_grid_position, GridTransform} and
projective_grid::expert::geometry::estimate_homography; Coord and
LatticeKind remain at the root. GridTransform is the single affine
integer-grid mapping (matrix * coordinate + translation) shared by lattice
symmetries and target alignments. It never acts on image pixels.
The model
Three small pieces define the public surface.
Two lattice families (LatticeKind). Square is the orthogonal
(i, j) grid and is detected by the topological algorithm. Hex (axial (q, r))
is also detected on the topological path: its triangles are the unit
cells directly, so there is no diagonal/quad-merge stage.
Two tasks.
- Detection —
detect_grid/detect_grid_all: recover labels from raw evidence when you do not know which feature is which cell. - Consistency —
check_consistency: you already have a proposed(i, j)label per feature (e.g. from a marker decode) and want to know whether those labels are geometrically consistent under a single projective fit. This is a separate entry point with its own request and report types; it does not go through theEvidenceenum.
Explicit evidence shapes (Evidence). Detection input is wrapped
in an enum that names exactly how much orientation the caller can
supply. The less-oriented square kinds synthesize the missing axes from
neighbour geometry up front and then run the same strategy:
| Variant | Payload | Square | Hex |
|---|---|---|---|
Positions | &[PointFeature] | ✅ synthesize 2 axes | ✅ synthesize 3 axes (topological) |
Oriented1 | &[OrientedFeature<1>] | ✅ synthesize 2nd axis | ❌ UnsupportedCombination |
Oriented2 | &[OrientedFeature<2>] | ✅ native (topological) | ❌ UnsupportedCombination |
Oriented3 | &[OrientedFeature<3>] | ❌ UnsupportedCombination | ✅ native (topological) |
Each feature carries a PointFeature (position + caller-owned
source_index) plus N undirected LocalAxis directions (N = 0 for
Positions). Any unsupported (lattice, evidence) combination — for
example (Square, Oriented3) or (Hex, Oriented1/Oriented2) — returns a typed
GridError::UnsupportedCombination { task, lattice, evidence }, never a
guessed answer.
Worked example
A fully self-contained, image-free example: synthesize a small 3×3
grid with a mild perspective shear, wrap the features as evidence,
detect, and read the recovered labels. This is the body of
examples/hello_grid.rs — run it with
cargo run -p projective-grid --example hello_grid.
use nalgebra::Point2;
use projective_grid::{
detect_grid, DetectionParams, DetectionRequest, Evidence, LatticeKind, LocalAxis,
OrientedFeature, PointFeature,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Build a 3x3 grid of oriented features. The `+ j * 6.0` term adds a
// mild perspective-style shear, so this is a genuine projective grid,
// not a perfectly axis-aligned one.
let mut features: Vec<OrientedFeature<2>> = Vec::new();
for j in 0..3 {
for i in 0..3 {
// Image-frame position: origin top-left, x right, y down.
let x = 60.0 + i as f32 * 40.0 + j as f32 * 6.0;
let y = 50.0 + j as f32 * 40.0;
// `source_index` is a stable caller-owned handle; the solution
// reports it back so you can map a label to the input feature.
let point = PointFeature::new(features.len(), Point2::new(x, y));
// Two roughly-orthogonal local axes: horizontal (0 rad) and
// vertical (pi/2 rad), each with a small angular sigma.
let axes = [
LocalAxis::new(0.0, Some(0.02)),
LocalAxis::new(std::f32::consts::FRAC_PI_2, Some(0.02)),
];
features.push(OrientedFeature::new(point, axes));
}
}
// Wrap as Oriented2 evidence and ask for a square lattice.
let request = DetectionRequest::new(
LatticeKind::Square,
Evidence::Oriented2(&features),
);
// `detect_grid` returns the largest recovered component.
let detection = detect_grid(request)?;
for entry in detection.grid().entries() {
// coord.u = i, coord.v = j; source_index maps back to the input.
println!(
"(i={}, j={}) <- feature {}",
entry.coord.u, entry.coord.v, entry.source_index
);
}
Ok(())
}
Running it labels all nine features (0,0) through (2,2) with a
sub-pixel fit residual. Two sibling examples under
crates/projective-grid/examples/ round out the surface:
detect_square_oriented2 (a larger detection run) and
check_square_consistency (the consistency task on pre-labelled
features).
The square algorithm: Topological
Detection of (Square, Oriented2) uses the topological grid finder — the
sole grid builder for all square targets. It is the Shu / Brunton / Fiala 2009
axis-driven grid finder: a Delaunay triangulation over the corner cloud whose
edges are classified by per-corner axis match, with triangle pairs merged into
cells and integer coordinates flooded across the mesh. Image-free; recovers
dense grids and copes well with distortion. May produce several components —
see detect_grid_all below.
There is only one square grid builder, so the request carries no algorithm
choice: what to detect is selected by the LatticeKind (Square / Hex)
and the Evidence shape on the DetectionRequest, not by an algorithm enum.
The historical single-variant SquareAlgorithm / GraphBuildAlgorithm seams
were removed once seed-and-grow was retired.
The algorithm shares the post-detection validation and projective fit across
all target types, recovering the full pattern with zero wrong labels. The
deep-dive — the axis-classification test, the triangle-to-cell merge, and the
line between the generic machinery here and the chessboard-specific wrapper —
is on the Topological grid finder algorithm page,
with the full stage-by-stage reference in
docs/algorithms/topological-grid-detection.md.
Hex also uses the topological algorithm. On a hex point lattice the
Delaunay triangles are the unit cells, so the diagonal/quad-merge stage is
bypassed; the axial (q, r) walk and the projective fit back-half are
otherwise shared with the square topological path. The fit residual is the
precision gate.
Single vs. multi-component results
detect_grid returns the largest recovered component as one
GridDetection. When the lattice is split into islands (for example by
occlusion) and the secondary components matter, call detect_grid_all:
it returns a Vec<GridDetection> ordered by labelled-count descending.
Inputs
Detection input is the Evidence enum (see The model above). For the
native Oriented2 shape each element is an OrientedFeature<2>:
point: PointFeature—position(image-frame pixel center) and a stable, caller-ownedsource_index. The solution reports thesource_indexback so a recovered label maps to the exact input.axes: [LocalAxis; 2]— two undirected local lattice directions, each anangle_radplus an optionalsigma_rad(angular uncertainty). Axes are undirected:θandθ + πdenote the same direction.
DetectionRequest::new(lattice, evidence) is the ordinary entry point.
Optional GridDimensions and stable parameters use the named
with_dimensions / with_params builders. Stage-specific controls live in
the opt-in projective_grid::expert::DetectionTuning namespace.
Outputs
A successful detection is a GridDetection:
| Field | Meaning |
|---|---|
grid() | Accepted entries, lattice family, inclusive bbox, and optional dimensions. |
fit() | Mandatory model-plane-to-image transform and residual summary. |
Each GridEntry carries:
coord: Coord— the(i, j)label ascoord.u/coord.v, rebased so the labelled bounding box starts at(0, 0).source_index: usize— back into the caller’s input slice.image_position: Point2<f32>— the feature’s image-frame pixel-center position.residual_px: Option<f32>— reprojection residual in pixels, present when a fit was computed.
Rejected features and exact stage traces are intentionally separate from the
ordinary result. Detector builders and evidence campaigns enable the
diagnostics feature and use projective_grid::diagnostics::detect_grid*.
Checking caller-supplied labels
When labels already exist — for instance after decoding marker IDs into
(i, j) coordinates — check_consistency scores them against a single
projective fit instead of recovering them from scratch. Build a
ConsistencyRequest::new(lattice, features, hypotheses, dimensions, params) from position-only PointFeatures and a parallel slice of
CoordinateHypothesis (each pairing a source_index with a proposed
Coord), with a ConsistencyParams whose max_residual_px sets the
acceptance threshold. The returned ConsistencyReport exposes passed(),
grid(), fit(), rejected(), and max_residual_px().
check_square_consistency in
the examples directory is the runnable version.
This is the only entry point that consumes coordinate hypotheses; detection’s
Evidence enum does not include an unsupported placeholder variant.
Conventions
- Coordinates. Image pixels: origin top-left, x right, y down. Grid
i(coord.u) increases right,j(coord.v) increases down. - Undirected axes. A
LocalAxisangle is undirected —θandθ + πare the same direction. Any circular mean over axis angles must therefore accumulate(cos 2θ, sin 2θ)and halve the resultingatan2; naive(cos θ, sin θ)averaging breaks at the 0°/180° seam. - Non-negative, top-left-origin labels. Output
(i, j)is rebased so the labelled bounding-box minimum is(0, 0). - Single precision. The detection surface is pinned to
f32. Only the standalone projective geometry kernel stays generic overF: Float, for a futuref64calibration consumer.
Out of scope
- 3D grids. Coordinates are 2D (
nalgebra::Point2); there is no 3D support. - Non-planar surfaces. The fit assumes a single planar homography maps the labelled set; severely curved surfaces are not modelled here.
- Feature detection. This crate does lattice recovery and projective
consistency, not corner finding. Bring your own points; if you have an
image, run a corner detector first and convert its output into
PointFeature/OrientedFeaturevalues before calling in. - Dense, unstructured point clouds. Pure noise without any local axis structure does not yield a usable Delaunay classification.
Learn more
API reference: projective-grid on docs.rs.
The topological grid finder has an in-repo deep-dive at
docs/algorithms/topological-grid-detection.md.
Understanding Detection Results
This chapter describes the shared corner vocabulary used by the result
types, explains when optional fields are populated, and gives guidance on
interpreting score values.
Shared TargetDetection
#![allow(unused)]
fn main() {
pub struct TargetDetection {
pub kind: TargetKind,
pub corners: Vec<LabeledCorner>,
}
}
TargetDetection is the shared carrier used by serialization, FFI, and
adapter methods such as target_detection(). The primary Rust, Python,
and WASM APIs return typed result objects with a top-level corners
field.
kind identifies the target type:
| Variant | Produced by |
|---|---|
TargetKind::Chessboard | ChessboardDetection::target_detection() |
TargetKind::Charuco | CharucoDetection::target_detection() |
TargetKind::PuzzleBoard | PuzzleBoardDetection::target_detection() |
TargetKind::CheckerboardMarker | MarkerBoardDetection::target_detection() |
In the shared carrier, corners is a Vec<LabeledCorner> ordered
differently per target type:
- Chessboard: row-major order (left-to-right, top-to-bottom by grid coordinates).
- ChArUco: ordered by ascending
id. - PuzzleBoard: ordered by detected grid traversal, with absolute master-grid
coordinates in
grid. - Marker board: ordered by grid coordinates
(i, j).
LabeledCorner fields
#![allow(unused)]
fn main() {
pub struct LabeledCorner {
pub position: [f32; 2],
pub grid: Option<[i32; 2]>,
pub id: Option<u32>,
pub target_position: Option<[f32; 2]>,
pub score: f32,
}
}
position
Pixel coordinates of the detected corner in the input image.
- Origin: top-left.
- X axis: right; Y axis: down.
- Sub-pixel accuracy; values are not rounded to integer pixels.
grid — (i, j)
Integer corner coordinates within the detected grid.
i= column index (increases right).j= row index (increases downward).- Origin: top-left corner of the detected region (not necessarily the top-left of the physical board — the detector does not know board orientation).
Always populated for chessboard and marker board detections. Populated for ChArUco when a board spec is provided (i.e., when alignment succeeds).
id
Logical marker corner ID. ChArUco only.
Each inner corner of a ChArUco board is shared by two squares and is assigned a unique
integer ID by the board specification. This ID is identical to the one used by OpenCV’s
aruco::CharucoBoard. For chessboard and marker board detections, id is always
None.
target_position
Real-world position of the corner in board units (typically millimeters when cell_size
is given in mm).
| Target type | When populated |
|---|---|
| Chessboard | Never (no physical size in ChessboardParams) |
| ChArUco | Always when board.cell_size > 0 and alignment succeeds |
| PuzzleBoard | Always when decode succeeds |
| Marker board | Only when board.cell_size > 0 and alignment succeeds |
Use target_position directly as the object-space point for camera calibration (pass
alongside the corresponding position as the image-space point).
score
A 0..1 quality score for this corner’s associated marker decode. Higher is better.
The score blends the border contrast of the surrounding marker border ring and a
Hamming penalty based on the number of bit errors when matching to the dictionary.
For chessboard corners (no marker), score reflects the ChESS corner response
strength, normalised to 0..1.
Interpretation:
| Score range | Meaning |
|---|---|
| ≥ 0.90 | High-confidence detection — use with confidence |
| 0.75–0.90 | Acceptable — watch for occasional false matches |
| < 0.75 | Treat with caution; upstream sampling may be poor |
Corners with score < min_border_score are filtered out before being returned, so
scores below that threshold will not appear in the output.
ChArUco-Specific: CharucoDetection
detect_charuco returns CharucoDetection rather than a bare TargetDetection:
#![allow(unused)]
fn main() {
pub struct CharucoDetection {
pub corners: Vec<CharucoCorner>,
pub markers: Vec<MarkerDetection>,
pub alignment: GridAlignment, // alias of the canonical GridTransform
}
}
markers
Raw list of decoded ArUco markers, one per cell that passed decoding. Each
MarkerDetection carries:
id: decoded dictionary ID.border_score: the contrast score for the border ring (maps toscoreinLabeledCornerfor marker-anchored corners).code: the raw decoded bit pattern (before dictionary lookup).rotation: 0/1/2/3 clockwise 90° rotations applied to normalise the marker.
alignment
When not None, carries the affine/homographic mapping between board-grid coordinates
and image-pixel coordinates. Populated when at least min_marker_inliers markers
agree on a consistent geometric transformation. Use this to project additional board
points into the image without re-running detection.
FAQ
Q: Why are grid coordinates not always the same as the printed board coordinates?
The detector builds the grid from scratch without knowing which corner is the
board’s physical origin. For plain chessboards and marker boards, the (0, 0)
origin is the top-left of the detected region in the image, not necessarily
the physical board corner. Use id (ChArUco or PuzzleBoard) or
target_position to obtain board-canonical positions.
Q: Can I use target_position directly for solvePnP?
Yes. Pair each LabeledCorner.position (image point) with the corresponding
LabeledCorner.target_position (object point) and pass them to solvePnP or your
calibration solver. Filter to corners where target_position.is_some() first.
Q: What is a normal score for a well-printed board under good lighting?
Typical values are 0.88–0.97. Scores consistently below 0.80 suggest image
blur, poor print quality, or an incorrect inset_frac.
Conventions
These conventions are used throughout the workspace. They are not optional and should not change silently.
Coordinate systems
- Image pixels: origin at top-left,
xincreases right,yincreases down. - Grid coordinates:
iincreases right,jincreases down. - Grid indices in detections are corner indices (intersections), not square indices, unless explicitly stated otherwise.
Homography and quad ordering
- Quad corner order is always TL, TR, BR, BL (clockwise).
- The ordering must match in both source and destination spaces.
- Never use self-crossing orders like TL, TR, BL, BR.
Sampling and pixel centers
- Warping and sampling should be consistent about pixel centers.
- When in doubt, treat sample locations as
(x + 0.5, y + 0.5)in pixel space.
Orientation angles
- ChESS-style corner orientations are in radians and defined modulo
pi(not2*pi). - Orientation clustering finds two dominant directions and assigns each corner to cluster 0 or 1, or marks it as an outlier.
Marker bit conventions
- Marker codes are packed in row-major order.
- Black pixels represent bit value 1.
- Border width is defined in whole cells (
border_bits).
If you introduce new algorithms or data structures, document any additional conventions in the relevant crate chapter.
Metrics
This page records the workspace’s measured recall, precision, and
performance characteristics. All concrete numbers below come from
public sources only — the checked-in testdata/ images, the
deterministic synthetic suites, and the criterion microbenchmarks.
Private real-world regression datasets are referenced qualitatively
only, per the project’s dataset-disclosure policy.
The numbers are indicative and drift with tuning; the binding contracts are the gates (the tests that fail on regression), not the exact figures. Re-generate them with the commands noted in each section.
Precision contract (all detectors)
The non-negotiable contract across every detector is zero wrong
(i, j) labels. A wrong label poisons downstream calibration and is
unrecoverable; a missing label is acceptable. Every recall figure
below is therefore reported alongside the standing zero-wrong-label
guarantee — recall may move with tuning, precision may not.
Chessboard / grid recall on public testdata
The public baseline (crates/calib-targets-bench/baselines/chessboard.json,
the default topological cell) labels 1834 corners across 15 public
images with zero position / id / duplicate diffs. Per-image labelled
counts:
| Image | Labelled corners |
|---|---|
testdata/large.png | 345 |
testdata/puzzleboard_reference/example1.png | 253 |
testdata/puzzleboard_reference/example2.png | 180 |
testdata/small2.png | 135 |
testdata/small0.png | 134 |
testdata/small3.png | 125 |
testdata/small4.png | 121 |
testdata/small5.png | 132 |
testdata/small1.png | 119 |
testdata/mid.png | 77 |
testdata/02-topo-grid/gptchess1.png | 60 |
testdata/02-topo-grid/GeminiChess1.png | 54 |
testdata/02-topo-grid/GeminiChess3.png | 42 |
testdata/02-topo-grid/GeminiChess2.png | 29 |
testdata/puzzleboard_reference/example3.png | 28 |
Reproduce with cargo run -p calib-targets-bench --release --bin bench -- check --dataset public. A passing run reports pos=0 id=0 dup=0 on
every image — the pos= counter validates positions of baseline
corners, not new labels (see the debugging guide), so new (i, j)
labels are gated separately by overlay inspection + the geometry checks.
Synthetic suites (projective-grid)
Two in-crate synthetic suites gate the precision contract on
deterministic, image-free fixtures (seeded LCG, no rand dependency):
- Square positions (
tests/detect_square_positions.rs) — perfect / perspective / outlier grids on both algorithms; headline assertion is full recovery of a perfect grid with zero wrong labels, plus a determinism assertion (identical output across runs). - Hex positions (
tests/detect_hex_positions.rs) — the hex regression gate (perfect / perspective / position-noise / dropouts / off-lattice-clutter / nativeOriented3/ D6-under-rotation / determinism). Recall floors are measured-minus-margin (e.g. ≥ 24 nodes under perspective and under noise, ≥ 15 with dropouts) and every case asserts zero wrong(q, r)labels modulo the 12 D6 automorphisms.
Run with cargo test -p projective-grid.
Performance (criterion, indicative)
cargo bench -p projective-grid --bench detect_grid measures the public
detect_grid_all entry on deterministic synthetic fixtures (a single
mild-perspective grid per cell). Indicative wall-clock times on the
reference dev machine (16×16 = 256-corner square, hex radius-6 =
127-node):
| Cell | Algorithm | Time |
|---|---|---|
square_oriented2 | topological | ~0.6 ms |
square_positions | topological (axis synthesis) | ~0.8 ms |
hex_positions | topological (axis synthesis) | ~0.19 ms |
These are perf-regression tracking numbers, not a benchmark of any competitor; absolute values depend heavily on hardware, corner count, and perspective.
The workspace also ships a puzzleboard-size criterion suite
(cargo bench -p calib-targets --bench puzzleboard_sizes).
Private real-world regression (qualitative)
Beyond the public surfaces above, the chessboard, ChArUco, and puzzleboard detectors are validated against private real-world regression sets as part of every change’s gate. These confirm the zero-wrong-label contract on real captured frames under perspective, foreshortening, and partial occlusion. Per the dataset-disclosure policy, no counts, filenames, or frame identifiers from those sets appear in this (or any) public document — the statement is qualitative: validated on private real-world regression sets at zero wrong labels.
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 eveni + jand 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_circlesreturned 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_relreachedcircle_diameter_rel— swallowed it. Regenerate any printed board that carries both. - A fully detected board no longer drops the id and
target_positionof its last row and column (13 of 48 corners on a 6x8). A circle layout names squares, so the resolved frame labelled corners1..=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, andtarget_positionmeasures from the board’s outer corner, matching the printable spec’sresolved_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 printableCharucoTargetSpecand the PythonCharucoBoardSpec. Defaults to 1;border_bits = 0is now rejected withCharucoBoardError::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 fullPrintableTargetDocument— page size, orientation, margin, and every spec field the fixed-arityrender_*_bundlehelpers do not name. Those helpers are unchanged; handing the new function the page one of them would have synthesised returns a byte-identical SVG.PrintableMarkerCircleSpecin the WASM TypeScript declarations. The printable model’s circle ({ i, j, polarity }) needs a name distinct from the detector’sMarkerCircleSpec({ cell, polarity }): two Rust crates may share a type name, one.d.tsmodule 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:
MarkerLayoutno longer offers"bottom_left". The Rust enum has exactly one variant, so that value was always rejected at the boundary.CharucoBoardSpecgainsborder_bitsandmarker_layoutbecomes optional, matching the serde shape.- The printable
circlesfield 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
| Algorithm | Crate / module | Role |
|---|---|---|
| ChESS corner detection | chess-corners (external front-end) | The feature-input contract: sub-pixel saddle position + two undirected local axes per corner. |
| Axis clustering | projective_grid::cluster | Recover the two global grid-direction centres {Θ₀, Θ₁} from per-corner dual axes. |
| Topological grid finder | projective_grid::topological | The sole grid builder: Delaunay → axis-driven edge classify → quad merge → flood-fill walk → component merge → lattice fit. |
| Recovery & validation | projective_grid::shared | Recall boosters (grow / fill / extension), the shared precision pass (drop_set), and grid-result normalization. |
| Homography & lattice fit | projective_grid::geometry | Normalized DLT projective fit; HomographyQuality as a diagnostic. |
| ArUco bit decode | calib-targets-aruco | Grid-aware bit sampling in rectified space, with explicit bit order / polarity / borderBits. |
| PuzzleBoard edge-code decode | calib-targets-puzzleboard | Decode interior edge-midpoint dots against the 501×501 master code. |
| ChArUco alignment & corner IDs | calib-targets-charuco | Grid-first + marker-anchored board alignment and absolute corner-ID assignment. |
How they relate
Every target detector starts from a cloud of ChESS corners, recovers the
two global grid directions by axis clustering, builds an integer
(i, j) lattice with the topological grid finder, repairs and proves
that lattice with recovery & validation (which uses homography &
lattice fit internally), and then — for self-identifying targets —
decodes target-specific marks (ArUco bits, PuzzleBoard edge
codes) and assigns absolute IDs (ChArUco alignment).
The first five blocks are target-agnostic and live in
projective-grid (image-free, no workspace dependencies) plus its
chessboard adapter; the last three are target-specific decoders.
Cross-cutting contracts
Two contracts hold across every block and shape the whole section:
- Precision is asymmetric. A missing
(i, j)label is acceptable; a wrong label is unrecoverable for downstream calibration. Every block that can attach a label runs an axis / parity / edge invariant, and the final precision pass can only drop, never add or relabel. - Corner orientation is axes-only. There is no single-orientation
field —
Corner::orientationwas removed workspace-wide. The only orientation signal isCorner.axes: [AxisEstimate; 2], two undirected local lattice directions. Any circular mean over axis angles MUST accumulate(cos 2θ, sin 2θ)and halve the resultingatan2; naive(cos θ, sin θ)averaging breaks at the 0°/180° seam.
ChESS corner detection
Front-end crate:
chess-corners(external). This page documents the feature-input contract the workspace builds on — not an algorithm this workspace re-implements.
Every detector in the workspace starts from a cloud of ChESS X-junction corners — the sub-pixel saddle points where four chessboard squares meet. Corner finding itself is out of scope here: the workspace consumes a corner cloud and recovers structure from it. What matters downstream is the precise shape of each corner, because the axis clustering and topological grid stages read it directly.
The per-corner contract
Each detected corner carries:
- Sub-pixel position — image-frame pixel coordinates, origin top-left, x right, y down. Not rounded to integer pixels.
- Two undirected local axes —
axes: [AxisEstimate; 2], the two orthogonal grid directions visible in the corner’s immediate neighbourhood. EachAxisEstimateis anangle ∈ [0, π)plus a 1σ angular uncertainty (sigma). - A quality scalar —
strength, the ChESS response magnitude.
Together these are exactly what the prefilter reads. A corner’s axes are
admitted into grid construction when strength ≥ min_corner_strength
and neither axis is more uncertain than the axis-alignment tolerance
the grid builder will test it against
(max(σ₀, σ₁) ≤ topological.axis_align_tol_rad) — see
the chessboard pipeline. Before 0.11.0 the
second half of that rule was a fit-residual ratio
(fit_rms ≤ max_fit_rms_ratio · contrast); contrast and fit_rms no
longer exist upstream, and the σ-based rule is the direct replacement.
In the workspace’s shared types this corner is
calib_targets_core::AxisEstimate carried on a detector-specific input
type (e.g. calib_targets_chessboard::ChessCorner).
Orientation is axes-only
This is the load-bearing contract for everything downstream:
- There is no single-orientation field.
Corner::orientationwas removed workspace-wide and must never be reintroduced. The only orientation signal is the two-axis pair. - The two axes are undirected: an angle
θandθ + πdenote the same direction, so all axis comparisons work modulo π. - The axes are stored in fixed slots (
axes[0],axes[1]). The slot ordering encodes a local parity that adjacent chessboard corners flip — the topological grid finder and the chessboard wrapper’s parity discipline both depend on it. - A default-constructed / no-information axis carries
sigma = π(the no-info sentinel) and is filtered out before it can vote.
Circular means over axis angles must therefore accumulate
(cos 2θ, sin 2θ) and halve the resulting atan2. Doubling the angle
wraps θ and θ + π onto the same point, so the mean is stable across
the 0°/180° seam; naive (cos θ, sin θ) averaging collapses to zero
when votes straddle the wrap.
Orientation modes (DiskFit / RingFit)
The upstream detector exposes two axis-fitting modes, both still selectable through the facade / Studio / bench:
RingFitorders the two axis slots consistently by construction.DiskFitcan uniformly pick the wrong antipodal dark sector, reversing a corner’s(axes[0], axes[1])slot ordering relative to the board. The chessboard pipeline detects and repairs this globally in its axis-clustering stage (the slot-coherence repair), so aDiskFitswap never breaks the parity invariant.
Why the workspace stops at “corners in”
Corner finding is image processing; lattice recovery is geometry. Keeping the boundary here lets the geometry crates stay image-free and reusable: anything that can supply oriented point features — a different corner detector, a blob detector with a local-orientation estimate, a laser-dot extractor — can drive the same grid recovery. See The Grid Model for the generic feature shapes.
Cross-references
- Axis clustering — the first consumer of the dual-axis signal.
- The Grid Model — the generic
OrientedFeatureshapes ChESS corners are adapted into. - Conventions — the coordinate / orientation conventions in full.
Axis clustering
Code:
projective_grid::cluster(cluster_axes,AxisClusterCenters,AxisAssignment), re-exported at the crate root.
Axis clustering recovers the two global grid-direction centres
{Θ₀, Θ₁} (≈ 90° apart) from a set of features that each carry two
undirected local lattice axes (e.g. ChESS corners).
It is the orientation-prior stage every grid pipeline runs before
building a lattice: the two centres are the only global axis hint handed
to the topological grid finder, and they are
reused later for booster recovery so the clustering runs once.
The module is pure direction-clustering math — no image types, no target vocabulary. Which features are eligible to vote, and how the canonical/swapped assignment maps onto a caller’s own label type, stays caller-side.
Input / output
- Input: a slice of
AxisFeature, each carrying its twoAxisObservations(angle, sigma)and a detectorstrength. Axes whosesigmais the no-info sentinel (≥ π) or non-finite are skipped; callers pre-filter to the features they want to vote (the chessboard passes only itsStrongcorners). - Output:
AxisClusterCenters { theta0, theta1 }in[0, π)withtheta0 ≤ theta1.- A per-feature
AxisAssignment—Canonical(axes[0] matches Θ₀),Swapped(axes[0] matches Θ₁), orNoCluster(neither axis is close enough to either centre).
The algorithm
- Circular histogram. Build a smoothed histogram on
[0, π)withnum_binsbins. For every feature and every axisk ∈ {0, 1}, add a vote atwrap_pi(axes[k].angle)weighted bystrength / (1 + axes[k].sigma)— stronger, more-certain axes vote harder. - Smoothing. Convolve with a
[1, 4, 6, 4, 1] / 16circular kernel so single-bin noise does not masquerade as a peak. - Plateau-aware peak picking. Find local maxima; keep peaks whose
total weight is at least
min_peak_weight_fraction × total; pick the two strongest peaks separated by at leastpeak_min_separation_rad. “Plateau-aware” matters for a perfectly rectilinear board whose two axes land exactly on histogram-bin boundaries — a naive argmax would split one true peak into two adjacent bins. - Double-angle 2-means refinement. Refine the two peak centres with
k-means (k = 2) in double-angle space — each axis angle is mapped
to
(cos 2θ, sin 2θ)before clustering, and the cluster means are halved back into[0, π). This is the same undirected-mean discipline the whole workspace uses; it makes the refinement stable across the 0°/180° seam.
Per-feature slot assignment
Once {Θ₀, Θ₁} are fixed, each feature is scored against the two
possible slot assignments:
- Canonical — cost
d(axes[0], Θ₀) + d(axes[1], Θ₁). - Swapped — cost
d(axes[0], Θ₁) + d(axes[1], Θ₀).
The cheaper assignment wins; a feature whose worse axis exceeds the
caller’s tolerance is labelled NoCluster and excluded from voting on
edges. All distances d are angular and computed modulo π.
Why double-angle, not naive circular mean
Axes are undirected, so a histogram vote at θ is equally a vote at
θ + π. A naive circular mean over raw (cos θ, sin θ) of two votes
180° apart sums to zero — the mean is undefined exactly where it
matters most. Doubling the angle folds θ and θ + π onto the same
point on the unit circle, so the mean is well-defined; halving the result
recovers the undirected direction. This contract is mandatory anywhere
the workspace averages axis angles.
Cross-references
- ChESS corner detection — the source of the
dual-axis votes, and the
DiskFitslot-flip the chessboard repairs after clustering. - Topological grid finder — the consumer of the two centres (as an optional per-corner usability gate).
- Recovery & validation — reuses the same
(features, centres)pair for booster recovery.
Topological grid finder
Code:
crates/projective-grid/src/topological/(reached throughdetect_grid_all, or through the curatedexpert::squarecomponent seam for pattern-specific builders). In-repo deep-dive:docs/algorithms/topological-grid-detection.md.
The topological grid finder is the sole grid builder in the
workspace. Given a cloud of oriented features (positions + two undirected
local axes each) and the two global grid directions from
axis clustering, it recovers an integer
(i, j) corner lattice without ever sampling the image again. Every
target type — chessboard, ChArUco, PuzzleBoard, marker board — routes
through this one path.
It is the Shu / Brunton / Fiala (2010) topological grid finder, with the paper’s image-color cell test replaced by an axis-alignment test so the core stays image-free and tolerant of perspective and radial distortion.
Historical note. An earlier
SeedAndGrowbuilder once coexisted with this one. It and the algorithm selector have been removed; there is no algorithm choice in the current public configuration.
Vocabulary
- Grid edge — a link between two corners that runs along the lattice (a true cell side).
- Diagonal edge — a link that crosses a cell corner-to-corner. Delaunay introduces one per cell; the pipeline identifies and removes it.
- Spurious edge — a link that is neither a cell side nor a cell diagonal (a triangulation artefact).
- Quad — four corners forming one lattice cell, bounded by four grid edges.
- Axis slot — each corner stores its two axes in fixed slots
(
axes[0],axes[1]); axes are undirected, compared modulo π.
Stages
The generic, image-free core runs these stages (full source under
crates/projective-grid/src/topological/):
- Axis cache + usability prefilter. Precompute each feature’s two
axis angles and an informative flag per slot (an axis is informative
when its
sigmais belowmax_axis_sigma_rad). A feature is usable when at least one slot is informative — and, if the optional cluster centres are supplied, when at least one informative axis lies withincluster_axis_tol_radof one global grid direction (modulo π). - Delaunay triangulation. Triangulate only the usable features to get a cheap, well-conditioned candidate-neighbour graph without committing to a prior cell size — important because cross-cluster nearest-neighbour distances are unreliable on boards with markers.
- Edge classification (Grid / Diagonal / Spurious). For each
Delaunay half-edge
a → b, compare the edge directionatan2(b − a)(modulo π) to each endpoint’s informative axes. The edge is a Grid edge when both endpoints see it withinaxis_align_tol_radof one of their own axes; otherwise it is provisionally Spurious. Diagonals are then promoted topologically: a triangle with exactly two Grid edges meeting at a shared vertex through different axis slots has its third edge promoted to Diagonal. Crucially, diagonals are not found by a fixedaxis ± π/4rule — under a projective warp a projected diagonal is not the angle bisector in image space. - Triangle-pair → quad merge. A triangle with exactly one Diagonal edge is fused with its neighbour across that diagonal; removing the shared diagonal yields a quadrilateral whose four edges are all Grid edges — one lattice cell, ordered clockwise (image y-down) from its top-left vertex.
- Quad filtering. Three gates: a topological mesh-degree gate
(drop junction artefacts with too many incident edges), an
opposing-edge ratio gate (reject extreme parallelograms), and a
per-component cell-size band (drop quads with any edge outside
[min, max] × component-median, computed per connected component so two boards at different scales coexist). - Topological walk (flood-fill). Each connected quad-mesh component
is labelled independently: a seed quad gets
(0,0),(1,0),(1,1),(0,1)clockwise, and labels propagate across shared edges. A component is dropped if two quads ever disagree on a corner’s label. Each component’s(i, j)bbox is rebased so its minimum is(0, 0). - Generic component merge. Disconnected walk patches are reunited in label space using local geometry and cell scale, without relying on a global homography.
- Public validation + projective fit, or detector-builder handoff. The
ordinary facade canonicalizes labels, runs pattern-agnostic line/local-H
validation, and fits the mandatory projective transform. Pattern-specific
builders may instead stop after component merge through
expert::square::assemble_oriented2_components; the chessboard takes this branch so parity/recovery see the original axis-slot frame and then applies its own mandatory geometry check. - Orchestration. Component detections are sorted by labelled-corner
count (ties broken by smallest source index, for determinism). Every
unplaced feature is collected into a global rejected/unlabelled set.
detect_gridreturns the largest component;detect_grid_allreturns all of them.
Hex lattices
The same algorithm serves a hexagonal point lattice: on a hex lattice the
Delaunay triangles are the unit cells, so the diagonal/quad-merge stage
is bypassed and the axial (q, r) walk runs directly. The projective-fit
back-half is shared with the square path.
Why axis alignment, not pixel colour
The paper decides what counts as a lattice edge by sampling the image between two corners and checking the light/dark cell pattern. Replacing that with an axis-alignment test makes the core image-free and distortion-tolerant: a grid edge is exactly the link both endpoints agree runs along one of their own local axes, and a cell diagonal is recognised by the local “two grid edges through different slots” rule rather than by any global angle. The classifier only checks that an edge aligns with some endpoint axis, not the parity-correct one — the chessboard wrapper adds parity discipline in recovery & validation.
Known limits
- Three-corner cells are not recovered as quads. The merge needs a complete cell (two triangles sharing a diagonal); one missing corner per cell starves the surrounding flood-fill. The downstream booster recovery fills single interior holes from local geometry.
- Delaunay is not projective-invariant. Severe perspective combined with radial distortion can make a Delaunay triangle span more than one physical cell, leaving cells the diagonal-inference rule cannot resolve.
- Axis quality is load-bearing. Every classification decision rests on per-corner axis estimates; low-resolution or noisy inputs can fail before the topology has enough reliable evidence.
- Marker-internal corners can poison the per-cell axis test. Because the classifier checks alignment with some endpoint axis, a corner detected inside a marker bit whose axes happen to match the grid directions can be admitted. The marker-bearing targets defend against this with a strength floor that cuts marker-internal saddles before the grid grows — see the ChArUco pipeline.
Cross-references
docs/algorithms/topological-grid-detection.md— the generic core in full, stage by stage, with the clean line betweenprojective-gridand the chessboard adapter.- Axis clustering — supplies the two global grid directions used by the usability prefilter.
- Recovery & validation — the chessboard-specific component merge, parity alignment, recall boosters, and mandatory precision pass that run after this core.
- The Grid Model — the public detection surface
(
Evidence,detect_grid/detect_grid_all,GridDetection).
Recovery & validation
Code:
projective_grid::shared(merge_components_local, thefill/grow/extensionboosters, thevalidateprecision pass, andLabelledGrid::normalize).
The topological grid finder stops at labelled
(i, j) components. Recovery & validation is the back half that turns
those raw components into a precision-safe, normalized grid. It does three
jobs, in this order:
- Recovery boosters — recover corners the topological walk missed.
- The precision pass (
drop_set) — prove the labelled set, dropping anything that slipped through. - Normalization — rebase, canonicalize, and sort the final grid.
These live in projective-grid so any lattice consumer can reuse them;
the chessboard wrapper sequences them and supplies the parity discipline.
Recovery boosters (recall, can only add)
The boosters extend a component to recover missed corners. Each addition re-runs the same axis / parity / edge-slot-swap invariants the topological walk uses, so a booster can only add a corner that would have been admitted had the walk reached it:
- Local component merge (
merge_components_local) — reunite disconnected components in label space using local geometry only (no global homography), so it tolerates radial distortion that would break a global fit. Run before and after the fill/extension boosters. - Interior gap fill + line extrapolation (
fill_grid_holes) — fill interior holes with ≥ 3 labelled neighbours, and extend each labelled row / column one corner at a time. A per-axis directional edge scale is used because a partially-grown component can be anisotropic before its boundaries fill in. - Extension (the
extensionsubmodule) — homography-based outward extension, global or per-candidate local-H. - Weak-cluster rescue (caller-driven) — re-admit
NoClustercorners within a loosened tolerance, with the full invariant stack still enforced.
Boosters are capped by an iteration limit to prevent unbounded growth.
The precision pass: drop_set
The precision pass is mandatory and subtractive — it can only drop or refuse, never add or relabel. A corner that survives it has been proven to sit at a real intersection. It composes these checks:
- Line collinearity. For every row (
j = const) and column (i = const) with enough members, fit a line in pixel space (a projective-line fit when there are enough members, to absorb mild lens distortion) and flag members whose perpendicular residual exceeds the tolerance. - Local-H residual. For every labelled corner with ≥ 4 non-collinear labelled neighbours, fit a 4-point local homography from the grid-closest neighbours, predict the corner’s pixel position, and flag a residual over tolerance. Tolerances can be step-aware: per-corner local step from finite differences, so foreshortened cells get a tighter pixel tolerance and radially-distorted cells a looser one.
- Topological wrong-label checks. Direct structural checks that catch mislabels the line/H residuals can miss: interior skipped-corner edges, duplicate-pixel labels, and a frontier line-spacing smoothness test. The frontier test is second-order and distortion-model-agnostic: under any smooth (C²) lens distortion the edge-length sequence along a grid line is a smooth function, so a kink in that sequence at the frontier is a false attachment, not a legitimately foreshortened corner — it is flagged regardless of the absolute edge length.
- Largest-component filter. Keep only the largest cardinally-connected component, dropping isolated leaks outside the main grid.
Why not a global smooth-warp residual gate. A natural-seeming addition would be to fit a single low-order
(i, j) → pixelwarp over the whole labelled set and drop high-residual corners. This was investigated and falsified: a global low-order fit extrapolates almost exactly through a false leaf one cell past the true board edge (giving it a tiny residual), while it fits the interior so tightly that legitimately barrel-distorted periphery corners get large residuals — the gate is simultaneously too loose and too tight. The discriminating signal for that false-positive class is the local second-order spacing kink, which a global fit averages away — hence the frontier line-spacing smoothness check above rather than a global-warp gate.
The attribution logic decides which flagged corner is the outlier (e.g. a corner flagged in ≥ 2 lines is the outlier; an isolated local-H flag with no supporting line evidence is deferred rather than dropped), so the pass blames the genuine intruder rather than its innocent neighbours. After updating the drop set the caller re-runs its seed/grow/validate loop, capped to prevent infinite cycling.
Why “can only subtract” is the whole contract. Wrong
(i, j)labels are unrecoverable for downstream calibration; missing corners are acceptable. A precision pass that could add a label could add a wrong one. By construction this pass never does — it is the last gate that makes a false positive impossible.
Normalization: LabelledGrid::normalize
Grid-result normalization is owned by projective_grid::LabelledGrid::normalize
— a single source of truth that target detectors call instead of
re-implementing it at their output stage. Three steps, in order:
- Rebase the coordinate bbox minimum to
(0, 0)so every label is non-negative (the hard non-negative-label invariant for overlay / calibration consumers). - Canonicalize orientation so the first lattice axis (
u) points roughly+x(right) and the second (v) roughly+y(down) in image pixels. The grid finder assigns(u, v)from its internal axis-slot convention, which has no relation to image orientation; this step decides the permutation / sign-flip from the averaged step vectors over adjacent labelled pairs. Positions are never modified — only labels are permuted. - Sort entries by
(v, u)for a stable output order, and recompute the bbox.
Because normalize permutes labels, any LatticeFit computed against the
pre-normalization labels is invalid afterwards — normalize before
fitting, or refit.
Cross-references
- Topological grid finder — produces the raw components this stage repairs and proves.
- Homography & lattice fit — the projective fit used inside the local-H residual check and the final fit.
- Axis clustering — the
(features, centres)pair reused by the boosters. - Chessboard pipeline — how the chessboard detector sequences merge → parity-align → boost → precision pass → normalize.
Homography & lattice fit
Code:
projective_grid::geometry(Homography,estimate_projective,homography_from_4pt,apply_projective,HomographyQuality).
A planar calibration target maps to the image through a single projective
transform (a homography) — up to lens distortion. Several stages need to
fit that transform: the recovery & validation
local-H residual check fits a 4-point homography per corner, and the final
lattice fit recovers the model-plane-to-image transform reported by
GridDetection::fit(). This page describes that fit.
The fit
- Normalized DLT.
estimate_projectivesolves for the homography fromN ≥ 4model→image point correspondences via the Direct Linear Transform with Hartley normalization — each point set is translated and scaled so its centroid is at the origin with unit average distance before the SVD, then the result is denormalized. Normalization is what keeps the linear system well-conditioned; an un-normalized DLT degrades badly when image coordinates are large. - Direct 4-point.
homography_from_4ptsolves the exact minimal-case transform from four correspondences — used for the local per-corner predictions where exactly four neighbours bracket a corner. - Mapping.
apply_projectivemaps a model-plane point through the fitted transform to image pixels (and is the prediction step in the validation residual checks).
The standalone geometry kernel stays generic over F: Float
(f32 / f64), so a future f64 calibration consumer can reuse it; the
detection surface itself is pinned to f32.
Residuals are the precision gate
The fit reports a ResidualSummary (count, mean_px, max_px). The
residual — the pixel distance between each labelled corner’s measured
position and its reprojection through the fit — is the precision gate:
corners whose residual exceeds max_residual_px are dropped and the
transform is refit once. A sub-pixel mean residual on a recovered grid is
the signal that the labelling is geometrically self-consistent.
HomographyQuality is diagnostic-only
HomographyQuality (returned by the *_with_quality estimators) reports
conditioning / fit-quality scalars for a fitted homography. It is a
diagnostic, not a scale-stable gate — its magnitudes are not normalized
to a scale-invariant range, so it is unsuitable as an accept/reject
threshold across images at different pixel scales. Use the per-corner
reprojection residual (which is scale-relative, measured in pixels
against the cell pitch) as the gate, and treat HomographyQuality as a
debugging aid only.
This mirrors the workspace-wide rule against first-order magnitude thresholds: prefer scale-relative or structural criteria. A raw conditioning number that “works on the data it was measured on” is exactly the kind of non-generalizable constant the precision contract avoids.
A note on distortion
A single homography assumes a planar target with no lens distortion. Real captures carry radial / tangential distortion, which a global fit cannot absorb. That is why the recovery stage prefers local geometry (per-cell local-H, local component merge) over a global fit wherever it can: local fits tolerate smooth distortion that a global homography rejects. The global fit is still computed and reported, but the distortion-tolerant precision checks are what protect the labels.
Cross-references
- Recovery & validation — the consumer of both the 4-point local-H predictions and the final fit.
- The Grid Model —
GridDetection::fit()and theLatticeFit/ResidualSummaryoutput shapes. - calib-targets-core — the core
Homography/RectifiedViewrectification helpers built on the same DLT.
ArUco bit decode
Code:
calib-targets-aruco(Dictionary,Matcher,ScanDecodeConfig,scan_decode_markers,scan_decode_markers_in_cells,decode_marker_in_cell).
ArUco bit decode reads a marker’s binary code out of an already-located chessboard cell and matches it against a dictionary. It is deliberately grid-aware, not generic contour/quad detection: the grid stage has already found where every cell is, so the decoder samples the expected cell in rectified space and reads bits on a regular grid. This sidesteps the quad-finding and perspective-recovery steps a standalone ArUco detector spends most of its time on.
Inputs
The decoder works on rectified cells where each chessboard square is
approximately px_per_square pixels and cell indices align with the
board grid. Two paths supply that:
- Rectified-grid scan (
scan_decode_markers) — build a single rectified image of the board, then scan a regular grid of cells. - Per-cell scan (
scan_decode_markers_in_cells) — pass a list of per-cell image quads and decode each cell directly, with no full-image warp. This is the path the ChArUco detector drives; the work is proportional to the number of valid cells and parallelises trivially.
Bit sampling model
Inside each candidate cell:
- The marker area is
marker_size_relof the square side (ChArUco uses< 1.0), with an extrainset_fracinset to keep the bit grid off a thick or blurred border. - Bits are sampled on a regular grid spanning the marker area.
- A per-marker Otsu threshold is computed from the sampled intensities, so the decode adapts to local lighting.
- The surrounding black border ring is scored; cells whose border
score is below
min_border_scoreare rejected before a dictionary lookup is attempted.
Explicit bit conventions
These three conventions are explicit in the code and must match the printed board exactly:
- Bit order — codes are packed row-major.
- Polarity — black = 1.
border_bits— the number of whole black border cells, matching the OpenCV definition (typically 1).
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
- ChArUco alignment & corner IDs — the consumer of decoded marker IDs.
- ChArUco pipeline — the end-to-end target detector.
- calib-targets-aruco crate and ArUco Decoding Details — the crate’s API surface and sampling knobs.
PuzzleBoard edge-code decode
Code:
calib-targets-puzzleboard—detector/edge_sampling.rs(reading the dots) anddetector/decode/(recovering the position). Based on Stelldinger 2024, arXiv:2409.20127.
A PuzzleBoard is a self-identifying chessboard: every interior edge carries a dot at its midpoint, and the pattern of dots is designed so that a fragment of the board identifies where on the board it is. Decode turns the dots a camera happened to see into an absolute position on a fixed 501 × 501 master pattern, so even a partial view yields absolute corner IDs and object-space coordinates — no need to see a corner of the board, no need for the whole target to be in frame.
This chapter is about the decoder specifically. The grid that feeds it comes from the chessboard pipeline; the end-to-end flow is in PuzzleBoard pipeline.
What is printed
Two cyclic binary maps are superposed on the board:
| map | shape | governs | lookup |
|---|---|---|---|
| A | 3 × 167 | vertical edges | map_a[mr mod 3][mc mod 167] |
| B | 167 × 3 | horizontal edges | map_b[mr mod 167][mc mod 3] |
A horizontal edge at master cell (mr, mc) is the edge between square
(mr, mc) and the square below it; a vertical edge is the edge between
(mr, mc) and the square to its right. Both maps tile cyclically, which is what
makes the 501 × 501 master storable in 126 bytes.
The dot colour is the bit, and the polarity is worth stating precisely because it is easy to get backwards:
bit 0 = black dot, bit 1 = white dot.
The dot sits on the boundary between a light and a dark square, so its visible half-moon always falls on the square of the opposite colour, and the intensity at the exact edge midpoint reads the dot directly. The sampler compares that intensity against bright/dark references taken from the two adjacent squares, which is what makes the read robust to vignetting and local exposure.
Why 3 × 167
Each map is a sub-perfect map: over its cyclic domain, all 501 of its 3 × 3 windows are pairwise distinct. Superposing the two makes every 4 × 4 window of squares — 3 × 4 horizontal bits plus 4 × 3 vertical ones, 24 bits in all — distinct across every one of the 501 × 501 master positions.
501 = 3 · 167 is not incidental. Because gcd(3, 167) = 1, a master row mr
is uniquely determined by the pair (mr mod 3, mr mod 167), and likewise for
columns. That is the Chinese Remainder Theorem, and the decoder leans on it
twice: once to store the pattern compactly, and once to avoid searching it. See
Code map construction for how the maps are
built and where the shipped ones come from.
What the decoder is given
Edge sampling emits one observation per interior edge it could read:
struct PuzzleBoardObservedEdge {
row: i32, // anchor corner, in the fragment's own frame
col: i32,
orientation: EdgeOrientation, // Horizontal | Vertical
bit: u8, // 0 = black, 1 = white
confidence: f32, // 0 = ambiguous, 1 = crisp
}
An observation is produced only when the corners bounding both adjacent squares were detected. That rule matters more than it looks: it means the fragment’s outermost corners host no dots, and therefore every lookup cell an observation refers to lies on the printed board. The fixed-board search depends on that.
Confidence is not decoration — it weights every score below, and bits under
min_bit_confidence (default 0.15) are dropped entirely rather than guessed.
The hypothesis space
A fragment knows neither where it sits on the master nor which way up it is: the camera may have seen the board rotated or mirrored. So a hypothesis is a pair — one of the 8 D4 transforms, and a master origin:
8 transforms × 501 rows × 501 columns = 2,008,008 hypotheses
Scoring one means predicting the bit at every observed edge and counting
agreement. Done naively that is 8 · 501² · N bit comparisons — for a
1200-edge fragment, 2.4 billion. The decoder does not do that.
Collapsing the search
Step 1: the score depends only on residues
Because the maps are cyclic, the predicted bit at an origin depends on that
origin only through mr mod 167, mr mod 3, mc mod 3, mc mod 167. There
are only 501 distinct horizontal classes and 501 vertical ones, and an origin’s
score splits into one term from each:
score(mr, mc) = H[mr mod 167][mc mod 3] + V[mr mod 3][mc mod 167]
So instead of scoring each origin against each observation, the decoder builds
the two tables once per transform — O(501 · N) — after which every origin
costs two lookups and an add. That is the single most important step:
O(8 · 501² · N) becomes O(8 · (501 · N + 501²)).
Step 2: CRT removes the 501² as well
The remaining origin walk is 2 M table reads. For the hard scorer it disappears
entirely. Since 501 = 3 · 167 with gcd(3, 167) = 1, the four residues are
mutually independent and each ranges over its full domain, so
argmax over origins of ( H[·] + V[·] ) = ( argmax over H , argmax over V )
and the winner is recovered from the two per-table argmaxes by CRT inversion:
mr = (334·va + 168·ha) mod 501. The scan drops to O(501).
The separation needs an integer key, and that is a real constraint rather
than an implementation detail. With integers, a table entry below the maximum is
at least one below it, so it provably cannot reach the maximum sum. With f32,
rounding breaks that implication: two origins built from different table values
can land on the same sum. The hard scorer ranks on an integer bit-match count
and separates safely; the soft scorer ranks on an f32 log-likelihood sum and
therefore keeps the 501² walk, stripped down to two reads and a compare per
origin.
A pathological all-tied input (an empty or degenerate observation set) can make the per-table argmax sets large; the hard path falls back to a direct table scan for the affected transform, so worst-case cost never exceeds the pre-CRT version.
Scoring: hard and soft
Both scorers consume the same tables.
HardWeighted ranks by (bits matched, summed confidence of matched bits),
lexicographically, and rejects anything whose bit-error rate exceeds
max_bit_error_rate (default 0.30). Integer-keyed, so it gets the CRT
collapse.
SoftLogLikelihood — the default — scores each bit as
log σ(±κ · confidence), clipped below by a per-bit floor so one
catastrophically wrong bit cannot dominate, and sums. A crisp bit contributes
strongly; a marginal one barely moves the score. This is materially better on
noisy or small fragments, and it is the same transfer function the ChArUco
board matcher uses. It additionally requires the winner to clear
alignment_min_margin over the runner-up.
The uniqueness gate
Winning is not enough. A decode is accepted only if
margin > k_winner
where margin = best_matched − runner_up_matched and
k_winner = edges_observed − best_matched is the winner’s own mismatch count.
Equivalently: the winner’s net score must strictly beat the runner-up’s
matched count.
This is parameter-free — it compares two counts — and it separates two failure modes that any single magnitude threshold conflates:
- A clean exact read has
k_winner = 0and passes at any margin ≥ 1, so the code’s exact-uniqueness design is honoured at any fragment size. - A noisy ambiguous read fails: if a wrong origin matches nearly as many
bits (small margin) while the winner itself mismatches many (large
k_winner), the winner is not meaningfully closer to a perfect read than its competitor, and the decode declines.
Declining is the right answer. A missing label costs a calibration nothing; a wrong absolute label is unrecoverable.
The runner-up is taken across all eight transforms, not just within the winning one, precisely because a fragment too small to break the board’s D4 symmetry must not be allowed to invent an orientation.
How big a fragment do you need?
The paper’s headline is that a 4 × 4 fragment is unique. That is true across
positions at a fixed orientation — and the decoder does not have a fixed
orientation. Over D4 × position, measured on clean, noise-free windows at
seven planted origins:
Mind the unit. This table sizes windows in squares, as the paper does.
min_window is a corner span, and a window of s × s squares spans
s + 1 corners — so the 6 × 6 row below is what min_window = 7 means.
Both columns are given to keep the two readings from being confused.
| window (squares) | corner span | edge bits | decoded | rejected as D4-aliased |
|---|---|---|---|---|
| 3 × 3 | 4 | 12 | 0 / 7 | 7 |
| 4 × 4 | 5 | 24 | 0 / 7 | 7 |
| 5 × 5 | 6 | 40 | 5 / 7 | 2 |
| 6 × 6 | 7 | 60 | 7 / 7 | 0 |
| 7 × 7 | 8 | 84 | 7 / 7 | 0 |
Every clean 4 × 4 window tested had a perfect alias under some other
transform. Clean uniqueness begins at 6 × 6 squares — a 7-corner span, which
research/puzzleboard-rings then verified exhaustively at all 251 001 master
positions under the four rotations.
Hence min_window = 7 by default, and the edge-count pre-filter that
accompanies it is required_edges(7) = 60 — the interior edge count of exactly
that span. (Before 0.13 the pre-filter read min_window as squares and demanded
84, one full ring more than the span gate intended, so fragments at exactly the
documented minimum were rejected before being decoded.)
The span gate is applied to the corner span on both axes, because a wide-but-short strip can meet an edge-count floor while carrying too little code distance on its thin axis.
Noise pushes the floor up: the code’s minimum Hamming distance is 1 at 4 × 4, so a single flipped bit can turn a fragment into a perfect read of a different location. A 300k-trial sweep over random origins and error patterns put the smallest window with zero false accepts at 7 × 7 squares — 84 edge bits — at both 30 % and 40 % bit-error rates.
That measurement predates the gates now standing between a noisy read and a
returned detection, and it is no longer what sets the floor. Window size is a
blunt instrument for the job: what actually rules out a false accept is the
uniqueness gate, a bounded-distance proof that the winner is the only codeword
within its error radius. Two additions make that proof carry the noise case at
a 7-corner span — the period-3 majority vote, which repairs a minority of
misread dots before any gate sees them, and the
PuzzleBoardDetectError::NotEnoughLogicalBits floor, which refuses the decode
when too few distinct bits survive for the proof to mean anything. Without that
second guard the proof stays formally valid while becoming vacuous, and
wrong-origin decodes reappear at high corruption.
Reproduce the table with:
cargo test --release -p calib-targets-puzzleboard --lib -- \
window_uniqueness_report --ignored --nocapture
Restricting the search to a declared board
When the caller knows which board they printed, PuzzleBoardSearchMode::FixedBoard
restricts the origin to that board’s rectangle. Because a printed board is a
sub-rectangle cut from the master, its bit at board cell (r, c) is exactly
the master bit at (origin + r, origin + c) — so this is the same scoring
problem, restricted, and it reuses the same class tables.
Restricting the origins also restricts the residue classes they can reach, so the precompute itself does less work. That is why declaring the board is cheaper than not declaring it rather than merely bounded — until the board grows past the maps’ 167-long period, where there is nothing left to restrict.
The scan considers only shifts under which every observation lands on the board. Because a dot is only sampled where the surrounding corners were detected, every observation does lie on the printed board, so a shift placing one outside it cannot describe the physical scene. Excluding those keeps each hypothesis at two table lookups, and keeps impossible placements out of the uniqueness gate where they could only ever suppress a correct decode.
Two guarantees follow: the decode cannot return a position the board does not cover, and any subset of the board decodes to the same master IDs a full view would — so fragments from different frames or different cameras stitch without further work.
Complexity
With N observed edges, w the observed window in squares, and L_r × L_c the
shift rectangle a declared board admits:
| stage | cost |
|---|---|
| Edge sampling | O(N · r²), r = dot sample radius |
| Class precompute, per transform | O(min(501, reachable classes) · N) |
| Origin scan — hard, full master | O(501) via CRT |
| Origin scan — soft, full master | O(501²) |
| Origin scan — fixed board | O(L_r · L_c) |
| Full master, hard | O(8 · 501 · N) |
| Full master, soft | O(8 · (501 · N + 501²)) |
| Fixed board | O(8 · (reachable · N + L_r · L_c)) |
Measured decode-only cost (synthetic observations, no image pipeline), in milliseconds:
| window | edges | full/hard | full/soft | fixed 25×25 | fixed 130×130 | fixed 501×501 |
|---|---|---|---|---|---|---|
| 7 × 7 | 84 | 0.33 | 2.19 | 0.08 | 0.73 | 3.94 |
| 13 × 13 | 312 | 2.09 | 3.90 | 0.20 | 1.88 | 5.23 |
| 25 × 25 | 1200 | 7.97 | 10.29 | 0.07 | 5.80 | 10.83 |
Reproduce with:
cargo test --release -p calib-targets-puzzleboard --lib -- \
decode_scaling_report --ignored --nocapture
Multiple components, and the origin conflict
A view can yield several disconnected grid components. Each decodes independently, and they are ranked by edges matched, then bit-error rate, then the scorer’s own tie-breaks.
If two well-supported components disagree on the master origin, that is an unrecoverable ambiguity — some part of the labelling must be wrong, and nothing in the image says which. The detector refuses the frame rather than picking one.
Cross-references
- Code map construction — how the two maps are built, why they work, and where the shipped ones come from.
- PuzzleBoard pipeline — the end-to-end detector.
- calib-targets-puzzleboard crate — API, search modes, printable targets.
- Topological grid finder — the grid whose interior edges this decoder samples.
PuzzleBoard code maps and registration
Code:
calib-targets-puzzleboard/src/code_maps.rs(the maps),tools/import_author_maps.rsandtools/generate_code_maps.rs(where they come from),detector/pipeline.rs(registration).
The decode chapter takes the master pattern as given. This chapter answers the two questions underneath it: how is a pattern with that uniqueness property constructed, and once decoded, how does an origin become an absolute corner ID and an object-space coordinate.
The construction
The requirement
We need a binary array whose every window is distinct — a perfect map, the two-dimensional analogue of a De Bruijn sequence. The PuzzleBoard needs two of them, one per edge orientation, and it needs them cyclic, so a board can be cut from anywhere on the master.
The paper’s choice is a (3, 167; 3, 3)₂ sub-perfect map: shape 3 × 167,
window 3 × 3, binary. “Sub-perfect” because a true perfect map of window 3 × 3
would hold all 2⁹ = 512 windows; this one holds the 501 that fit its domain,
and asks only that they be pairwise distinct.
The letter trick
Reading a 3 × 167 array by columns, each column is 3 bits — call it a letter
l ∈ {0..7} via l = b₀ | b₁≪1 | b₂≪2. The array becomes a length-167 string
over an 8-letter alphabet, and a 3 × 3 window becomes a 3-letter substring.
Now the cyclic requirement bites. The map has only 3 rows, so shifting the window down by one row wraps — and on letters, a row shift is the permutation
σ : (b₀, b₁, b₂) → (b₁, b₂, b₀)
σ has order 3, with fixed points 0 (all zero) and 7 (all one). So the
windows starting at the three row offsets of a given column position are
t, σ(t), σ²(t) — the σ-orbit of that triple.
For all 501 windows (167 column positions × 3 row shifts) to be distinct, two conditions suffice:
- No triple is a σ-fixed point — otherwise its three row shifts coincide, and three windows collapse into one.
- No two column positions share a σ-orbit — otherwise a window at one position equals a row-shifted window at the other.
The 512 possible triples split into 8 singleton orbits (the σ-fixed ones) and 168 orbits of size 3. We need 167 of them, one per column position. That budget — 167 needed out of 168 available — is what makes the search easy.
Finding a valid sequence
tools/generate_code_maps.rs does stochastic hill-climbing: start from a random
length-167 letter sequence; define the energy as the number of invalid column
triples (σ-fixed ones plus orbit duplicates); propose single-letter mutations
and accept those that lower it; restart on a local minimum. With one orbit of
slack it converges in milliseconds.
The companion paper (arXiv:2405.03309) gives a closed-form construction, but no reference implementation — local search is simply the cheaper route to the same contract.
Verification is independent of construction:
verify_cyclic_window_unique enumerates all 501 cyclic windows of a map and
asserts pairwise distinctness, and master_4x4_windows_unique does the
end-to-end check over all 501 × 501 master positions. Both run as ordinary unit
tests.
Provenance of the shipped maps — and why it matters
The maps in src/data/ are not generated. They are imported.
tools/import_author_maps.rs takes the reference implementation’s arrays from
PStelldinger/PuzzleBoard (CC0 1.0): map_a is the author’s code1
verbatim, and map_b is rot90(code2[::-1, ::-1]), which re-expresses code2
so its fundamental period is stored as 167 × 3. src/data/map_metadata.json
records the import, the derivation, and the packing.
This is a deliberate interoperability decision. A board printed from the reference tooling decodes here, and a board printed from this crate decodes with the reference Python decoder — because both sides agree on the same code.
A regenerated pair satisfies exactly the same uniqueness property and is, in every mathematical sense, just as good. It is also a different code: boards printed with it will not decode against the reference implementation. The generator is a research and validation tool, not the shipping path. If you run it, you have forked the pattern.
Registration: from origin to absolute IDs
Decode returns a GridAlignment — a D4 transform plus a translation — that maps
the fragment’s local corner coordinates onto the master. Turning that into
usable output takes three steps.
1. Apply the alignment
For each labelled corner at local (u, v):
(raw_i, raw_j) = alignment.apply(u, v)
2. Wrap into the master
wrap_master reduces both coordinates into [0, 501). This is not
housekeeping — it is load-bearing. Four of the eight D4 transforms have negative
entries on the diagonal, so a corner far from the origin can map to a negative
raw coordinate. A negative value still yields the correct id (the ID
computation reduces modulo 501 anyway) but the wrong target_position, since
that one is a plain multiplication by the cell size. Wrapping first keeps both
consistent:
let (master_i, master_j) = wrap_master(raw_i, raw_j);
3. Emit the ID and the object-space point
id = master_j · 501 + master_i
target_position = (master_i · cell_size, master_j · cell_size)
which preserves the invariant a consumer can rely on:
target_position.x == (id % 501) · cell_size
target_position.y == (id / 501) · cell_size
id is what makes multi-view calibration work without correspondence search:
two cameras that see overlapping parts of the board report the same id for
the same physical corner, so their observations join on it directly.
Origin conflicts
A view can produce several disconnected grid components, each decoding
independently. If two well-supported components disagree on the master origin —
compared modulo the cyclic master by origins_conflict — then at least one
labelling is wrong and the image contains no evidence of which. The detector
refuses the frame rather than emitting a plausible-looking mixture.
Per-view origin drift is expected
The master origin reported for a view depends on which print-corner the
chessboard stage happened to call local (0, 0), which depends on what the
camera framed. Two views of the same board routinely report different origins.
That is not an inconsistency: after registration both produce the same
absolute IDs for the same physical corners, which is the only thing downstream
consumers should compare.
Cross-references
- PuzzleBoard edge-code decode — how the origin is recovered in the first place.
- PuzzleBoard pipeline — the end-to-end detector.
- calib-targets-puzzleboard crate — API and printable targets.
ChArUco alignment & corner IDs
Code:
calib-targets-charuco(the marker matcher + alignment + corner mapping stages). Layout-compatible with OpenCV’s aruco/charuco.
A ChArUco board carries an ArUco marker in every white square. Alignment recovers the board→image transform from the sampled marker cells and then assigns each chessboard inner corner its absolute, OpenCV-compatible corner ID. This is what makes ChArUco robust to partial views: even a handful of identifiable markers anchor the whole detected grid to the board’s canonical frame.
Inputs
- The labelled chessboard grid from the topological grid finder (one or more components).
- Per-cell sampled marker bits (the candidate marker cells extracted from the grid).
- The board specification (
CharucoBoardSpec): rows, cols, dictionary, marker layout.
Alignment — the board-level matcher
The detector uses a single, board-level matcher that chooses the whole
board placement jointly rather than decoding each cell independently and
then voting. It solves one question: which board placement — a D4 rotation
together with an integer (Δcol, Δrow) translation on the grid — is most
consistent with all the sampled cells at once.
-
Per-cell × per-marker score matrix. Each candidate cell is sampled into a small bit grid. For every board marker
m(and each of its four rotations) the matcher accumulates a soft-bit log-likelihoodΣ_bits max(log_sigmoid(κ · sign · (otsu − mean)/255), per_bit_floor), wheresign = ±1is the marker’s expected bit andκ(bit_likelihood_slope) sets the per-bit confidence. Theper_bit_floorclip stops a single wildly-wrong bit from dominating a cell’s score. Each cell also gets a weight, attenuated toward zero when its border did not read as black (cell_weight_border_threshold). -
Hypothesis enumeration. For each of the four D4 rotations the matcher maps the observed cells onto the board and enumerates exactly the integer translations that keep every cell inside the board. Each
(rotation, translation)hypothesis scoresΣᵢ wᵢ · sᵢ(m_{p_i(H)})— the weighted score of the marker each cell would contain under that placement. A hypothesis with no contributing cells is rejected (it would otherwise score zero and beat genuine negative-log-likelihood evidence). -
Maximum-likelihood placement + margin gate. The matcher keeps the best and runner-up hypotheses and computes the relative margin
(best − runner-up)/max(|best|, |runner-up|). The placement is accepted only when that margin clearsalignment_min_margin; below it, detection is rejected rather than mislabelled — heavy bit noise or a near-symmetric layout produces a near-tie, and a coin-flip alignment would risk a wrong ID. -
Constrained re-emit. Under the chosen placement every cell has a single expected marker. The matcher re-emits each cell’s marker under that identity, so a returned marker can never disagree with the alignment it was matched against — the wrong-id count is zero by construction.
The brute-force hypothesis space is tiny (four rotations × a bounded translation window), so the joint search is cheap. Because the decision is made over all cells jointly, the matcher tolerates per-cell bit noise that would defeat an independent hard decode, which is what lets it recover blurred, tiny-marker, and large-board frames.
The accepted alignment must still clear the downstream inlier floors
min_marker_inliers (primary component) / min_secondary_marker_inliers
(non-primary components); because the margin gate already does the real
accept/reject work, for_board keeps these floors low.
Corner-ID assignment
With the alignment fixed, each board-spec inner-corner position is mapped through the transform into the image and matched to a detected chessboard corner. Only inner-cell intersections receive IDs — the marker corners themselves are not emitted. Each emitted corner carries:
- its absolute ChArUco
id(identical to OpenCV’sCharucoBoardnumbering), - a
target_positionin board units (mm whencell_size > 0), - the sub-pixel
positionfrom the chessboard stage.
A final corner validation pass checks each detected corner against its
marker-predicted seed; a corner that deviates beyond
corner_validation_threshold_rel × px_per_square triggers a
marker-constrained redetection or is dropped. This is the marker-aware
half of ChArUco’s precision: the chessboard layer already guarantees no
wrong (i, j) labels, and marker-ID consistency guards the ID assignment
on top.
Why marker-anchored, not grid-only
A bare chessboard grid has no canonical origin — the detector does not
know which physical corner is (0, 0). The markers break that symmetry:
because each marker ID is unique and tied to a known board cell, even a
partial view recovers the absolute board frame, so corners from
different frames or cameras share IDs without manual stitching. This is
the same anchoring idea the marker board achieves
with three reference circles, but with per-cell IDs instead of a 3-point
pose.
Cross-references
- ArUco bit decode — supplies the decoded marker IDs and confidences.
- ChArUco pipeline — the full end-to-end stage map.
- calib-targets-charuco crate and ChArUco Alignment and Refinement — the crate’s API and refinement pass.
Pipelines
This section documents each target’s complete, end-to-end detection pipeline — one page per target type. Where the Algorithms section describes each building block in isolation, a pipeline page shows how a particular target composes those blocks from a grayscale image (or a pre-detected corner cloud) to a labelled, ID-carrying detection.
Each pipeline page narrates and links the canonical stage map that
lives next to the code. The crate-level docs/PIPELINE.md files are the
source of truth; these pages mirror them and must not diverge.
The shared front-end
Every detector shares the same first three steps:
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ Image │ -> │ ChESS │ -> │ Target- │ -> │ Labelled │
│ (u8 gray) │ │ corners │ │ specific │ │ grid out │
└───────────┘ │ (front- │ │ detector │ │ │
│ end) │ │ │ │ │
└───────────┘ └───────────┘ └───────────┘
- Input image —
image::GrayImageor aGrayImageView. The facade helpers incalib_targets::detectaccept either. - Corner front-end — the ChESS X-junction
detector via the
chess-cornerscrate produces a raw corner cloud (sub-pixel position + two undirected axes, each with a 1σ angular uncertainty + astrengthresponse magnitude). The workspace default iscalib_targets::detect::default_chess_config(). - Grid recovery — every target then runs the same grid stack: axis clustering → the topological grid finder → recovery & validation. This is the chessboard pipeline, and it is the shared spine of all the others.
- Target-specific decode + IDs — self-identifying targets add their own decoder (ArUco bits, PuzzleBoard edge codes) and ID assignment on top of the recovered grid.
- Output — every detector produces a
TargetDetectionwrapping aVec<LabeledCorner>; higher-level detectors wrap that in their own result struct with extra metadata (marker decodes, alignment, IDs). See Understanding Results.
The pages
| Pipeline | Composes | Source of truth |
|---|---|---|
| Regular grid | clustering + topological grid + validation | docs/algorithms/topological-grid-detection.md |
| Chessboard | the full grid stack, precision-anchored | crates/calib-targets-chessboard/docs/PIPELINE.md |
| PuzzleBoard | chessboard grid + edge-code decode | crates/calib-targets-puzzleboard/docs/PIPELINE.md |
| ChArUco | chessboard grid + ArUco decode + alignment | crates/calib-targets-charuco/docs/PIPELINE.md |
| Marker board | chessboard grid + 3-circle anchoring | crates/calib-targets-marker/docs/PIPELINE.md |
One builder, everywhere
There is no grid-builder choice or selector to configure: the topological grid finder is the sole builder for every target, including ChArUco. Legacy config keys for removed builder selectors are rejected as unknown fields.
Output types
Output types are standardised in calib-targets-core as TargetDetection
with LabeledCorner values. The chessboard layer’s labelling carries the
precision contract every target inherits: wrong (i, j) labels are
unrecoverable for downstream calibration, so the grid stage may fail to
detect a corner but must never deliver a wrong label. Higher-level crates
enrich that output with additional metadata (marker detections, rectified
views, per-corner IDs).
Regular grid pipeline
Composes: axis clustering → topological grid finder → recovery & validation. Source of truth:
docs/algorithms/topological-grid-detection.md. Public surface: The Grid Model.
The regular grid pipeline is the target-free end-to-end path: a cloud
of oriented point features in, a labelled (i, j) lattice out, with no
image and no calibration vocabulary. It is the standalone
projective-grid crate, and it is the spine every
target detector builds on. Reach for it directly when you have a grid that
is not one of the workspace’s named targets — a laser-dot cloud, a
scanned form, a photographed board game.
End-to-end stages
OrientedFeature<2>[] (positions + two undirected axes each)
→ axis clustering recover global directions {Θ₀, Θ₁} (optional hint)
→ topological grid Delaunay → classify → quads → walk (the builder)
→ validation + fit line / local-H / residual gate
→ GridDetection labelled (i, j) component(s) + projective fit
- Axis clustering (optional). If the caller can supply the two global
grid directions, they gate the topological usability prefilter. For the
bare
projective-gridentry points the caller may skip this and let the detector synthesize axes from neighbour geometry. - Topological grid finder. The sole builder: Delaunay triangulation →
axis-driven edge classification → triangle-pair → quad merge →
flood-fill
(i, j)walk → orchestration into components. See the algorithm page for each stage. - Per-component validation + projective fit. A pattern-agnostic
geometry gate (line collinearity, local-H residual, edge-length band)
plus a projective fit with a
max_residual_pxgate. For the bare grid crate this stage is active (it is the precision gate); the chessboard wrapper disables it and substitutes its own mandatory geometry check. - Output. A
GridDetectionper component: an acceptedLabelledGridplus a mandatoryLatticeFit. Rejections and stage snapshots are an opt-in diagnostics contract.
Public surface
The detection input is the Evidence enum — it names exactly how much
orientation the caller can supply (Positions, Oriented1, Oriented2,
Oriented3). The native square shape is Oriented2; less-oriented kinds
synthesize the missing axes up front. detect_grid returns the largest
component; detect_grid_all returns all of them. A separate
check_consistency entry point scores pre-labelled features against a
single projective fit. The full surface — DetectionRequest,
GridDetection, the diagnostics seam, and the worked example — is documented
in The Grid Model and the
Regular Grid Detection example.
Hex lattices
The same pipeline detects a hexagonal point lattice on the topological
path: the Delaunay triangles are the unit cells, so the
diagonal/quad-merge stage is bypassed and the axial (q, r) walk runs
directly, with the projective-fit back-half shared.
Failure modes
| Symptom | Likely stage | What it means |
|---|---|---|
GridError::InsufficientEvidence | input | Too few features to assemble a 2×2 seed cell. |
GridError::DegenerateGeometry | input | Coincident or collinear points; no usable lattice spread. |
GridError::UnsupportedCombination | dispatch | The (lattice, evidence) pair has no algorithm (e.g. Hex + Oriented1). Returned rather than guessed. |
Few entries, many Unlabelled rejects | topological walk | Noisy or low-resolution axes — the classifier could not build enough confident grid edges. |
ValidationDropped rejects | validation | Placed by the walk but failed line / local-H / edge-band; a gross mislabel was caught. |
ResidualTooHigh rejects | fit | Reprojection residual over max_residual_px; loosen only if the geometry is genuinely distorted. |
Tuning
For ordinary callers, DetectionParams intentionally has one stable knob:
max_residual_px— the fit residual gate. Raise on genuinely distorted captures; it is the precision lever, so prefer the smallest value that still recovers the grid.
Stage-specific topological, validation, and recovery controls are available
only by attaching projective_grid::expert::DetectionTuning. This keeps the
default configuration compact while preserving an explicit seam for detector
builders and reproducible evidence campaigns.
When this pipeline runs inside a target detector, these knobs are mostly set by the wrapper (the chessboard wrapper disables the validate stage and owns its own checks); see Tuning the Detector.
Cross-references
- The Grid Model — the full public surface and a worked example.
- Chessboard pipeline — the same spine with the chessboard precision discipline layered on.
docs/algorithms/topological-grid-detection.md— the generic core in full.
Chessboard pipeline
Composes the full grid stack: ChESS corners → axis clustering → topological grid → recovery & validation. Source of truth:
crates/calib-targets-chessboard/docs/PIPELINE.md. Crate reference: The Chessboard Detector.
The chessboard detector takes a cloud of ChESS X-junction
corners and produces an integer-labelled grid
(i, j) → image position. It is the shared spine of every other
target pipeline, and it is precision-anchored: every stage that can
attach a label runs an axis / parity / edge invariant, and the mandatory
final geometry check drops anything that slipped through. Wrong (i, j)
labels are unrecoverable for downstream calibration; missing corners are
acceptable — that asymmetry is the whole contract.
The six stages
The orchestrator is pipeline::detect_all_topological. The canonical
stage map (mirror of the crate docs/PIPELINE.md):
| # | Stage | In → Out | What it does |
|---|---|---|---|
| 1 | prefilter | ChESS corners → usable-flagged corners | Keep a corner iff strength ≥ min_corner_strength and max(σ₀, σ₁) ≤ topological.axis_align_tol_rad. Weak corners are kept as positions with no-information axes (so indices stay stable) but cannot vote. |
| 2 | cluster_axes | strong corners’ axes → {Θ₀ ≤ Θ₁} + per-corner slot label | The generic axis clustering: histogram + plateau peak picking + double-angle 2-means. |
| 3 | topological_grid | oriented features + cluster centres → labelled components | The topological grid finder through expert::square::assemble_oriented2_components; this builder seam stops after generic component merge and preserves the axis-slot frame for chessboard parity/recovery. |
| 4 | recover_components | merged components → boosted, re-merged grid | Per-component cell-size estimate, then the recovery boosters (interior gap fill + line extrapolation with a per-axis directional edge scale), optional weak-cluster rescue, then merge_components_local. Every addition re-runs the axis / parity / edge-slot-swap invariants. |
| 5 | final_geometry_check | labelled set → drop list + refuse flag | Mandatory, can only DROP. The shared drop_set precision pass: line collinearity + local-H residual + the topological wrong-label checks (skipped-corner edges, duplicate-pixel labels, frontier line-spacing smoothness) + the largest-component filter. Refuses if survivors < min_labeled_corners. |
| 6 | output | surviving set → ChessboardDetection | Build a LabelledGrid and call normalize() (rebase min → (0, 0); canonicalise +u ≈ +x, +v ≈ +y; stable (v, u) sort). The lattice Coord{u,v} is the canonical grid-coordinate type, so it is copied straight onto each output corner. |
Note on the output shape.
ChessboardDetectionisCoord{u,v}-based; what moved is where normalization lives — the rebase + canonicalise + sort algorithm is now owned byprojective_grid::LabelledGrid::normalize, with the output stage merely calling it.
Why the prefilter reads σ (Stage 1)
The axis half of the prefilter is derived, not configured: it reuses
advanced.topological.axis_align_tol_rad, the tolerance the grid builder’s
cell test applies to those same axes. The argument is dimensional rather than
empirical — an axis estimate whose own 1σ uncertainty exceeds the alignment
window cannot answer the question the cell test asks of it, so letting it vote
adds noise to the classification, not information. Because the gate is the
tolerance it protects, the two cannot drift apart, and there is no separate
constant to tune.
This replaces the pre-0.11.0 fit-residual ratio
(fit_rms ≤ max_fit_rms_ratio · contrast), which is not expressible on
chess-corners 1.0 — contrast and fit_rms no longer exist. Note that the
builder’s own max_axis_sigma_rad is a looser backstop applied inside grid
construction and is unrelated to admission.
Key invariants
These hold across every stage that can attach a label, and are what make a miss recoverable but a false positive impossible:
- Two grid directions. Clustering recovers
{Θ₀, Θ₁}(≈ 90° apart) as the only global axis prior. All axis means use the undirected(cos 2θ, sin 2θ)accumulation — there is noCorner::orientation, onlyCorner.axes: [AxisEstimate; 2]. - Parity / edge-slot-swap. A corner’s four cardinal neighbours sit at the opposite axis-slot parity by construction. Every attachment checks that the candidate edge crosses a slot-swap boundary, so a diagonal or skipped-corner attachment is rejected structurally, not by a magnitude threshold.
- Geometry check can only subtract. Stage 5 never adds or relabels; a corner that survives every stage has been proven to sit at a real intersection.
- Non-negative labels. Output rebases the labelled bbox minimum to
(0, 0).
Multi-component dispatch
ChessboardDetector::detect_all is the multi-board entry point: it returns several
ChessboardDetections (up to max_components) when one image contains
physically distinct grids. Within a single image, the topological facade
already merges connected components, so a single physical board split into
disjoint sub-grids (e.g. ChArUco rows separated by markers) is reunited in
label space by the Stage-4 merge. The precision contract holds per emitted
component. The workspace explicitly does not support multiple separate
physical boards in one frame.
Failure modes
Identify the stage from the serializable topological trace
(pipeline::trace_topological, layered over the production path) and the
final-check GeometryCheckTrace drop counters, then consult:
| Symptom | Likely stage | Knob to try | Notes |
|---|---|---|---|
| No detection, no grid directions | Stage 2 (clustering) | min_peak_weight_fraction, peak_min_separation_deg | The two grid axes never separated — common on very-bad-light frames. |
| No cell size / no seed | Stage 3 (topological) | detect_chessboard_best with sweep_default() | No quad assembled. Builder tolerances are internal. |
| Very few corners | Stage 4 (recover) | attach_search_rel, attach_axis_tol_deg, step_tol, edge_axis_tol_deg | Grid grew but couldn’t extend — common on heavily distorted views. |
| Many dropped corners | Stage 5 (geometry check) | geometry_check_local_h_tol_rel | Invariants found outliers; check the drop reasons. |
Wrong (i, j) labels | never | — | File a bug. The precision contract has been violated; do not tune around it. |
Tuning
ChessboardParams splits into a stable core (min_labeled_corners, max_components,
min_corner_strength) plus an opt-in, non-semver advanced
(ChessboardAdvancedTuning) block of per-stage knobs. Leave advanced unset unless
a specific input fails and you have evidence for the change. For
challenging images use detect_chessboard_best with
ChessboardParams::sweep_default() (three configs varying only
recall-affecting tolerances; all preserve the precision invariants). The
full knob table is in Tuning the Detector and the
chessboard crate chapter.
Cross-references
- The Chessboard Detector — the full invariant stack, the topological-trace diagnostics surface, and a quickstart.
crates/calib-targets-chessboard/docs/PIPELINE.md— the canonical stage map this page mirrors.- The downstream pipelines that build on this spine: PuzzleBoard, ChArUco, Marker board.
PuzzleBoard pipeline
Composes: the chessboard grid stack + PuzzleBoard edge-code decode. Source of truth:
crates/calib-targets-puzzleboard/docs/PIPELINE.md. Crate reference: calib-targets-puzzleboard.
A PuzzleBoard is a self-identifying chessboard: every interior edge carries a midpoint dot, and the dot pattern uniquely identifies a fragment’s position on a 501×501 master code. The pipeline runs the full chessboard grid stack first, then samples the interior-edge dots and decodes them into absolute master positions — so a visible fragment still yields absolute corner IDs and object-space coordinates.
End-to-end stages
| # | Stage | In → Out | What it does |
|---|---|---|---|
| 0 | chessboard grid detect | ChESS corners → Vec<ChessDetection> | The full chessboard pipeline (multi-component). Wrong (i, j) labels here become wrong absolute master labels — same precision-unrecoverable property as ChArUco. |
| 1 | edge sampling | labelled corners + image → observed edges | Per interior edge: sample a disk of radius sample_radius_rel × edge_len at the midpoint, derive bright/dark references from the adjacent cells, classify into bit ∈ {0,1} with a confidence ∈ [0,1]. |
| 2 | bit-confidence filter | observed edges → high-confidence edges | Drop bits below min_bit_confidence; low-confidence bits become unknown. |
| 3 | minimum-window gate | filtered edges → pass / fail | Require the fragment to span min_window corners on both axes (default 7). That span carries 30 distinct code bits — the same information the paper’s 4 × 4 fragment carries — and is unique at every master position under the rotations-only search. |
| 3b | period-3 consensus | filtered edges → voted code bits | Both maps repeat every three rows or columns, so collapse the dots onto the distinct bits they read, by confidence-weighted majority vote. Origin- and orientation-invariant, so it runs before any hypothesis. Ties are erasures, never guesses; too few surviving bits → NotEnoughLogicalBits. |
| 4 | origin decode | filtered edges + master maps → (D4, origin) + score | Recover the (D4 transform, master origin) via the cyclic class tables — Full (whole master) or FixedBoard (declared board’s rectangle) scope, scored by HardWeighted (BER gate) or SoftLogLikelihood (margin gate, default). Both are re-gated for uniqueness. See the decode algorithm. |
| 5 | best-component selection | per-component results → one decode | Rank components by edges-matched, then BER, then soft score. Conflict detection: two well-supported components disagreeing on master origin → InconsistentPosition (refuse, don’t guess). |
| 6 | emit detection | best decode → result | Rebase (i, j) to non-negative; sort by (j, i); assign absolute IDs (j·501 + i) and target_position (i·cell_size, j·cell_size). |
What it inherits from the chessboard detector
The full chessboard topological pipeline runs on the input ChESS corners — prefilter, axis clustering, the topological grid walk, booster-driven component recovery, and the mandatory final geometry check. PuzzleBoard already defaulted to the topological builder, which is the only builder and has no selector field.
The partial-view guarantee
For a given printed board, any subset of its corners decodes to the same
master IDs a full-view decode would produce — across single-camera
captures that frame only part of a large board and across multi-camera
rigs where each camera sees a different fragment. Overlapping corners
share master IDs without further stitching. FixedBoard mode sidesteps the
per-view master-origin drift by scoring against the declared board rather
than the full master.
Failure modes
| Symptom | Likely stage | What it means / knob to try |
|---|---|---|
| No grid components | Stage 0 (chessboard) | Sparse / empty corner cloud — see the chessboard failure modes. Try --upscale on small boards. |
NotEnoughEdges | Stage 2–3 | Too few high-confidence edge bits survived. Lower decode.min_bit_confidence; check that interior dots are resolved at this image scale. |
| Every hypothesis over BER | Stage 4 (HardWeighted) | Board too small or too noisy. Raise decode.max_bit_error_rate, or switch to SoftLogLikelihood (more robust on ambiguous fragments). |
| Small / ambiguous decode margin | Stage 4 (SoftLogLikelihood) | Near-symmetric fragment or few high-confidence bits. Capture a larger fragment; the margin gate is correctly refusing a coin-flip. |
InconsistentPosition | Stage 5 | Two sub-grids disagree on the master origin — an unrecoverable ambiguity. Crop to a single board, or use FixedBoard with the known spec. |
| Wrong absolute IDs | never | A wrong chessboard (i, j) would cause this — file a bug at the chessboard layer; the decode itself is exhaustive and gated. |
Tuning
The ChESS corner front-end is params.chess (a DetectorConfig, consumed
by the facade’s whole-image entry points); the grid side is the standard
chessboard ChessboardParams (under params.chessboard); the decode side
is params.decode:
min_bit_confidence(default0.5) — the confidence floor for an edge bit to count. Lower on blurry boards; too low admits noise bits.max_bit_error_rate(default0.3) — the BER gate.min_window(default7) — the uniqueness floor; rarely changed, and lowering it trades misses for the risk of a wrong absolute label.search_mode—Full(default) vsFixedBoard(cheaper, fixes per-view origin drift when the board is known).scoring_mode—SoftLogLikelihood(default) vsHardWeighted(robust on ambiguous fragments; adds the margin gate).symmetry_mode—Rotations(default; the four 90° rotations, correct for an ordinary camera) vsRotationsAndReflections(all eight dihedral transforms; enable only when the optical path mirrors the image).search_all_components(defaulttrue) — decode every grid component and pick the best, with conflict detection.
For threshold-sensitive images use
PuzzleBoardParams::sweep_for_board(&spec) with
detect_puzzleboard_best. The sweep tries the default soft scorer first,
then a hard-weighted fallback at the paper’s 40% BER allowance for
high-distortion fragments. The naive hard-bit decoder already clears the
precision/recall contract at zero wrong labels; do not rewrite to a
coherent-hypothesis matcher without a demonstrated precision gap.
Cross-references
- PuzzleBoard edge-code decode — the decoder algorithm in detail.
- calib-targets-puzzleboard — the crate API, search modes, and printable examples.
crates/calib-targets-puzzleboard/docs/PIPELINE.md— the canonical stage map this page mirrors.
ChArUco pipeline
Composes: the chessboard grid stack + ArUco bit decode + ChArUco alignment & corner IDs. Source of truth:
crates/calib-targets-charuco/docs/PIPELINE.md. Crate reference: calib-targets-charuco.
A ChArUco board carries an ArUco marker in every white square. The pipeline runs the chessboard grid detector first, then decodes the per-cell markers, aligns them to the board spec, and assigns each inner corner its absolute, OpenCV-compatible corner ID. The markers are what make ChArUco robust to partial views and unambiguous about orientation.
End-to-end stages
| # | Stage | In → Out | What it does |
|---|---|---|---|
| 0 | chessboard grid detect | ChESS corners → Vec<ChessDetection> | ChessDetector::detect_all on the topological builder. The min_corner_strength floor keeps marker-bit saddles out of the grid (below). |
| 1 | grid smoothness pre-filter | grid corners + image → cleaned corners | Per-corner position vs midpoint-averaged neighbours; a deviation over grid_smoothness_threshold_rel × px_per_square triggers a local ChESS redetection or a drop. |
| 2 | marker cell enumeration | corner map → Vec<MarkerCell> | Per cell, require all four corners {(i,j),(i+1,j),(i+1,j+1),(i,j+1)}; skip incomplete cells. |
| 3 | marker decode + alignment | cells + image → markers + alignment | Score each cell’s soft bits (ArUco bit decode) against every (D4 rotation, integer translation) board hypothesis, pick the maximum-likelihood placement, and accept it through a margin gate. See alignment. |
| 4 | alignment validation | markers + spec → inliers | Require ≥ min_marker_inliers (primary component) or ≥ min_secondary_marker_inliers (subsequent). |
| 5 | ChArUco corner mapping | corners + alignment → IDed corners | Map each board-spec inner-corner position through the alignment; only inner-cell intersections get IDs (not marker corners). |
| 6 | corner validation | mapped corners + markers + image → validated corners | Check each corner against its marker-predicted seed; deviation over corner_validation_threshold_rel × px_per_square → marker-constrained redetect or drop. |
| 7 | emit detection | validated corners + alignment → result | Sort typed ChArUco corners by ID; refuse below the caller’s threshold. |
What it inherits from the chessboard detector — and the strength floor
ChArUco runs the full chessboard topological pipeline (prefilter,
clustering, the grid walk,
booster recovery, and the mandatory geometry
check). The chessboard precision contract
carries forward: a wrong (i, j) label here would corrupt every
downstream marker match.
CharucoParams::for_board sets two chessboard knobs that adapt the shared
detector to marker scenes:
min_corner_strength = 33.0— an absolute ChESS-strength floor that cuts the weak corner responses on ArUco marker-bit saddles before the grid grows. Those corners are grid-consistent but lie inside the marker interior; cutting them early keeps the topological per-cell axis test from being poisoned by marker-internal X-corners. This floor — not marker presence — is the precision lever here. (It is the concern the historical ChArUco builder pin used to guard; the topological builder is now safe on marker scenes because of this floor.)- the chessboard’s final wrong-label geometry check stays enabled, exactly
as for the standalone chessboard detector. Marker decoding is not a
substitute: the board alignment searches
D4 × integer translation, a rigid relabelling of whatever lattice the chessboard produced, so it cannot see or repair a labelling that is wrong within a component.
There is no chessboard grid-builder selector; legacy selector keys are rejected as unknown configuration fields.
Failure modes
| Symptom | Likely stage | What it means / knob to try |
|---|---|---|
No grid / None from Stage 0 | Stage 0 (chessboard) | Sparse corner cloud or clustering failure — see the chessboard failure modes. |
NoMarkers (grid found, no decodes) | Stage 3 (decode) | Wrong dictionary / marker_size_rel, or blur. Enable multi_threshold, lower min_border_score, verify the board spec. |
AlignmentFailed { inliers: 0 } | Stage 4 | No decoded ID is in the layout — board-spec mismatch (rows, cols, dictionary, marker_layout) or a non-zero first_marker offset. |
AlignmentFailed, small but non-zero inliers | Stage 4 | Partial view or strong perspective — lower min_marker_inliers to what you reliably see. |
| Small soft-matcher margin | Stage 3 (board-level matcher) | Ambiguous decodes / heavy bit noise — the margin gate is flagging a low-confidence alignment. |
| Corners drift off true intersections | Stage 6 | Weak alignment — verify the board pose / occlusion; check corner_validation_threshold_rel. |
| Wrong corner IDs | never | A wrong chessboard (i, j) would cause this — file a bug at the chessboard layer. |
Tuning
ChArUco config layers three surfaces:
- Chessboard grid —
CharucoParams.chessboardis aChessboardParams(stable core + opt-inadvanced).for_boardpre-sets the marker-scene-safemin_corner_strengthfloor; do not lower it on marker boards. - Marker decode —
scan.*(ScanDecodeConfig):min_border_score(default0.75; lower cautiously to0.65on blur),multi_threshold(defaulttrue),inset_frac(default0.06; raise when borders bleed into the bit grid).marker_size_relmust match the printed board. - Alignment — the board-level matcher’s
alignment_min_margin/bit_likelihood_slope, the downstreammin_marker_inliers/min_secondary_marker_inliersfloors (default1each — the matcher is its own gate), andcorner_validation_threshold_rel(default0.08).
Board sampling scale is px_per_square (starts at 60 in for_board);
adjust it first if the board appears at a very different pixel scale. For
challenging images use CharucoParams::sweep_for_board(&board) with
detect_charuco_best. See Tuning the Detector and
Troubleshooting.
Cross-references
- ArUco bit decode and ChArUco alignment & corner IDs — the two marker-side algorithms.
- calib-targets-charuco and ChArUco Alignment and Refinement — the crate API.
crates/calib-targets-charuco/docs/PIPELINE.md— the canonical stage map this page mirrors.
Marker board pipeline
Composes: the chessboard grid stack + 3-circle anchoring. Source of truth:
crates/calib-targets-marker/docs/PIPELINE.md. Crate reference: calib-targets-marker.
A marker board is a chessboard with three reference circles in known cells. The pipeline runs the chessboard grid detector to recover the lattice, then detects the three circles and uses them to anchor the otherwise-unlabelled grid to a known board frame. It is the lightest self-identifying target: where ChArUco anchors with per-cell marker IDs, this anchors with a single 3-point pose.
End-to-end stages
| # | Stage | In → Out | What it does |
|---|---|---|---|
| 0 | chessboard grid detect | ChESS corners → ChessDetection | ChessDetector::detect — single best component (multi-component is not supported here). |
| 1 | circle candidate detection | corner map + image → Vec<CircleCandidate> | For each complete 4-corner cell, warp the cell to a square patch 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–3 | board-frame resolution | candidates + 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. |
| 4 | corner-frame shift | alignment → alignment | A 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. |
| 5 | emit detection | grid + circles + alignment → result | Emit typed marker-board corners (optional IDs / target_position); circle evidence is returned through MarkerBoardDiagnostics. |
What it inherits from the chessboard detector
The full chessboard topological pipeline (prefilter,
clustering, the grid walk,
booster recovery, and the mandatory geometry
check). The 3-circle pattern serves only to
anchor the labelled grid to a known frame — a wrong (i, j) label at
the chessboard layer would mis-align every alignment-derived ID. This
detector uses detect (single best component), not detect_all.
Failure modes
| Symptom | Likely stage | What it means / knob to try |
|---|---|---|
No grid / Err(ChessboardNotDetected) | Stage 0 (chessboard) | Sparse corner cloud or clustering failure — see the chessboard failure modes. |
| No / too few circle candidates | Stage 1 | Circles absent, wrong polarity (e.g. white circle on white cell), 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–3 | Fewer 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–3 | Two 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 empty | output | board.cell_size is unset (or alignment failed) — target_position is only populated when both hold. |
| Wrong anchored IDs | never | A wrong chessboard (i, j) would cause this — file a bug at the chessboard layer. |
Tuning
MarkerBoardParams is board layout + chessboard params + circle scoring +
matching:
board— theMarkerBoardSpec(rows, cols, the threeMarkerCircleSpeccells + polarities,circle_diameter_rel, optionalcell_size). The marker circles supply the geometry constraint, so the v1expected_rows/colsandcompleteness_thresholdno longer apply.circle_diameter_relis the one place the printed disc size is stated — every radius the scorer probes is relative to it.chessboard— aChessboardParamsfor 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(default6),min_offset_inliers(default3, the whole layout). Loweringmin_offset_inliersbuys 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-coreis the base and should not depend on higher-level crates.calib-targets-chessboarddepends oncorefor geometry and types.calib-targets-arucodepends oncorefor rectified image access.calib-targets-charucodepends onchessboardandaruco.calib-targets-puzzleboarddepends onchessboardand uses committed code-map blobs for absolute edge-code decoding.calib-targets-markerdepends onchessboardandcore.calib-targets-printdepends on the target crates and owns printable-target rendering.calib-targetsis the facade that re-exports types and offers end-to-end helpers.
Python bindings
Python bindings are provided by the calib-targets-py crate (module name
calib_targets). It depends on the facade crate and is built with maturin;
see crates/calib-targets-py/README.md in the repository root.
Where to start
If you are new to the codebase, start with:
Then branch into the target-specific crates depending on your use case.
calib-targets-core
calib-targets-core provides shared geometric types and utilities. It is intentionally small and purely geometric; it does not depend on any particular corner detector or image pipeline.
Global rectification output from the chessboard pipeline.
Core data types
AxisEstimate: one local grid-axis direction — an angle in[0, π)plus a 1σ angular uncertainty. A corner carries two of these asaxes: [AxisEstimate; 2]; there is no single-orientation field (Corner::orientationwas removed workspace-wide).Coord: integer grid indices(u, v)in board space, withuright andvdown. Re-exported fromprojective-grid; it is the single canonical grid-coordinate type across the workspace.LabeledCorner: a detected corner with optional grid coordinates and logical ID —position,grid,id,target_position,score.- The raw per-corner input type is detector-specific and lives in the
detector crates (e.g.
calib_targets_chessboard::ChessCorner), not incalib-targets-core. TargetDetection: a collection of labeled corners for one board instance.TargetKind: enum forChessboard,Charuco, orCheckerboardMarker.
These types are shared across all detectors so downstream code can be target-agnostic.
Orientation clustering
ChESS corner orientations are only defined modulo pi. The clustering utilities help recover two dominant grid directions:
cluster_orientations: histogram-based peak finding followed by 2-means refinement.OrientationClusteringParams: histogram size, separation thresholds, outlier rejection.compute_orientation_histogram: debug visualization helper.estimate_grid_axes_from_orientations: a lightweight fallback when clustering fails.
Chessboard detection uses these helpers to label corners by axis and to reject outliers.
Homography and rectification
Homography is a small wrapper around a 3x3 matrix with helpers for DLT estimation and point mapping:
estimate_homography_rect_to_img: DLT with Hartley normalization for N >= 4 point pairs.homography_from_4pt: direct 4-point estimation.warp_perspective_gray: warp a grayscale image using a homography.
For mapping rectified pixels back to the original image, core defines:
RectifiedView: a rectified grayscale image and its mapping info.RectToImgMapper: either a single global homography or a per-cell mesh map.
Higher-level crates (notably chessboard) wrap these utilities for global or mesh rectification.
Image utilities
GrayImage and GrayImageView are lightweight, row-major grayscale buffers with bilinear sampling helpers:
sample_bilinear: float sampling with edge clamp to 0.sample_bilinear_u8: u8 sampling with clamping to 0..255.
These utilities are used by rectification and marker decoding.
Conventions recap
- Coordinate system: origin at top-left,
xright,ydown. - Grid coordinates:
iright,jdown, and grid indices refer to corners. - Quad order: TL, TR, BR, BL in both source and destination spaces.
If you build on core types, stick to these conventions to avoid subtle alignment bugs.
The Chessboard Detector
Code:
calib-targets-chessboard. Related: the generic axis clustering, topological grid construction, and line/local-H validation live in the standaloneprojective-gridcrate.For the canonical end-to-end stage map see the Chessboard pipeline; for the individual building blocks see the Algorithms section. This page is the crate’s invariant-and-API reference and goes deeper on the precision-by-construction design.
The chessboard detector takes a cloud of ChESS X-junction corners and produces
an integer-labelled chessboard grid (i, j) → image position. It is
precision-by-construction: every emitted label has been proven to sit at
a real grid intersection by a stack of independent geometric invariants.
Missing corners are acceptable; wrong corners are not.
On real-world captures carrying non-negligible lens distortion and motion
blur, the detector achieves a high detection rate with zero wrong
(i, j) labels — precision-by-construction.
A wrong label would corrupt downstream calibration; that is the constraint the algorithm refuses to break.
┌───────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌────────┐
│Corners│ ->│Prefilter│ ->│ Cluster │ ->│ Topo │ ->│ Recover │ ->│ Geom │
│ in │ │(Stage 1)│ │ axes │ │ grid │ │ + boost│ │ check │
└───────┘ └─────────┘ │(Stage 2)│ │(Stage 3) │ │(Stage 4)│ │(Stage 5)│
└─────────┘ └──────────┘ └─────────┘ └────────┘
│
v
┌────────┐
│ Output │
│(Stage 6)│
└────────┘
1. Corner axes contract
The detector reads only one orientation signal per corner:
ChessCorner.axes: [AxisEstimate; 2]. Convention (enforced workspace-wide
and documented in CLAUDE.md):
axes[0].angle ∈ [0, π),axes[1].angle ∈ (axes[0].angle, axes[0].angle + π).axes[1] − axes[0] ≈ π/2— the two axes are orthogonal grid directions (NOT diagonals of unit squares).- The CCW sweep from
axes[0]toaxes[1]crosses a dark sector. This encodes parity: at parity-0 cornersaxes[0] ≈ Θ_horizontal(dark-entering), at parity-1 cornersaxes[0] ≈ Θ_vertical. Adjacent chessboard corners therefore have opposite axis-slot assignments. - Default-constructed axes carry
sigma = π(no information) and are filtered out in Stage 1.
Any function computing a circular mean of axis angles MUST accumulate
(cos 2θ, sin 2θ) and halve the atan2 result. Accumulating raw
(cos θ, sin θ) breaks at the 0°/180° seam.
2. Invariants
A labelled corner C at (i, j) is kept iff every one of these holds at
convergence:
- Axis membership. Both
C.axes[0]andC.axes[1]are withincluster_tol_degof the two global grid-direction peaks{Θ₀, Θ₁}, each axis matching a different peak. - Cluster label = axis-slot.
cluster(C) = 0iffC.axes[0]is closer toΘ₀; otherwise1. Binary, per-corner. - Parity.
cluster(C) ≡ (i + j) mod 2(modulo a global sign fixed by the seed quad). - Edge orientation along the corner’s axes. For every in-graph edge
C ↔ Nwith vectorv = N.pos − C.pos,atan2(v) mod πis withinedge_axis_tol_degof exactly one ofC.axes[*]AND of exactly one ofN.axes[*]. (No ±π/4 offset — edges align with axes, not diagonals.) - Edge axis-slot swap. Let
ax_C ∈ {0, 1}be the slot ofCmatching the edge, andax_Nthe slot ofN. Requireax_C ≠ ax_N. - Cell-size consistency.
|v| ∈ [1 − step_tol, 1 + step_tol] × s. - Line collinearity. For every labelled row / column through
Cwith≥ line_min_membersmembers,C’s perpendicular residual to the fitted line is≤ line_tol × s. Projective-line fits use a looser tolerance to absorb mild lens distortion. - Local-H consistency. A local 4-point homography from 4 non-collinear
labelled neighbors predicts
C’s pixel position with residual≤ local_h_tol × s. - No ambiguity at attachment. When admitted via prediction, no other
strong corner lies within
attach_ambiguity_factor ×the attachment distance.
A corner failing any invariant is blacklisted. A blacklist update
restarts seed → grow → validate with the blacklist excluded; the loop is
capped at max_validation_iters.
3. Pipeline
The detector runs as a sequence of named stages, orchestrated by
pipeline::detect_all_topological with one module per stage group under
crates/calib-targets-chessboard/src/pipeline/. The canonical six-stage
map (this mirrors crates/calib-targets-chessboard/docs/PIPELINE.md and
the crate-level rustdoc — that crate doc is the authoritative stage list):
ChessCorner[]
→ 1. prefilter strength + fit-quality gates; weak corners kept as
→ positions with no-information axes (indices stay stable)
→ 2. cluster_axes global axes Θ₀, Θ₁ + per-corner slot label,
→ then the DiskFit slot-coherence repair
→ 3. topological_grid the projective-grid topological builder
→ (Delaunay → classify → quads → walk → facade merge)
→ 4. recover_components per-component cell-size estimate, recall boosters
→ (gap fill + line extrapolation), weak-cluster rescue,
→ merge_components_local
→ 5. final_geometry_check MANDATORY precision pass; can only DROP corners
→ 6. output LabelledGrid::normalize (Coord copied straight out)
→ Output: ChessboardDetection (one per component) or None
The precision core is the whole chain: any corner that survives to output has passed every axis / parity / edge invariant. The boosters (Stage 4) only add corners — each addition re-runs the same invariants the topological walk uses — and the final geometry check (Stage 5) only drops them. Neither relaxes an invariant.
One builder, no seed/grow loop. The historical seed-and-grow grid builder (with its
find_seed/grow/extend_boundary/ blacklist-restart loop) has been removed.(i, j)labelling is done by the topological grid finder; the chessboard crate owns the prefilter, clustering, recovery boosters, the mandatory geometry check, and output canonicalisation around it. The generic builder is documented on the Topological grid finder algorithm page and indocs/algorithms/topological-grid-detection.md.
Stage 1 — Pre-filter (inputs.rs)
Mark corner c usable iff:
c.strength ≥ min_corner_strength(default33.0); andmax(c.axes[0].sigma, c.axes[1].sigma) ≤ axis_align_tol_rad(default 15°, the Stage 3 axis-alignment tolerance).
The second condition is derived, not a knob of its own: an axis whose 1σ
uncertainty is wider than the window the cell test will judge it against
carries no usable information for that test. It replaces the pre-0.11.0
fit_rms ≤ max_fit_rms_ratio × contrast rule, which chess-corners 1.0 made
inexpressible by removing both scalars.
A corner that fails keeps its pixel position but has its axes replaced by
the no-information sentinel (sigma = π), so it cannot vote on edges but
the corner array is not renumbered (trace / index stability).
Stage 2 — Axis clustering (cluster/)
Recover the two global grid directions {Θ₀ ≤ Θ₁} from the strong
corners’ axes with the generic axis clustering
(circular histogram + plateau-aware peak picking + double-angle 2-means
on (cos 2θ, sin 2θ)), and label each corner Canonical (axes[0] matches
Θ₀), Swapped, or NoCluster.
Why double-angle. Axes are undirected —
θandθ + πare the same direction. Naïve circular mean over raw(cos θ, sin θ)produces zero when votes straddle the 0°/π seam. Doubling the angle wraps both halves together; the inverse halving gives a stable mean.
Stage 3 — Topological grid (mod.rs → projective-grid)
Hand the oriented features (positions + dual axes) and the cluster centres
(as an axis hint) to the topological grid finder
(via expert::square::assemble_oriented2_components): Delaunay
triangulation → axis-driven edge classification → triangle-pair → quad
merge → flood-fill (i, j) walk → the facade’s merge_components_local.
The expert seam stops at that merge and preserves the topology axis-slot
frame; chessboard parity/recovery and its final validation run downstream.
Stage 4 — Recover components (recover.rs + boosters.rs)
Per labelled component: estimate the cell size from the labelled cardinal
edges, then run the recovery boosters —
interior gap fill + line extrapolation via fill_grid_holes, with a
per-axis directional edge scale because a partially-grown component can
be anisotropic before its boundaries fill in. Each addition re-runs the
same axis / parity / edge-slot-swap invariants as the walk; the pass is
capped by max_booster_iters. Optional weak-cluster rescue re-admits
NoCluster corners within weak_cluster_tol_deg. Finally
merge_components_local reunites components in label space.
Stage 5 — Final geometry check (geometry_check.rs)
Mandatory, and can only DROP (never add or relabel). It sequences the
shared drop_set precision pass:
- the shared
validate(line collinearity + local-H residual) with loosergeometry_check_*tolerances — catches gross mislabels (full-cell / diagonal ≈ 1.4-cell residual) without flagging accepted perspective drift; - the direct topological wrong-label check (interior skipped-corner edges, duplicate-pixel labels, frontier line-spacing smoothness);
- the largest-cardinally-connected-component filter, dropping isolated leaks outside the main grid.
The detection is refused if survivors fall below min_labeled_corners.
Stage 6 — Output (output.rs)
Build a projective_grid::LabelledGrid from the surviving labelled set and
call LabelledGrid::normalize() (rebase
min → (0, 0); canonicalise so +u ≈ +x, +v ≈ +y; stable (v, u)
sort — all owned by projective-grid). The normalized lattice Coord{u,v}
is the workspace’s canonical grid-coordinate type, so it is copied straight
onto each output corner with no adaptation step.
4. Why precision is by construction
The design constraint “wrong (i, j) labels are unrecoverable” is what
shapes every non-obvious choice in the pipeline. Two examples:
Cell size is an OUTPUT, not an input. A naïve detector estimates a
global cell size first, then uses it to set a search window. On ChArUco
scenes the nearest-neighbor histogram is bimodal (marker-internal
pairs at ~10 px vs true board pairs at ~55 px); even multimodal mean-shift
can pick the wrong mode. The topological builder instead assembles cells
from local axis topology — its quad filter uses a per-component
edge-length band (relative to that component’s own median), never a global
scalar — and the chessboard recovery stage then derives each component’s
cell size from its own labelled cardinal edges. There is no global pitch to
mispick. See the per-component cell-size band on the
Topological grid finder page and the
Cell-size gotcha in CLAUDE.md.
Edges align with axes, not diagonals. Some chessboard detectors model
ChESS corners as having a single orientation θ and check that grid
edges align with θ ± π/4. It reads the two axes directly and requires
edges to align with one axis (per invariant 4). The edge check then
becomes “does the edge match exactly one of the two axes within
tolerance?” — robust to the axis-swap parity that ChESS X-junctions
exhibit at adjacent corners. Skipping the ±π/4 offset removes a
single-orientation dependence that the workspace already discarded
(Corner::orientation was removed entirely).
Multi-component scenes are first-class. The same precision contract
applies to ChessboardDetector::detect_all, which peels off disconnected components
of the same physical board (the typical ChArUco case where markers
interrupt grid contiguity). Each component is rebased to its own (0, 0)
origin; alignment to a global frame is the caller’s job.
We explicitly do NOT support scenes containing multiple separate physical boards. One target per frame is the contract.
5. Failure modes
When detection fails or returns fewer corners than expected, run the
serializable trace (pipeline::trace_topological, see §7) and consult this
table.
| Symptom | Likely stage | Knob to try | Notes |
|---|---|---|---|
No detection; trace shows few usable corners | Stage 1–2 (prefilter / clustering) | min_corner_strength ↓, min_peak_weight_fraction, peak_min_separation_deg | Either the corners failed the prefilter (weak response, or axes too uncertain to vote) or the two grid axes never separated. Most common on very-bad-light frames. |
No detection; trace shows usable corners but NoComponents | Stage 3 (topological grid) | Try detect_chessboard_best with ChessboardParams::sweep_default() | No quad mesh assembled. Builder tolerances are internal; the sweep widens the upstream clustering / attachment tolerances. |
| Detection has very few corners | Stage 4 (recover) | attach_search_rel, attach_axis_tol_deg, step_tol, edge_axis_tol_deg | The grid walked but couldn’t extend. Common on heavily distorted views. |
Many corners dropped (GeometryCheckTrace.dropped high) | Stage 5 (geometry check) | geometry_check_local_h_tol_rel | Invariants found outliers; inspect the per-reason dropped_* counters. |
Wrong (i, j) labels emitted | never | — | If you ever see this, file a bug. The precision contract has been violated. |
The rare unrecovered frame on our internal regression set is typically a very-bad-light capture whose Stage-2 clustering never converges.
6. Parameters
ChessboardParams is #[non_exhaustive] and splits into a small stable
core — min_labeled_corners, max_components,
min_corner_strength — plus an opt-in, unstable ChessboardAdvancedTuning sub-struct
(ChessboardParams::advanced) holding the per-stage tuning knobs. Build with
Default::default() and overwrite the stable fields, attach advanced
overrides with ChessboardParams::with_advanced(...), or call
ChessboardParams::sweep_default() for a 3-config preset (default, tighter,
looser) suitable for detect_chessboard_best-style sweeps.
advanced is Option-wrapped and serialized as a nested "advanced"
object — it is not flattened, and is omitted entirely when unset (in
which case detection runs on the defaults). The three stable knobs stay
top-level JSON keys. ChessboardAdvancedTuning’s fields are not covered by
semver and may change between minor versions. The Field column below
shows the access path: top-level for the three stable knobs,
advanced.<knob> for the rest.
| Field | Default | Stage | Purpose |
|---|---|---|---|
max_components | 3 | — | Cap for detect_all. |
min_labeled_corners | 8 | 5 | Minimum labelled corners to emit a ChessboardDetection. |
min_corner_strength | 33.0 | 1 | Minimum ChESS strength. 0 disables. (Stable.) |
advanced.num_bins | 90 | 2 | Axis-direction histogram bins on [0, π). |
advanced.cluster_tol_deg | 12.0 | 2 | Per-axis tolerance from a cluster center. |
advanced.peak_min_separation_deg | 60.0 | 2 | Minimum separation between the two peaks. |
advanced.min_peak_weight_fraction | 0.02 | 2 | Minimum fraction of total vote weight per peak. |
advanced.attach_search_rel | 0.35 | 4 | Candidate radius around predicted position (booster attachment). |
advanced.attach_axis_tol_deg | 15.0 | 4 | Axis match at booster attachment. |
advanced.attach_ambiguity_factor | 1.5 | 4 | Reject if 2nd-nearest within factor × nearest. |
advanced.step_tol | 0.25 | 4 | Edge-length window when admitting attachments. |
advanced.edge_axis_tol_deg | 15.0 | 4 | Edge axis tolerance at admission. |
advanced.geometry_check_local_h_tol_rel | 0.20 | 5 | Local-H prediction tolerance in the final geometry check. |
advanced.line_min_members | 3 | 5 | Minimum members to fit a row / column. |
advanced.enable_weak_cluster_rescue | true | 4 | Toggle for the weak-cluster rescue booster. |
advanced.weak_cluster_tol_deg | 18.0 | 4 | Loosened cluster tolerance for rescue candidates. |
The advanced. rows above are part of ChessboardAdvancedTuning, which is opt-in
and not covered by semver. (ChessboardAdvancedTuning carries more per-stage
knobs than shown — see crates/calib-targets-chessboard/src/params/.)
All spatial tolerances are multiplicative with respect to the cell size — the pipeline is scale-invariant once the per-component cell size is estimated.
7. Debugging via the topological trace
The diagnostic entry point is pipeline::trace_topological(corners, params) -> Result<TopologicalTrace, TopologicalTraceError>. It is layered
over the production detect_grid_all facade (no separate timed
implementation), so the trace stays consistent with what detect()
actually does. TopologicalTrace (re-exported from
projective_grid::topological::trace) carries:
params: TopologicalParams— the parameters the topological stage ran with.corners: Vec<TopologicalCornerTrace>— every input corner with itsindex,source_index,position, per-axisaxis_angles_rad/axis_sigmas_rad, and ausableflag (did it survive the sigma/axis prefilter).components: Vec<TopologicalComponentTrace>— the labelled connected components, each a list of(u, v) -> source_indexlabels sorted by(v, u, source_index).diagnostics: TopologicalTraceDiagnostics— summary counters (corners_in,corners_used,components,labels).
TopologicalTraceError is NotEnoughCorners { usable } (fewer than three
usable corners for Delaunay) or NoComponents (production detection
returned no labelled component) — these are the two ways the grid stage
can come up empty.
For the drop accounting in the final geometry check, the pipeline’s
GeometryCheckTrace records dropped plus per-reason counters
(dropped_line_collinearity, dropped_local_h_residual,
dropped_edge_invariant, dropped_disconnected), components_seen, and a
detection_refused flag — the place to look when corners that should
survive are being dropped.
The stable cell_size (the grid pitch in px) is carried on
ChessboardDetection directly, populated on the normal detect() path.
8. Quickstart
use calib_targets_chessboard::{ChessCorner, ChessboardDetector, ChessboardParams};
fn detect(corners: &[ChessCorner]) {
let params = ChessboardParams::default();
// `ChessboardDetector::new` validates params and is fallible: it returns
// `Err(ChessboardParamsError)` for an invalid combination. No combination
// the public surface can express is rejected today; the fallible signature
// is a reserved seam for future validations.
let det = ChessboardDetector::new(params).expect("valid params");
if let Some(d) = det.detect(corners) {
println!("labelled {} corners", d.corners.len());
// `cell_size` (the seed-derived grid pitch in px) is populated on the
// normal `detect()` path; `Option<f32>`, so `None` on edge cases.
if let Some(pitch) = d.cell_size {
println!("grid pitch ≈ {pitch:.1} px");
}
for c in &d.corners {
// `grid` is non-optional; `input_index` points back into `corners`.
println!(
"(u, v) = ({}, {}) at ({:.1}, {:.1}) [input #{}]",
c.grid.u, c.grid.v, c.position.x, c.position.y, c.input_index
);
}
}
}
fn detect_multi(corners: &[ChessCorner]) {
let det = ChessboardDetector::new(ChessboardParams::default()).expect("valid params");
for (k, comp) in det.detect_all(corners).iter().enumerate() {
println!("component {k}: {} corners", comp.corners.len());
}
}
For a minimal, dependency-free onboarding program — a synthetic
corner cloud detected and printed end to end — see
crates/calib-targets-chessboard/examples/detect_chessboard.rs:
cargo run -p calib-targets-chessboard --example detect_chessboard
The per-image regression overlays for the testdata/ set are emitted by
the driver script scripts/chessboard_regression_overlays.sh and are
wired into a #[test] harness at
crates/calib-targets-chessboard/tests/testdata_regression.rs.
9. Open questions
- Degenerate axes (one axis with
sigma = π) — current: the corner keeps its position but cannot vote on edges. Could a single-axis attachment pathway recover some recall on low-quality inputs? - Three-corner cells. The topological merge needs a complete cell (two triangles sharing a diagonal); one missing corner per cell starves the surrounding walk and the gap fill only recovers single interior holes. A richer local-geometry recovery could rebuild more partial cells.
- Distortion-curved lines — current: projective-line fit when there are enough members, straight-fit fallback. A true polynomial fit could absorb more distortion at the cost of false-negative risk.
- Delaunay under severe distortion — current: a Delaunay triangle can span more than one physical cell under combined perspective + radial distortion, leaving cells the diagonal-inference rule cannot resolve. A distortion-aware candidate-neighbour graph could help.
Contributions welcome.
calib-targets-aruco
calib-targets-aruco provides embedded ArUco/AprilTag-style dictionaries and decoding on rectified grids. It does not detect quads or perform image rectification by itself.
Rectified grid used for ArUco/AprilTag decoding.
For the algorithm — the grid-aware bit sampling model, the explicit bit order / polarity /
borderBitsconventions, and why this is not generic contour detection — see ArUco bit decode. This page is the crate API reference.
Current API surface
Dictionary: built-in dictionary metadata and packed codes.Matcher: brute-force matching against a dictionary with rotation handling.ScanDecodeConfig: how to scan a rectified grid (border size, inset, polarity).scan_decode_markers: read and decode markers from rectified cells.scan_decode_markers_in_cells: decode markers from per-cell image quads (no full warp).decode_marker_in_cell: decode a single marker inside one square cell.
The crate expects a rectified view where each chessboard square is approximately px_per_square pixels and where cell indices align with the board grid.
Decoding paths
There are two supported scanning modes:
- Rectified grid scan (
scan_decode_markers): build a rectified image first and scan a regular grid. - Per-cell scan (
scan_decode_markers_in_cells): pass a list of per-cell quads and decode each cell directly.
Per-cell scanning avoids building the full rectified image and is easy to parallelize across cells.
Scan configuration
ScanDecodeConfig controls how bit sampling and thresholding behave:
border_bits: number of black border cells (OpenCV typically uses 1).marker_size_rel: marker size relative to the square size (ChArUco uses < 1.0).inset_frac: extra inset inside the marker to avoid edge blur.min_border_score: minimum fraction of border bits that must be black.dedup_by_id: keep only the best detection per marker id.
If decoding is too sparse on real images, reduce inset_frac slightly and re-run.
Conventions
- Marker bits are packed row-major with black = 1.
Match::rotationis in0..=3such thatobserved == rotate(dict_code, rotation).border_bitsmatches the OpenCV definition (typically 1).
Status
Decoding is implemented and stable for rectified grids, but quad detection and image-space marker detection are deliberately out of scope.
For deeper tuning and sampling details, see ArUco Decoding Details.
ArUco Decoding Details
This chapter expands on the marker decoding path in calib-targets-aruco. The decoder is grid-first: it samples expected square cells and reads bits in rectified space (or per-cell quads).
Per-cell decoding
scan_decode_markers_in_cells reads marker bits in their own cells given an existing grid of square corners, without warping the full image. ChArUco detection drives this path: only valid grid cells are decoded, and the per-cell work parallelises trivially.
Sampling model
- Bits are sampled on a regular grid inside the marker area.
- The marker area is defined by
marker_size_rel, with an extra inset frominset_frac. - A per-marker threshold (Otsu) is computed from sampled intensities.
Tuning knobs
inset_fraccontrols how far inside the marker area bits are sampled. Lower values capture more of the marker; higher values are more robust to thin black borders bleeding into the bit grid.min_border_scoreis the minimum “frame looks like a marker border” score required to accept a cell. Higher values reject ambiguous cells.dedup_by_idcollapses repeated decodes of the same dictionary ID across cells.marker_size_relis the marker side relative to the enclosing chessboard cell and must match the physical board spec.
calib-targets-charuco
calib-targets-charuco combines chessboard detection with ArUco decoding to detect ChArUco boards. ChArUco dictionaries and board layouts are fully compatible with OpenCV’s aruco/charuco implementation. The flow is grid-first:
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 whatcv2.aruco.CharucoBoardproduces out of the box; it is notsetLegacyPattern(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, andcrates/calib-targets-print/tests/opencv_charuco_conformance.rspins 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.
- Detect a chessboard grid from ChESS corners.
- Build per-cell quads from the detected grid.
- Decode markers per cell (no full-image warp).
- Align marker detections to a board specification and assign corner IDs.
Board specification
CharucoBoardSpecdescribes the board geometry:rows,colsare square counts (not inner corners).cell_sizeis the physical square size.marker_size_relis the marker size relative to a square.dictionaryselects the marker dictionary.marker_layoutdefines the placement scheme.
CharucoBoardvalidates and precomputes marker placement.
Detector
CharucoParams::for_boardprovides a reasonable default configuration.CharucoDetector::detectreturns aCharucoDetectionwith:detection: labeled corners with ChArUco IDs, filtered to marker-supported corners.markers: decoded marker detections in rectified grid coordinates (with optionalcorners_img).alignment: grid alignment from detected grid coordinates into board coordinates.
Per-cell decoding
The detector decodes markers per grid cell. This avoids building a full rectified image and keeps the work proportional to the number of valid squares. If you need a full rectified image for visualization, use the rectification helpers in calib-targets-chessboard on a detected grid.
Alignment and refinement
Alignment maps decoded marker IDs to board positions using a small set of grid transforms and a translation vote. Once an alignment is found, the detector re-decodes markers at their expected cell locations and re-solves the alignment to filter out inconsistencies.
This two-stage approach helps reject spurious markers while keeping the final corner IDs consistent.
Tuning notes
scan.inset_fractrades off robustness vs. sensitivity. The defaults infor_boarduse a slightly smaller inset (0.06) to improve real-image decoding.min_marker_inlierscontrols how many aligned markers are required to accept a detection.
Status
The current implementation focuses on the OpenCV-style layout and is intentionally conservative about alignment.
For alignment details, see ChArUco Alignment and Refinement.
ChArUco Alignment
calib-targets-charuco aligns decoded marker IDs to a board layout and assigns ChArUco corner IDs. Alignment is discrete and fast: it tries a small set of grid transforms and selects the translation with the strongest inlier support.
Alignment pass
- Each decoded marker votes for a board translation under each candidate transform.
- The best translation wins (ties broken by inlier count).
- Inliers are the markers whose
(sx, sy)map exactly to the expected board cell for their ID.
Inlier filtering
After alignment is chosen, the detector keeps only inlier markers and assigns ChArUco corner IDs based on the aligned grid coordinates. The final alignment uses the semantic GridAlignment alias for the canonical affine GridTransform; it maps detected grid coordinates into board coordinates with one matrix plus translation.
calib-targets-puzzleboard
calib-targets-puzzleboard detects PuzzleBoard targets: checkerboards whose
interior edge midpoints carry binary dots. The dots identify the board position
inside a 501 x 501 master pattern, so a visible fragment can still produce
absolute corner IDs and object-space coordinates.
PuzzleBoard is based on Stelldinger 2024, arXiv:2409.20127.
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:
- Run ChESS corner detection.
- Assemble one or more chessboard grid components.
- Sample every visible interior edge midpoint and estimate a bit confidence.
- Drop bits below
decode.min_bit_confidence. - Decode against the master maps over the candidate orientations — the
four 90° rotations by default (
symmetry_mode = Rotations), or all eight D4 transforms underRotationsAndReflections. - Assign absolute IDs and target-space positions to inlier corners.
The default decode.min_window is 7: after confidence filtering the fragment
must span at least 7 corners on both axes — 6 × 6 squares. A 4 × 4 window is
unique across master positions only at a fixed orientation, and a fragment
gives no cue to how the board was printed, so the decoder searches candidate
orientations too — over orientation × position, clean uniqueness begins at
exactly this span. Noise is handled by the uniqueness gate and the period-3
majority vote rather than by a larger window. See
the decode chapter.
Rust Facade Example
use calib_targets::{detect, puzzleboard::{PuzzleBoardParams, PuzzleBoardSpec}};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let img = image::open("testdata/puzzleboard_small.png")?.to_luma8();
let spec = PuzzleBoardSpec::new(10, 10, 12.0)?;
let params = PuzzleBoardParams::for_board(spec);
let result = detect::detect_puzzleboard(&img, ¶ms)?;
println!("{} corners", result.corners.len());
Ok(())
}
For threshold-sensitive images, use:
#![allow(unused)]
fn main() {
use calib_targets::{detect, puzzleboard::{PuzzleBoardParams, PuzzleBoardSpec}};
let img = image::GrayImage::new(1, 1);
fn run(img: &image::GrayImage) -> Result<(), Box<dyn std::error::Error>> {
let spec = PuzzleBoardSpec::new(10, 10, 12.0)?;
let configs = PuzzleBoardParams::sweep_for_board(&spec);
let result = detect::detect_puzzleboard_best(img, &configs)?;
let _ = result;
Ok(()) }
}
Search Modes
The default PuzzleBoardSearchMode::Full considers every (D4, origin)
candidate against the full master code — without enumerating them, since the
code’s cyclic structure collapses the search (see
the decode chapter). When
the caller already knows which board they printed,
PuzzleBoardSearchMode::FixedBoard restricts the origin to that board’s
rectangle, which is both faster than the full search at every board size
below the master’s own and guarantees the decode cannot return a position
outside the printed board:
#![allow(unused)]
fn main() {
use calib_targets::{detect, puzzleboard::{PuzzleBoardParams, PuzzleBoardSearchMode, PuzzleBoardSpec}};
let img = image::GrayImage::new(1, 1);
fn run(img: &image::GrayImage) -> Result<(), Box<dyn std::error::Error>> {
let spec = PuzzleBoardSpec::new(50, 50, 1.0)?;
let mut params = PuzzleBoardParams::for_board(spec);
params.decode.search_mode = PuzzleBoardSearchMode::FixedBoard;
let _ = detect::detect_puzzleboard(img, ¶ms)?;
Ok(()) }
}
Partial-view guarantee: for a given printed board, any subset of its corners decodes to the same master IDs a full-view decode would produce. This applies equally to single-camera captures that only frame part of a large board and to multi-camera rigs where each camera sees a different fragment — in both cases overlapping corners across frames or cameras share master IDs without further stitching.
The decoder’s per-view master origin is otherwise not fixed — it shifts
with which print-corner the chessboard stage picks as local (0, 0),
which depends on what the camera sees. FixedBoard sidesteps that
entirely by scoring against the board rather than against the full
master.
FixedBoard runs 8 × (rows + 1)² × N operations, where N is the
number of confidence-filtered edge observations. At typical edge counts
even a 50 × 50 board decodes in well under 10 ms natively. The default
stays Full; switch via params.decode.search_mode as shown.
Printable Example
Canonical sample specs live in:
testdata/printable/puzzleboard_small.jsontestdata/printable/puzzleboard_mid.json
Generate one from the workspace root:
cargo run -p calib-targets --example generate_printable -- \
testdata/printable/puzzleboard_small.json \
tmpdata/printable/puzzleboard_small
Print the SVG at 100 percent scale. The generated PNG is intended for previews and regression tests.
calib-targets-marker
calib-targets-marker targets a checkerboard marker board: a chessboard grid with three circular markers near the center. The detector is grid-first and works with partial boards.
Detected circle markers and aligned grid overlay.
For the end-to-end stage map, failure modes, and tuning, see the Marker board pipeline. This page is the crate API reference.
Detection pipeline
- Chessboard detection: run
calib-targets-chessboardto obtain grid-labeled corners (partial boards are allowed). - Per-cell circle scoring: for every valid square cell, warp the cell to a canonical patch and score a circle by comparing a disk sample to an annular ring.
- Candidate filtering: keep the strongest circle candidates per polarity.
- Circle matching: match candidates to the expected layout (cell coordinates + polarity).
- Grid alignment estimation: derive a dihedral transform + translation from detected grid coordinates to board coordinates when enough circles agree.
Key types
MarkerBoardDetector: main entry point.MarkerBoardSpec: rows/cols plus the three expected circles (cell coordinate + polarity).MarkerBoardParams: board layout + chessboard params + circle score + match settings.MarkerBoardDetection:detection:TargetDetectionlabeled asCheckerboardMarker.alignment: optionalGridAlignmentsemantic alias for the canonical affineGridTransform, from detected grid coordinates to board coordinates.
MarkerBoardDiagnostics(opt-in, from thediagnose/diagnose_with_cornersentry points):circle_candidates: scored circles per cell.circle_matches: matched circles (with offsets).inliers: per-corner provenance back into the input ChESS-corner slice.alignment_inliers: 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_positionis populated).circles: threeMarkerCircleSpecentries withcell(top-left corner indices) andpolarity.circle_diameter_rel: printed disc diameter as a fraction of the square side (default0.5). Every radius the circle scorer probes is relative to this, so it is the one place the disc size is stated — it matchescircle_diameter_relon the printable spec.
MarkerBoardParams configures detection:
board: theMarkerBoardSpecto detect.chessboard:ChessboardParamsfor the underlying corner-grid step. The chessboard detector is scale-invariant, so the v1expected_rows/colsandcompleteness_thresholdknobs no longer apply — the marker circles supply the geometry constraint.circle_score: per-cell circle scoring parameters.match_params: candidate filtering and matching thresholds.roi_cells: optional cell ROI[i0, j0, i1, j1].
CircleScoreParams controls scoring:
patch_size: canonical square size in pixels.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 to3— 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). alignmentmaps 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:
whiteon an eveni + j,blackon an odd one. Any other combination draws a disc the same colour as its square, andcalib-targets-printrejects 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_versiontarget:chessboard,charuco,marker_board,puzzle_board, orpuzzlepolepage: size, orientation, and margin in millimetersrender: 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.jsontestdata/printable/marker_board_a4.jsontestdata/printable/puzzleboard_small.jsontestdata/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 + 2pieces 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.jsontestdata/printable/charuco_inner_square.jsontestdata/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::printablewhen you want the published Rust facade crate. - Use
calib-targets-printwhen you want the dedicated published printable-target crate. - Use the
calib-targetsCLI (cargo install calib-targetsorpip install calib-targets) when you want a command-line init/render tool. - Use the Python bindings when your downstream workflow is already in Python.
calib-targets (facade)
The calib-targets crate is the unified entry point for the workspace. It re-exports the lower-level crates and provides optional end-to-end helpers in calib_targets::detect (feature image, enabled by default).
Facade examples cover detection and rectification workflows.
Single-config detection
Each detect_* function takes the image plus a params struct. Where the
ChESS corner front-end is configured depends on the target shape — and the
asymmetry is deliberate:
- Plain chessboards are corner-cloud consumers, so
detect_chessboardtakes the front-end explicitly:detect_chessboard(&img, &chess_cfg, ¶ms). Passdetect::default_chess_config()unless you need to tune it. Because the chessboard detector is reusable on any corner cloud (custom upstreams, pre-detected corners), the facade keeps its corner config separable. - ChArUco, PuzzleBoard, and marker boards own their whole-image
pipeline, so the front-end travels on the params bundle as
params.chess(defaulting todefault_chess_config()); these entry points take no separate front-end argument. Their grid-step tuning lives under a separateparams.chessboardfield (aChessboardParams).
#![allow(unused)]
fn main() {
use calib_targets::detect;
use calib_targets::chessboard::ChessboardParams;
let img = calib_targets::image::open("board.png").unwrap().to_luma8();
let params = ChessboardParams::default();
let result = detect::detect_chessboard(&img, &detect::default_chess_config(), ¶ms);
}
Detector structs and free functions are one surface
Each detect_* free function is the detector one-liner: construct the
detector from params, then call detect. Reach for whichever fits — the
free function when a single call is all you need, the detector struct when
you configure once and detect many times, or when you want the
corner-reuse entry points below.
CharucoDetector, PuzzleBoardDetector, and MarkerBoardDetector each
expose the same five operations, mirrored by five free functions per
target (charuco, puzzleboard, marker_board):
detect(&view)/detect_t(img, params)— run the ChESS corner pass (fromparams.chess) and the whole pipeline.detect_with_corners(&view, &corners)/detect_t_with_corners(img, corners, params)— skip the corner pass and use corners you already have.diagnose(&view)/diagnose_t(img, params)(diagnosticsfeature) — likedetect, plus a diagnostics report.diagnose_with_corners(&view, &corners)/diagnose_t_with_corners(img, corners, params)(diagnosticsfeature) — likedetect_with_corners, plus diagnostics.detect_t_best(img, configs)— try several configs, keep the richest result.
detect_corners on a detector runs exactly the ChESS pass that detector’s
own params.chess configures, so corners you inject match what detect
would have produced on its own. The one case that genuinely wants
detect_with_corners: running a single corner pass across several target
detectors on the same image, rather than paying for it once per detector.
let corners = charuco_detector.detect_corners(&view);
let a = charuco_detector.detect_with_corners(&view, &corners)?;
let b = puzzle_detector.detect_with_corners(&view, &corners)?;
ChessboardDetector keeps its original, corner-cloud-only shape described
above — it has no chess field to run a corner pass from, by design: it
is embedded inside all three composite params types, so a nested
corner-detector config there would be dead in exactly the way a chess
field on ChessboardParams itself would be. Configure the corner pass for
a chessboard through detect_chessboard’s explicit chess_cfg argument
instead.
Multi-config sweep
For challenging images (uneven lighting, Scheimpflug optics), try multiple parameter configs and keep the best result:
#![allow(unused)]
fn main() {
use calib_targets::detect;
use calib_targets::charuco::{CharucoBoardSpec, CharucoParams};
use calib_targets::aruco::builtins;
let img = calib_targets::image::open("charuco.png").unwrap().to_luma8();
let board = CharucoBoardSpec::new(22, 22, 1.0, 0.75, builtins::DICT_4X4_1000)
.with_marker_layout(calib_targets::charuco::MarkerLayout::OpenCvCharuco);
let configs = CharucoParams::sweep_for_board(&board);
let result = detect::detect_charuco_best(&img, &configs);
}
sweep_for_board() returns three configs with different ChESS thresholds
(default, high, low). detect_charuco_best tries each and returns the result
with the most markers (then most corners).
PuzzleBoard follows the same facade shape. Its sweep also includes a hard-weighted fallback for high-distortion fragments:
#![allow(unused)]
fn main() {
use calib_targets::detect;
use calib_targets::puzzleboard::{PuzzleBoardParams, PuzzleBoardSpec};
let img = calib_targets::image::open("puzzleboard.png").unwrap().to_luma8();
let spec = PuzzleBoardSpec::new(10, 10, 12.0).unwrap();
let configs = PuzzleBoardParams::sweep_for_board(&spec);
let result = detect::detect_puzzleboard_best(&img, &configs);
}
Features
image(default): enablescalib_targets::detect.tracing: enables tracing output across the subcrates.diagnostics(off): forwards to thediagnosticsfeature of the chessboard, ChArUco, puzzleboard, and marker subcrates, gating their serializable trace surfaces (the chessboardtrace_topological/GeometryCheckTrace, the ChArUco / puzzleboard per-component decode diagnostics, and the marker-board circle-hypothesis diagnostics). The detectors build no per-stage trace on the hotdetect_*paths unless this is enabled (thedatasetfeature oncalib-targets-chessboardimplies it). The ChArUco, puzzleboard, and marker diagnostics are gated behind the same feature as chessboard rather than being always-on.
See the Migration Guide for the full breaking-change list when upgrading from an earlier release.
Examples
Examples live under crates/*/examples/ and are built per crate. The
facade examples under calib-targets take an image path directly;
the lower-level crate examples synthesize their own inputs or accept a
JSON config file (defaults point to testdata/ or tmpdata/).
To run an example from the workspace root:
# Standalone projective-grid — synthesizes its own oriented features:
cargo run -p projective-grid --example hello_grid
# Image-in / detection-out via the facade crate:
cargo run -p calib-targets --example detect_chessboard -- testdata/mid.png
The standalone projective-grid crate ships
three onboarding examples that need no image files — hello_grid
(the minimal detect-a-grid quickstart), detect_square_oriented2 (a
larger detection run), and check_square_consistency (scoring
caller-supplied labels). The image-free chessboard detector has its
own minimal onboarding program, cargo run -p calib-targets-chessboard --example detect_chessboard.
Python examples live under crates/calib-targets-py/examples/ and use the calib_targets module.
After maturin develop, run them with an image path, for example:
python crates/calib-targets-py/examples/detect_charuco.py testdata/small2.png
python crates/calib-targets-py/examples/detect_puzzleboard.py testdata/puzzleboard_small.png
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 a2×2seed.DegenerateGeometry— coincident or collinear points; no usable lattice spread.InconsistentInput(String)— input slices disagree or carry duplicatesource_indexhandles.
4. Read the result
A successful detection is a GridDetection:
grid(): &LabelledGrid— the recovered component.entries()is oneGridEntryper placed feature;bbox()is the inclusive coordinate bounding box;dimensions()echoes any caller-suppliedGridDimensions.fit(): &LatticeFit— the mandatory fitted model-plane-to-image projective transform (model_to_image) plus a residual summary (residuals.count,residuals.mean_px,residuals.max_px).
Rejected features and exact stage snapshots are available separately through
the opt-in diagnostics feature, so they do not expand the mandatory result.
Each GridEntry carries:
coord: Coord— the(i, j)label ascoord.u/coord.v, rebased so the labelled bounding box starts at(0, 0).source_index: usize— back into the input slice.image_position: Point2<f32>— the feature’s image-frame pixel-center position.residual_px: Option<f32>— reprojection residual in pixels, present when a fit was computed.
#![allow(unused)]
fn main() {
for entry in detection.grid().entries() {
// coord.u = i, coord.v = j; source_index maps back to the input.
println!(
"(i={}, j={}) <- feature {} at ({:.1}, {:.1})",
entry.coord.u,
entry.coord.v,
entry.source_index,
entry.image_position.x,
entry.image_position.y,
);
}
}
Running it labels all nine features (0,0) through (2,2) with a
sub-pixel fit residual.
Going further
- Multiple components —
detect_grid_allreturns aVec<GridDetection>, ordered by labelled count descending. Use it when the lattice fragments into islands (for example by occlusion) and the secondary components matter. The topological algorithm may yield several components. - Checking caller-supplied labels — when
(i, j)labels already exist (for instance from a marker decode),check_consistencyscores them against a single projective fit instead of recovering them from scratch. The runnable version iscrates/projective-grid/examples/check_square_consistency.rs. - A larger detection run —
crates/projective-grid/examples/detect_square_oriented2.rsexercises the sameEvidence::Oriented2path on a bigger synthetic grid.
See the projective-grid chapter for the full
model — the two lattice families, the Evidence shapes, and the
topological algorithm.
Chessboard Detection Example
Reference: crates/calib-targets/examples/detect_chessboard.rs —
end-to-end image-in / detection-out using the facade’s default
chessboard configuration.
Example output overlay for chessboard detection on testdata/mid.png.
Quick run
cargo run --release -p calib-targets --example detect_chessboard -- testdata/mid.png
The example:
- Decodes the image with
image::open(...).to_luma8(). - Calls
calib_targets::detect::detect_chessboard(&img, &ChessboardParams::default()). - Prints the detected
Detection— labelled corner count, cell size, the two grid-direction angles, and every(i, j) → pixel_positionpair.
If detection fails (None), rerun with the _best helper, which
tries three pre-tuned configs (default + tighter + looser) and returns
whichever produced the most labelled corners:
cargo run --release -p calib-targets --example detect_chessboard_best -- testdata/mid.png
Instrumentation
The chessboard crate’s diagnostic entry point is
calib_targets_chessboard::trace_topological(corners, params),
which returns a serializable TopologicalTrace layered over the
production detect_grid_all path (so the trace matches what detect()
actually does). It records every input corner with its usable flag, the
labelled connected components ((u, v) -> source_index), and summary
counters; the final-check drop accounting lives in the pipeline’s
GeometryCheckTrace. See
The Chessboard Detector §7
for the field-by-field walkthrough. Both trace_topological and the
TopologicalTrace type are gated behind the chessboard crate’s
off-by-default diagnostics feature.
The crates/calib-targets-py/examples/overlay_chessboard.py script draws
labelled corners in gold with their (i, j) text, grid edges,
cluster-direction tangent lines, and the faint grey input-corner cloud as
context.
Direct crate-level usage
If you need control over the ChESS corner front-end (e.g., custom
ChessConfig), bypass the facade:
#![allow(unused)]
fn main() {
use calib_targets::detect::{default_chess_config, detect_corners};
use calib_targets_chessboard::{ChessboardDetector, ChessboardParams};
use image::ImageReader;
let img = ImageReader::open("board.png").unwrap().decode().unwrap().to_luma8();
let chess_cfg = default_chess_config();
let corners = detect_corners(&img, &chess_cfg);
let params = ChessboardParams::default();
let detector = ChessboardDetector::new(params);
if let Some(detection) = detector.detect(&corners) {
println!("{} labelled corners", detection.corners.len());
}
}
ChessboardDetector::detect_all(&corners) returns every same-board component
found in the scene (see the chessboard chapter for
the multi-component contract).
Global Rectification Example
File: crates/calib-targets-chessboard/examples/rectify_global.rs
This example detects a chessboard and computes a single global homography to produce a rectified board view. The output includes:
Global rectification output from the small test image.
- A rectified grayscale image.
- A JSON report with homography matrices and grid bounds.
The code defaults to tmpdata/rectify_config.json, but a ready-made config exists in
testdata/rectify_config.json (input: testdata/small.png, rectified output:
tmpdata/rectified_small.png, report: tmpdata/charuco_report_small.json).
Run it with:
cargo run -p calib-targets-chessboard --example rectify_global -- testdata/rectify_config.json
If rectification succeeds, the rectified image is written to tmpdata/rectified.png unless
overridden in the config.
Mesh Rectification Example
File: crates/calib-targets-aruco/examples/rectify_mesh.rs
This example detects a chessboard, performs per-cell mesh rectification, and scans the rectified grid for ArUco markers. It writes:
Per-cell mesh rectification output from the small test image.
- A mesh-rectified grayscale image.
- A JSON report with rectification info and marker detections.
The code defaults to testdata/rectify_mesh_config_small0.json, and that config is a good
starting point (input: testdata/small0.png, mesh output: tmpdata/mesh_rectified_small0.png,
report: tmpdata/rectify_mesh_report_small0.json).
Run it with:
cargo run -p calib-targets-aruco --example rectify_mesh -- testdata/rectify_mesh_config_small0.json
This is a good reference for the full grid -> rectification -> marker scan pipeline.
Data and tools
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.
| Path | What it is |
|---|---|
testdata/small.png, mid.png, large.png | ChArUco boards at three scales; small is partially visible |
testdata/small0.png … small5.png | The 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.png | Marker board, whole and cropped |
testdata/printable/*.json | Printable-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.