Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

calib-targets-rs is a workspace of Rust crates for detecting and modeling planar calibration targets from corner clouds (for example, ChESS corners). The focus is geometry-first: target modeling, grid fitting, and rectification live here, while image I/O and corner detection are intentionally out of scope.

ChArUco detection overlay ChArUco detection overlay on a small board.

What it is:

  • A small, composable set of crates for chessboard, ChArUco, and marker-style targets.
  • A set of geometric primitives (homographies, rectified views, grid coords).
  • Practical examples and tests based on the chess-corners crate.

What it is not:

  • A replacement for your corner detector or image pipeline.
  • A full calibration stack (no camera calibration or PnP here).

Recommended reading order:

  1. Project Overview and Conventions
  2. Pipeline Overview
  3. Crate chapters, starting with calib-targets-core and calib-targets-chessboard

API docs.

Quickstart

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

cargo add calib-targets image

Minimal chessboard detection:

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

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

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

Python bindings

Python bindings are built with maturin:

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

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

MSRV: Rust 1.70 (stable).

Getting Started: From Target to Calibration Data

This tutorial walks you through the complete workflow:

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

No prior knowledge of the library is assumed.


Step 1: Choose your target type

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

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

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


Step 2: Generate a printable target

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

Python

pip install calib-targets
import calib_targets as ct

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

For a plain chessboard instead:

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

CLI (from source)

# list available ArUco dictionaries
cargo run -p calib-targets-cli -- list-dictionaries

# initialise a spec, validate, then render
cargo run -p calib-targets-cli -- 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

cargo run -p calib-targets-cli -- validate --spec my_board/charuco_a4.json
cargo run -p calib-targets-cli -- 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.CharucoDetectorParams.for_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.detection.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.detection.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.3"
image = "0.25"
use calib_targets::detect;
use calib_targets_charuco::{CharucoBoardSpec, CharucoDetectorParams, MarkerLayout};
use image::ImageReader;

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

    let board = CharucoBoardSpec {
        rows: 5,
        cols: 7,
        cell_size: 20.0,      // mm
        marker_size_rel: 0.75,
        dictionary: "DICT_4X4_50".parse()?,
        marker_layout: MarkerLayout::OpencvCharuco,
        ..Default::default()
    };

    let params = CharucoDetectorParams::for_board(&board);
    let chess_cfg = detect::default_chess_config();

    let result = detect::detect_charuco(&img, &chess_cfg, params)?;
    println!(
        "corners: {}, markers: {}",
        result.detection.corners.len(),
        result.markers.len()
    );

    // Collect point pairs
    for c in &result.detection.corners {
        if let Some(tp) = c.target_position {
            let obj = [tp[0], tp[1], 0.0_f32];
            let img = c.position;
            // pass (obj, img) to your calibration solver
            let _ = (obj, img);
        }
    }
    Ok(())
}

Next steps

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

Tuning the Detector

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

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::{default_chess_config, detect_chessboard};
use calib_targets::ChessboardParams;

let chess_cfg = default_chess_config();       // ChESS corner detector config
let params    = ChessboardParams::default();  // chessboard assembly params
}

For ChArUco:

#![allow(unused)]
fn main() {
use calib_targets_charuco::CharucoDetectorParams;

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

default_chess_config() sets px_per_square = 60 pixels. This value is chosen so that each bit cell in a 4×4 marker is ~15 px wide, keeping Otsu binarization reliable above the empirical 4 px / bit-cell minimum. If your image has a very different scale, adjust px_per_square proportionally before touching any other parameter.


Symptom → parameter table

SymptomParameter to adjust
ChessboardNotDetectedmin_corners ↓, min_corner_strength
Grid too small / partial boardcompleteness_threshold
Detects wrong connected componentexpected_rows / expected_cols → set explicitly
Fast perspective / wide-angle lensmax_spacing_pix ↑, orientation_tolerance_deg
Dense board, corners falsely mergedmin_spacing_pix
NoMarkers on blurry imagemin_border_score ↓, multi_threshold: true
AlignmentFailed (low inlier count)min_marker_inliers

Per-parameter reference: ChessboardParams

min_corner_strength

Default: 0.0 (accept everything from the ChESS detector).

When to raise: On real-world images with textures that produce many spurious saddle points, raise to 0.30.5 to drop weak corners before graph construction. Raising too far discards valid but low-contrast corners near board edges.

min_corners

Default: 16.

Guidance: Set to roughly 70 % of the expected inner-corner count for your board (e.g. 7 × 9 inner corners → min_corners = 44). Lowering allows partial detections; raising avoids spurious small detections.

expected_rows / expected_cols

Default: None (auto-detect from the largest connected component).

When to set: If the scene contains multiple chessboard-like objects and the wrong one is returned, set these to the inner corner count of the board you care about. The detector will only accept a component that matches these dimensions.

completeness_threshold

Default: 0.7.

Guidance: The fraction of expected corners that must be found for the detection to be accepted. Lower to 0.30.5 when the board is partially occluded or at the image border. Lower to 0.05 when exploring a very large, partially-visible board.

use_orientation_clustering

Default: true.

When to disable: Only on synthetic or perfectly axis-aligned images where all corners lie on a regular grid. On real images, orientation clustering is critical for separating the two edge directions and should remain on.


Per-parameter reference: GridGraphParams

min_spacing_pix

Default: 5.0 pixels.

Guidance: Minimum distance between two corners for them to be considered separate nodes. Raise (e.g. to 1020) when corners are dense and two nearby ChESS responses correspond to a single physical corner, causing false links.

max_spacing_pix

Default: 50.0 pixels.

Guidance: Maximum edge length in the proximity graph. For high-resolution images or large printed boards, raise to roughly image_width / expected_cols / 2. If too small, the graph is disconnected and large grids are not assembled.

k_neighbors

Default: 8.

Guidance: Number of nearest neighbors considered per corner during graph construction. Rarely needs tuning. Lower values (e.g. 4) can speed up graph construction on very large corner sets at the cost of slightly lower robustness to uneven corner spacing.

orientation_tolerance_deg

Default: 22.5 degrees.

Guidance: Tolerance for the angular difference between an edge direction and the dominant grid orientation. Tighten to 1015° in structured indoor scenes with many false corners (e.g. tile patterns). Relax to 30° or more for extreme perspective or a handheld camera at a steep angle.


Per-parameter reference: ScanDecodeConfig / ChArUco

These parameters live inside CharucoDetectorParams.

min_border_score

Default: 0.75 for ChArUco.

Guidance: Minimum contrast score for the black border ring around a marker. Lower cautiously to 0.65 for very blurry images. Values below 0.60 risk accepting non-marker regions.

multi_threshold

Default: true.

Guidance: When enabled, the decoder tries several Otsu-style binarization thresholds until a dictionary match is found. This handles uneven lighting and motion blur at the cost of a small speed penalty. Disable only when speed is critical and lighting is controlled.

inset_frac

Default: 0.06 for ChArUco.

Guidance: Fraction of the cell size inset from the cell boundary before sampling the marker interior. Raise to 0.100.12 when the cell boundary visibly bleeds into the bit area (common with thick printed borders or strong blur).

marker_size_rel

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

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


Quick checklist

  1. Start with defaults; run with RUST_LOG=debug to see corner counts and alignment scores.
  2. If no corners are found: loosen min_corner_strength, check image resolution.
  3. If corners found but no grid: check max_spacing_pix vs. actual square size.
  4. If grid found but no markers: enable multi_threshold, lower min_border_score.
  5. If alignment fails: verify board spec (rows, cols, dictionary, marker_size_rel).

See also: Troubleshooting for per-error checklists.

Troubleshooting

This chapter maps each error variant to a diagnostic checklist. For parameter descriptions, see Tuning the Detector.


Reading the debug log

Enable debug logging before anything else:

RUST_LOG=debug cargo run --example detect_charuco -- testdata/small2.png

Or from code:

#![allow(unused)]
fn main() {
tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init();
}

Key log lines and what they tell you:

Log lineMeaning
input_corners=NN ChESS corners passed the strength filter
chessboard stage failed: ...Grid assembly error; reason follows
marker scan produced N detectionsN cells decoded a valid marker ID
alignment result: inliers=NN markers matched the board spec
cell (x,y) failed decodeThat cell did not match any dictionary entry
cell (x,y) passed threshold but no dict matchBinarization ok, wrong dictionary

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


ChessboardNotDetected

The chessboard assembly stage found fewer corners than min_corners, or could not form a connected grid from the detected corners.

Checklist:

  1. How many corners were detected? Look for input_corners=N in the log.

    • If N < min_corners: lower min_corners or lower min_corner_strength.
    • If N is zero or very small: the ChESS detector found nothing. Check image resolution — px_per_square in default_chess_config() should be close to the actual pixel size of one board square.
  2. Corners found but grid assembly fails?

    • Check max_spacing_pix: if the physical board squares are larger than this value in pixels, the graph edges are pruned and the grid cannot be assembled.
    • Check min_spacing_pix: if two ChESS responses land on the same corner, they may confuse the graph. Raise min_spacing_pix.
  3. Orientation clustering failing? If the board is close to axis-aligned and the two corner directions are not well separated, try setting use_orientation_clustering = false (synthetic / controlled images only).

  4. Multiple boards in the scene? Set expected_rows / expected_cols so the detector only accepts the correct grid size.


NoMarkers

All ChESS corners were found and the chessboard grid was assembled, but no ArUco/AprilTag marker was decoded inside any cell.

Checklist:

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

  2. Correct marker_size_rel? If the sampled region is the wrong fraction of the cell, the bit cells will be misaligned. Verify against the board spec.

  3. Blurry image?

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

  5. Wrong scale? If px_per_square is far from the actual pixel size, the projective warp used for cell sampling will produce a very small or very large patch. Adjust px_per_square in ChessConfig.


AlignmentFailed { inliers: N }

Markers were decoded, but fewer than min_marker_inliers of them matched the board specification in a geometrically consistent way.

Checklist:

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

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

    • Board is partially visible — lower min_marker_inliers to the number of markers you reliably expect to see.
    • Strong perspective distortion — the homography RANSAC may not converge. Raise orientation_tolerance_deg so more corners enter the initial grid.
  3. inliers near threshold:

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

Common image problems

ProblemRecommended fix
Strong blurLower min_border_score to 0.65, enable multi_threshold
Uneven / gradient lightingmulti_threshold (already default)
Strong perspective / wide-angleRaise max_spacing_pix, raise orientation_tolerance_deg
Partial occlusionLower completeness_threshold, lower min_marker_inliers
Very small board in frameRaise px_per_square to match actual pixel size
Very large board / high-res imageRaise max_spacing_pix to ≥ image_width / cols / 2
Multiple boards in frameSet expected_rows / expected_cols explicitly
Specular reflections on boardPre-process with local contrast normalization (CLAHE)

Getting more help

Understanding Detection Results

This chapter describes every field of TargetDetection and LabeledCorner, explains when optional fields are populated, and gives guidance on interpreting score values.


TargetDetection

#![allow(unused)]
fn main() {
pub struct TargetDetection {
    pub kind:    TargetKind,
    pub corners: Vec<LabeledCorner>,
}
}

kind identifies the target type:

VariantProduced by
TargetKind::Chessboarddetect_chessboard
TargetKind::Charucodetect_charuco (embedded in CharucoDetectionResult)
TargetKind::CheckerboardMarkerdetect_marker_board (embedded in MarkerBoardDetectionResult)

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.
  • Marker board: ordered by grid coordinates (i, j).

LabeledCorner fields

#![allow(unused)]
fn main() {
pub struct LabeledCorner {
    pub position:        [f32; 2],
    pub grid:            Option<[i32; 2]>,
    pub id:              Option<u32>,
    pub target_position: Option<[f32; 2]>,
    pub score:           f32,
}
}

position

Pixel coordinates of the detected corner in the input image.

  • Origin: top-left.
  • X axis: right; Y axis: down.
  • Sub-pixel accuracy; values are not rounded to integer pixels.

grid(i, j)

Integer corner coordinates within the detected grid.

  • i = column index (increases right).
  • j = row index (increases downward).
  • Origin: top-left corner of the detected region (not necessarily the top-left of the physical board — the detector does not know board orientation).

Always populated for chessboard and marker board detections. Populated for ChArUco when a board spec is provided (i.e., when alignment succeeds).

id

Logical marker corner ID. ChArUco only.

Each inner corner of a ChArUco board is shared by two squares and is assigned a unique integer ID by the board specification. This ID is identical to the one used by OpenCV’s aruco::CharucoBoard. For chessboard and marker board detections, id is always None.

target_position

Real-world position of the corner in board units (typically millimeters when cell_size is given in mm).

Target typeWhen populated
ChessboardNever (no physical size in ChessboardParams)
ChArUcoAlways when board.cell_size > 0 and alignment succeeds
Marker boardOnly when layout.cell_size > 0 and alignment succeeds

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

score

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

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

Interpretation:

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

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


ChArUco-specific: CharucoDetectionResult

detect_charuco returns CharucoDetectionResult rather than a bare TargetDetection:

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

markers

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

  • id: decoded dictionary ID.
  • border_score: the contrast score for the border ring (maps to score in LabeledCorner for marker-anchored corners).
  • code: the raw decoded bit pattern (before dictionary lookup).
  • rotation: 0/1/2/3 clockwise 90° rotations applied to normalise the marker.

alignment

When not None, carries the affine/homographic mapping between board-grid coordinates and image-pixel coordinates. Populated when at least min_marker_inliers markers agree on a consistent geometric transformation. Use this to project additional board points into the image without re-running detection.


FAQ

Q: Why are grid coordinates not always the same as the printed board coordinates?

The detector builds the grid from scratch without knowing which corner is the board’s physical origin. The (0, 0) origin is always the top-left of the detected region in the image, not the physical board corner labelled (0, 0) by the manufacturer. Use id (ChArUco) or target_position to obtain board-canonical positions.

Q: Can I use target_position directly for solvePnP?

Yes. Pair each LabeledCorner.position (image point) with the corresponding LabeledCorner.target_position (object point) and pass them to solvePnP or your calibration solver. Filter to corners where target_position.is_some() first.

Q: What is a normal score for a well-printed board under good lighting?

Typical values are 0.880.97. Scores consistently below 0.80 suggest image blur, poor print quality, or an incorrect inset_frac.

Project Overview

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

Mesh-rectified grid

Workspace layout

  • calib-targets-core: shared geometry types and utilities.
  • calib-targets-chessboard: chessboard detection from corner clouds.
  • calib-targets-aruco: embedded dictionaries and decoding on rectified grids.
  • calib-targets-charuco: grid-first ChArUco detector and alignment.
  • calib-targets-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.

Conventions

These conventions are used throughout the workspace. They are not optional and should not change silently.

Coordinate systems

  • Image pixels: origin at top-left, x increases right, y increases down.
  • Grid coordinates: i increases right, j increases down.
  • Grid indices in detections are corner indices (intersections), not square indices, unless explicitly stated otherwise.

Homography and quad ordering

  • Quad corner order is always TL, TR, BR, BL (clockwise).
  • The ordering must match in both source and destination spaces.
  • Never use self-crossing orders like TL, TR, BL, BR.

Sampling and pixel centers

  • Warping and sampling should be consistent about pixel centers.
  • When in doubt, treat sample locations as (x + 0.5, y + 0.5) in pixel space.

Orientation angles

  • ChESS-style corner orientations are in radians and defined modulo pi (not 2*pi).
  • Orientation clustering finds two dominant directions and assigns each corner to cluster 0 or 1, or marks it as an outlier.

Marker bit conventions

  • Marker codes are packed in row-major order.
  • Black pixels represent bit value 1.
  • Border width is defined in whole cells (border_bits).

If you introduce new algorithms or data structures, document any additional conventions in the relevant crate chapter.

Pipeline Overview

At a high level, the workflow looks like this:

  1. Input corners: supply a list of calib_targets_core::Corner values, typically from a ChESS detector.
  2. Estimate grid axes: cluster corner orientations to get two dominant grid directions.
  3. Build a grid graph: connect corners that plausibly lie on the same grid lines.
  4. Assign integer coordinates: BFS the graph to produce (i, j) grid indices.
  5. Select the best board: choose the best connected component that matches expected size.
  6. Rectify (optional): compute a global homography or mesh warp to build a rectified view.
  7. Decode markers (optional): decode per-cell directly, or scan a rectified grid if you need a full rectified image.
  8. Align board (optional): map markers to a known layout and assign corner IDs.

Output types are standardized in calib-targets-core as TargetDetection with LabeledCorner values. Higher-level crates enrich that output with additional metadata (inliers, marker detections, rectified views).

Crates

The workspace is organized as a stack of crates with minimal, composable boundaries.

Dependency direction

  • calib-targets-core is the base and should not depend on higher-level crates.
  • calib-targets-chessboard depends on core for geometry and types.
  • calib-targets-aruco depends on core for rectified image access.
  • calib-targets-charuco depends on chessboard and aruco.
  • calib-targets-marker depends on chessboard and core.
  • calib-targets-print depends on the target crates and owns printable-target rendering.
  • calib-targets is the facade that re-exports types and offers end-to-end helpers.

Python bindings

Python bindings are provided by the calib-targets-py crate (module name calib_targets). It depends on the facade crate and is built with maturin; see crates/calib-targets-py/README.md in the repository root.

Where to start

If you are new to the codebase, start with:

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

Then branch into the target-specific crates depending on your use case.

calib-targets-core

calib-targets-core provides shared geometric types and utilities. It is intentionally small and purely geometric; it does not depend on any particular corner detector or image pipeline.

Rectified grid view Global rectification output from the chessboard pipeline.

Core data types

  • Corner: raw corner observations from your detector.
    • position: image-space pixel coordinates.
    • orientation: dominant grid orientation in radians, defined modulo pi.
    • orientation_cluster: optional cluster index (0 or 1) if clustering is used.
    • strength: detector response.
  • GridCoords: integer grid indices (i, j) in board space.
  • LabeledCorner: a detected corner with optional grid coordinates and logical ID.
  • TargetDetection: a collection of labeled corners for one board instance.
  • TargetKind: enum for Chessboard, Charuco, or CheckerboardMarker.

These types are shared across all detectors so downstream code can be target-agnostic.

Orientation clustering

ChESS corner orientations are only defined modulo pi. The clustering utilities help recover two dominant grid directions:

  • cluster_orientations: histogram-based peak finding followed by 2-means refinement.
  • OrientationClusteringParams: histogram size, separation thresholds, outlier rejection.
  • compute_orientation_histogram: debug visualization helper.
  • estimate_grid_axes_from_orientations: a lightweight fallback when clustering fails.

Chessboard detection uses these helpers to label corners by axis and to reject outliers.

Homography and rectification

Homography is a small wrapper around a 3x3 matrix with helpers for DLT estimation and point mapping:

  • estimate_homography_rect_to_img: DLT with Hartley normalization for N >= 4 point pairs.
  • homography_from_4pt: direct 4-point estimation.
  • warp_perspective_gray: warp a grayscale image using a homography.

For mapping rectified pixels back to the original image, core defines:

  • RectifiedView: a rectified grayscale image and its mapping info.
  • RectToImgMapper: either a single global homography or a per-cell mesh map.

Higher-level crates (notably chessboard) wrap these utilities for global or mesh rectification.

Image utilities

GrayImage and GrayImageView are lightweight, row-major grayscale buffers with bilinear sampling helpers:

  • sample_bilinear: float sampling with edge clamp to 0.
  • sample_bilinear_u8: u8 sampling with clamping to 0..255.

These utilities are used by rectification and marker decoding.

Conventions recap

  • Coordinate system: origin at top-left, x right, y down.
  • Grid coordinates: i right, j down, and grid indices refer to corners.
  • Quad order: TL, TR, BR, BL in both source and destination spaces.

If you build on core types, stick to these conventions to avoid subtle alignment bugs.

calib-targets-chessboard

calib-targets-chessboard detects a plain chessboard from a cloud of ChESS corners. It is graph-based and perspective-aware, and it returns integer grid coordinates for each detected corner.

Chessboard detection overlay Detected chessboard corners overlaid on the source image.

Detection pipeline

The detector follows these steps (see ChessboardDetector):

  1. Filter corners by minimum strength.
  2. Estimate two dominant grid axes from corner orientations.
  3. Estimate a base spacing from nearest-neighbor distances.
  4. For each corner, find up to 4 neighbors (right/left/up/down) based on distance and orientation consistency.
  5. Build a 4-connected undirected grid graph.
  6. BFS each connected component and assign integer (i, j) coordinates.
  7. Compute width, height, and completeness per component.
  8. Keep the best component that matches expected size and completeness thresholds.

Currently the detector returns at most one board instance (the best-scoring component).

Key types

  • ChessboardDetector: main entry point.
  • ChessboardParams: detection thresholds and expected board size.
  • GridGraphParams: neighbor search and geometric constraints.
  • ChessboardDetectionResult:
    • detection: TargetDetection with labeled corners.
    • inliers: indices into the corner list used for rectification.
    • orientations: estimated grid axes (optional).
    • debug: optional histogram and graph data for diagnostics.

Parameters

ChessboardParams controls high-level validity checks:

  • min_corner_strength: filter weak corners early.
  • min_corners: minimum number of corners to accept a component.
  • expected_rows, expected_cols: inner corner counts in each direction.
  • completeness_threshold: detected / expected corner ratio.
  • use_orientation_clustering: toggle orientation clustering (enabled by default).

GridGraphParams controls how neighbors are chosen:

  • min_spacing_pix, max_spacing_pix: expected corner spacing range in pixels.
  • k_neighbors: how many nearest neighbors to consider.
  • orientation_tolerance_deg: angular tolerance for neighbor relations.

Grid graph details

Neighbor selection uses orientation information in two modes:

  • With clustering: corners are labeled by one of two axis clusters. Candidate edges must align with one of the two grid directions derived from those clusters.
  • Without clustering: orientations are checked for near-orthogonality, and the edge direction must be close to 45 degrees from each corner orientation.

Edges are classified into Right, Left, Up, Down based on image-space directions, and only the best candidate per direction is kept. This yields a clean 4-connected grid graph for BFS.

Rectification helpers

The crate provides two rectification options:

  • rectify_from_chessboard_result: fits a single global homography and produces a RectifiedBoardView.
  • rectify_mesh_from_grid: fits one homography per cell and produces a RectifiedMeshView (more robust to lens distortion).

Both require labeled corners and a chosen px_per_square scale.

Example

#![allow(unused)]
fn main() {
use calib_targets_chessboard::{ChessboardDetector, ChessboardParams};
use calib_targets_core::Corner;

fn detect(corners: &[Corner]) {
    let params = ChessboardParams::default();
    let detector = ChessboardDetector::new(params);

    if let Some(result) = detector.detect_from_corners(corners) {
        println!("detected {} corners", result.detection.corners.len());
    }
}
}

For a full runnable example, see crates/calib-targets-chessboard/examples/chessboard.rs.

calib-targets-aruco

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

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

Current API surface

  • Dictionary: built-in dictionary metadata and packed codes.
  • Matcher: brute-force matching against a dictionary with rotation handling.
  • ScanDecodeConfig: how to scan a rectified grid (border size, inset, polarity).
  • scan_decode_markers: read and decode markers from rectified cells.
  • scan_decode_markers_in_cells: decode markers from per-cell image quads (no full warp).
  • decode_marker_in_cell: decode a single marker inside one square cell.

The crate expects a rectified view where each chessboard square is approximately px_per_square pixels and where cell indices align with the board grid.

Decoding paths

There are two supported scanning modes:

  • Rectified grid scan (scan_decode_markers): build a rectified image first and scan a regular grid.
  • Per-cell scan (scan_decode_markers_in_cells): pass a list of per-cell quads and decode each cell directly.

Per-cell scanning avoids building the full rectified image and is easy to parallelize across cells.

Scan configuration

ScanDecodeConfig controls how bit sampling and thresholding behave:

  • border_bits: number of black border cells (OpenCV typically uses 1).
  • marker_size_rel: marker size relative to the square size (ChArUco uses < 1.0).
  • inset_frac: extra inset inside the marker to avoid edge blur.
  • min_border_score: minimum fraction of border bits that must be black.
  • dedup_by_id: keep only the best detection per marker id.

If decoding is too sparse on real images, reduce inset_frac slightly and re-run.

Conventions

  • Marker bits are packed row-major with black = 1.
  • Match::rotation is in 0..=3 such that observed == rotate(dict_code, rotation).
  • border_bits matches the OpenCV definition (typically 1).

Status

Decoding is implemented and stable for rectified grids, but quad detection and image-space marker detection are deliberately out of scope. The roadmap chapter details planned improvements and API refinements.

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

When to use per-cell decoding

Use per-cell decoding (scan_decode_markers_in_cells) when you already have a grid of square corners and want to avoid warping the full image. It works well with ChArUco detection because you can decode only the valid cells and parallelize across them.

Sampling model

  • Bits are sampled on a regular grid inside the marker area.
  • The marker area is defined by marker_size_rel, with an extra inset from inset_frac.
  • A per-marker threshold (Otsu) is computed from sampled intensities.

Tuning checklist

  • If markers are missing, try reducing inset_frac slightly.
  • If false positives appear, raise min_border_score or enable dedup_by_id.
  • Make sure marker_size_rel matches the physical board spec.

calib-targets-charuco

calib-targets-charuco combines chessboard detection with ArUco decoding to detect ChArUco boards. ChArUco dictionaries and board layouts are fully compatible with OpenCV’s aruco/charuco implementation. The flow is grid-first:

ChArUco detection overlay ChArUco detection overlay with assigned corner IDs.

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

Board specification

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

Detector

  • CharucoDetectorParams::for_board provides a reasonable default configuration.
  • CharucoDetector::detect returns a CharucoDetectionResult with:
    • detection: labeled corners with ChArUco IDs, filtered to marker-supported corners.
    • markers: decoded marker detections in rectified grid coordinates (with optional corners_img).
    • alignment: grid alignment from detected grid coordinates into board coordinates.

Per-cell decoding

The detector decodes markers per grid cell. This avoids building a full rectified image and keeps the work proportional to the number of valid squares. If you need a full rectified image for visualization, use the rectification helpers in calib-targets-chessboard on a detected grid.

Alignment and refinement

Alignment maps decoded marker IDs to board positions using a small set of grid transforms and a translation vote. Once an alignment is found, the detector re-decodes markers at their expected cell locations and re-solves the alignment to filter out inconsistencies.

This two-stage approach helps reject spurious markers while keeping the final corner IDs consistent.

Tuning notes

  • scan.inset_frac trades off robustness vs. sensitivity. The defaults in for_board use a slightly smaller inset (0.06) to improve real-image decoding.
  • min_marker_inliers controls how many aligned markers are required to accept a detection.

Status

The current implementation focuses on the OpenCV-style layout and is intentionally conservative about alignment. Extensions for more layouts and improved robustness are planned (see the roadmap).

For alignment details, see ChArUco Alignment and Refinement.

ChArUco Alignment

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

Alignment pass

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

Inlier filtering

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

calib-targets-marker

calib-targets-marker targets a checkerboard marker board: a chessboard grid with three circular markers near the center. The detector is grid-first and works with partial boards.

Marker-board detection overlay Detected circle markers and aligned grid overlay.

Detection pipeline

  1. Chessboard detection: run calib-targets-chessboard to obtain grid-labeled corners (partial boards are allowed).
  2. Per-cell circle scoring: for every valid square cell, warp the cell to a canonical patch and score a circle by comparing a disk sample to an annular ring.
  3. Candidate filtering: keep the strongest circle candidates per polarity.
  4. Circle matching: match candidates to the expected layout (cell coordinates + polarity).
  5. Grid alignment estimation: derive a dihedral transform + translation from detected grid coordinates to board coordinates when enough circles agree.

Key types

  • MarkerBoardDetector: main entry point.
  • MarkerBoardLayout: rows/cols plus the three expected circles (cell coordinate + polarity).
  • MarkerBoardParams: layout + chessboard/grid graph params + circle score + match settings.
  • MarkerBoardDetectionResult:
    • detection: TargetDetection labeled as CheckerboardMarker.
    • circle_candidates: scored circles per cell.
    • circle_matches: matched circles (with offsets).
    • alignment: optional GridAlignment from detected grid coords to board coords.
    • alignment_inliers: number of circle matches used for the alignment.

Parameters

MarkerBoardLayout defines the board and marker placement:

  • rows, cols: inner corner counts.
  • cell_size: optional square size in your world units (when set, target_position is populated).
  • circles: three MarkerCircleSpec entries with cell (top-left corner indices) and polarity.

MarkerBoardParams configures detection:

  • chessboard: ChessboardParams (defaults to completeness_threshold = 0.05 to allow partial boards).
  • grid_graph: GridGraphParams for neighbor search constraints.
  • circle_score: per-cell circle scoring parameters.
  • match_params: candidate filtering and matching thresholds.
  • roi_cells: optional cell ROI [i0, j0, i1, j1].

CircleScoreParams controls scoring:

  • patch_size: canonical square size in pixels.
  • diameter_frac: circle diameter relative to the square.
  • ring_thickness_frac: ring thickness relative to circle radius.
  • ring_radius_mul: ring radius relative to circle radius.
  • min_contrast: minimum accepted disk-vs-ring contrast.
  • samples: samples per ring for averaging.
  • center_search_px: small pixel search around the cell center.

CircleMatchParams controls matching:

  • max_candidates_per_polarity: top-N candidates to keep per polarity.
  • max_distance_cells: optional maximum distance for a match.
  • min_offset_inliers: minimum agreeing circles to return an alignment.

Notes

  • Cell coordinates (i, j) refer to square cells, expressed by the top-left corner indices. The cell center is at (i + 0.5, j + 0.5).
  • alignment maps detected grid coordinates into board coordinates using a dihedral transform and translation.

calib-targets-print

calib-targets-print is the dedicated crate for printable target generation. The same functionality is also exposed through the published calib-targets facade as calib_targets::printable.

This page is the canonical guide for printable-target generation across the published Rust crates, the repo-local CLI, and the Python bindings.

What it generates

The input is one canonical JSON-backed document with:

  • schema_version
  • target: chessboard, charuco, or marker_board
  • page: size, orientation, and margin in millimeters
  • render: debug overlay toggle and PNG DPI

Generation writes one output bundle:

  • <stem>.json
  • <stem>.svg
  • <stem>.png

The normalized .json file records the exact document that was rendered. SVG and PNG are emitted from the same internal scene description, so they describe the same board geometry.

All physical dimensions are expressed in millimeters. The board is centered in the printable area, and generation fails if the chosen page and margins do not leave enough room.

Concrete example

testdata/printable/charuco_a4.json is the canonical ChArUco example:

{
  "schema_version": 1,
  "target": {
    "kind": "charuco",
    "rows": 5,
    "cols": 7,
    "square_size_mm": 20.0,
    "marker_size_rel": 0.75,
    "dictionary": "DICT_4X4_50",
    "marker_layout": "opencv_charuco",
    "border_bits": 1
  },
  "page": {
    "size": {
      "kind": "a4"
    },
    "orientation": "portrait",
    "margin_mm": 10.0
  },
  "render": {
    "debug_annotations": false,
    "png_dpi": 300
  }
}

Matching examples also exist for chessboard and marker-board targets:

  • testdata/printable/chessboard_a4.json
  • testdata/printable/marker_board_a4.json

Rust quickstart

If you are using the published Rust crates today, you can either depend on the dedicated calib-targets-print crate directly or use the calib-targets facade re-export. The facade path stays shortest when you also want detector APIs:

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

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let doc = PrintableTargetDocument::load_json("testdata/printable/charuco_a4.json")?;
    let written = write_target_bundle(&doc, "tmpdata/printable/charuco_a4")?;

    println!("{}", written.json_path.display());
    println!("{}", written.svg_path.display());
    println!("{}", written.png_path.display());
    Ok(())
}

The same flow is available in the workspace example:

cargo run -p calib-targets --example generate_printable -- \
  testdata/printable/charuco_a4.json \
  tmpdata/printable/charuco_a4

The underlying implementation crate is the published calib-targets-print crate; within this workspace it lives at crates/calib-targets-print.

CLI quickstart

The CLI currently lives in the repo-local crates/calib-targets-cli crate. Today it is the official repo-local app for printable target generation, and it is not published on crates.io.

If you need a valid ChArUco dictionary name, list the built-ins first:

cargo run -p calib-targets-cli -- list-dictionaries

To initialize a ChArUco spec, validate it, and then render it:

cargo run -p calib-targets-cli -- 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

cargo run -p calib-targets-cli -- validate \
  --spec tmpdata/printable/charuco_a4.json

cargo run -p calib-targets-cli -- 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.

If you already have a spec file, generation is a single command:

cargo run -p calib-targets-cli -- generate \
  --spec testdata/printable/charuco_a4.json \
  --out-stem tmpdata/printable/charuco_a4

The current init subcommands are:

  • chessboard
  • charuco
  • marker-board

Other printable workflow commands:

  • list-dictionaries
  • validate
  • generate

Python quickstart

The Python bindings expose the same printable document model and write API:

.venv/bin/python crates/calib-targets-py/examples/generate_printable.py \
  tmpdata/printable/charuco_a4_py

That example constructs a small ChArUco document in Python and writes the same three-file bundle.

Printing guidance

For a physically accurate calibration target:

  • Print at 100% scale or “actual size”.
  • Disable “fit to page”, “scale to fit”, or similar printer-driver options.
  • Prefer the generated SVG when sending the target to a print workflow that preserves vector geometry.
  • After printing, measure at least one known square width with a ruler or caliper and confirm it matches square_size_mm.
  • If the printed size is wrong, fix the print dialog or driver scaling and reprint instead of compensating in calibration code.

Choosing an entry point

  • Use calib_targets::printable when you want the published Rust facade crate.
  • Use calib-targets-print when you want the dedicated published printable-target crate.
  • Use crates/calib-targets-cli when you want a repo-local init/render tool.
  • Use the Python bindings when your downstream workflow is already in Python.

calib-targets (facade)

The calib-targets crate is the unified entry point for the workspace. It re-exports the lower-level crates and provides optional end-to-end helpers in calib_targets::detect (feature image, enabled by default).

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

Current contents

  • Re-exports: core, chessboard, aruco, charuco, marker.
  • detect module: helpers that run ChESS corner detection and then the target detector.
  • Examples under crates/calib-targets/examples/ that take an image path.

Features

  • image (default): enables calib_targets::detect.
  • tracing: enables tracing output across the subcrates.

See the roadmap for future expansion of the facade API.

Examples

Examples live under crates/*/examples/ and are built per crate. Many examples accept a JSON config file (defaults point to testdata/ or tmpdata/), while the facade examples under calib-targets take an image path directly.

To run an example from the workspace root:

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

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

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

Chessboard Detection Example

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

This example runs the full chessboard pipeline:

Chessboard detection overlay Example output overlay for chessboard detection.

  1. Detects ChESS corners using the chess-corners crate.
  2. Adapts them to calib_targets_core::Corner.
  3. Runs ChessboardDetector.
  4. Optionally outputs debug data (orientation histogram, grid graph).

The default config is testdata/chessboard_config.json (input: testdata/mid.png, output: tmpdata/chessboard_detection_mid.json).

Run it with:

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

The output JSON contains detected corners, grid coordinates, and optional debug diagnostics (if debug_outputs are enabled in the config).

Global Rectification Example

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

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

Global rectification output Global rectification output from the small test image.

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

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

Run it with:

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

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

Mesh Rectification Example

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

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

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

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

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

Run it with:

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

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

Data and Tools

This repository includes data and scripts that support testing and debugging.

Data folders

  • testdata/: sample images and JSON configs used by examples and tests.

Tools

The tools/ folder contains helper scripts for visualization and synthetic data generation, for example:

  • Overlay plotting for chessboard, marker, and ChArUco outputs.
  • Synthetic marker target generation.
  • Dictionary utilities.

These tools are optional but useful when debugging or extending detectors.

Roadmap

To be added later