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

ringgrid is a pure-Rust library for detecting dense coded ring calibration targets arranged on a hexagonal lattice. It detects markers with subpixel accuracy, decodes unique IDs from the shipped baseline 893-codeword profile (with an opt-in extended profile available for larger ID spaces), estimates a board-to-image homography, and returns structured results ready for downstream camera calibration.

No OpenCV bindings — all image processing is implemented in Rust.

The Problem

Camera calibration requires detecting fiducial markers — known patterns printed on a calibration target — with high geometric precision. Traditional approaches use checkerboard corners or square markers (ArUco). These patterns have limitations:

  • Checkerboards provide subpixel corner accuracy but carry no per-corner identity, making automatic correspondence ambiguous when the full board is not visible.
  • Square markers (ArUco, AprilTag) encode identity in a binary grid, but their corners are detected via contour intersection, which limits subpixel precision.

ringgrid introduces a different target design: concentric ring markers with binary-coded sectors, arranged on a hex lattice.

The Solution

Each ringgrid marker consists of two concentric rings — an outer ring and an inner ring — separated by a 16-sector binary code band that encodes a unique ID. This design provides three key advantages:

  1. Subpixel edge detection. Ring boundaries produce strong, omnidirectional intensity gradients. The detector samples edge points along radial rays and fits an ellipse using the Fitzgibbon direct least-squares method, achieving center localization well below one pixel.

  2. Projective center correction. Under perspective projection, the center of a fitted ellipse is not the true projected center of the circle. ringgrid fits both the outer and inner ring ellipses and uses their conic pencil to recover the unbiased projected center — without requiring camera intrinsics.

  3. Large identification capacity. The 16-sector binary code band ships with a stable 893-codeword baseline profile at minimum cyclic Hamming distance 2, plus an opt-in 2180-codeword extended profile when larger ID capacity matters more than the baseline ambiguity guarantee without introducing new polarity ambiguity beyond the shipped baseline.

What You Get

The detector returns a DetectionResult containing:

  • A list of DetectedMarker structs, each with:
    • Decoded ID (from the active codebook profile; baseline by default)
    • Subpixel center in image coordinates
    • Board coordinates in millimeters when the ID is valid for the active layout
    • Fitted outer and inner ellipses
    • Quality metrics (fit residuals, decode confidence) and detection source
  • A board-to-image homography (when enough markers are decoded)
  • Coordinate frame metadata describing the output conventions

See Detection Output Format for the exact JSON shape written by the CLI and the corresponding Rust DetectionResult fields.

Detection Modes

ringgrid supports four high-level detection modes:

  1. Simple detection — single-pass detection in image coordinates. No distortion correction.
  2. Adaptive scale detection — multi-tier detection that auto-selects scale bands (or uses explicit tiers) for scenes with large marker size variation.
  3. External pixel mapper — two-pass detection using a user-provided coordinate mapping (e.g., camera distortion model). Pass-1 finds seed positions, pass-2 refines in the undistorted working frame.
  4. Self-undistort — automatic estimation of a single-parameter division distortion model from the detected ellipses, followed by a corrected second pass. No external calibration required.

Target Audience

This book is for Rust developers working on:

  • Camera calibration pipelines
  • Photogrammetry and 3D reconstruction
  • Computer vision applications requiring high-precision fiducial detection
  • Metrology and measurement systems

Book Structure

  • Fast Start — one-command workflow to generate board_spec.json + printable SVG/PNG and run first detection
  • Marker Design — anatomy of the ring marker, coding scheme, and hex lattice layout
  • Detection Pipeline — detailed walkthrough of all 10 detection stages
  • Mathematical Foundations — full derivations of the core algorithms (ellipse fitting, RANSAC, homography, projective center recovery, division model)
  • Using ringgrid — configuration, output types, detection modes, and CLI usage

Fast Start

This section gets you from zero to:

  • board_spec.json (target config used by the detector)
  • printable target_print.svg
  • printable target_print.png

in one command.

1. Generate target JSON + SVG + PNG

Choose one of the three equivalent target-generation paths.

Rust CLI:

cargo run -p ringgrid-cli -- gen-target \
  --out_dir tools/out/target_faststart \
  --pitch_mm 8 \
  --rows 15 \
  --long_row_cols 14 \
  --marker_outer_radius_mm 4.8 \
  --marker_inner_radius_mm 3.2 \
  --name ringgrid_200mm_hex \
  --dpi 600 \
  --margin_mm 5

Python script (same geometry, same output files):

python3 -m venv .venv
./.venv/bin/python -m pip install -U pip maturin
./.venv/bin/python -m maturin develop -m crates/ringgrid-py/Cargo.toml --release
./.venv/bin/python tools/gen_target.py \
  --out_dir tools/out/target_faststart \
  --pitch_mm 8 \
  --rows 15 \
  --long_row_cols 14 \
  --marker_outer_radius_mm 4.8 \
  --marker_inner_radius_mm 3.2 \
  --name ringgrid_200mm_hex \
  --dpi 600 \
  --margin_mm 5

Rust API:

  • Use BoardLayout::new / BoardLayout::with_name with write_json_file, write_target_svg, and write_target_png when target generation happens inside a Rust application instead of from the terminal.

2. Output files

After the command finishes, you will have:

  • tools/out/target_faststart/board_spec.json
  • tools/out/target_faststart/target_print.svg
  • tools/out/target_faststart/target_print.png

If you also need synthetic camera renders and ground truth, use tools/gen_synth.py instead of the dedicated Rust CLI or Python target-generator path.

3. Detect against this board

cargo run -- detect \
  --target tools/out/target_faststart/board_spec.json \
  --image path/to/photo.png \
  --out tools/out/target_faststart/detect.json

detect.json contains the final marker list, coordinate-frame metadata, optional homography/RANSAC statistics, and optional mapper diagnostics. See Detection Output Format.

4. Scale handling

  • Start with default detection first (Detector::detect or CLI detect).
  • For scenes with very small and very large markers in the same image, use adaptive multi-scale APIs:
    • Detector::detect_adaptive
    • Detector::detect_adaptive_with_hint
    • Detector::detect_multiscale

See Adaptive Scale Detection.

Next Reads

Ring Structure

A ringgrid marker consists of two concentric circular rings printed on a planar calibration target. These rings serve a dual purpose: their edges provide high-contrast features for sub-pixel ellipse fitting, and the annular region between them carries a binary code that identifies each marker uniquely.

Physical geometry

Each marker is defined by two radii measured from the marker center:

ParameterDefault valueDescription
Outer radius4.8 mmRadius of the outer ring centerline
Inner radius3.2 mmRadius of the inner ring centerline
Ring width0.576 mm (0.12 * outer radius)Width of each dark ring band
Pitch8.0 mmCenter-to-center spacing on the hex lattice

The outer ring is a dark annular band centered at the outer radius. Its outer edge (at outer_radius + ring_width/2) forms the outermost visible boundary of the marker. Similarly, the inner ring is a dark annular band centered at the inner radius.

Between the two dark ring bands lies the code band – the annular region where binary sector patterns encode the marker’s identity. The code band occupies the gap between the inner edge of the outer ring and the outer edge of the inner ring.

Ring bands and edge detection

The detector identifies markers by locating the sharp intensity transitions at the boundaries of the dark ring bands. Under image blur, the physical ring width causes these transitions to broaden, so the detector targets the boundary of the merged dark band rather than the ring centerline. This means the effective detected edges sit at:

  • Outer edge: outer_radius + ring_width (outside of the outer band)
  • Inner edge: inner_radius - ring_width (inside of the inner band)

For the default geometry:

  • Outer edge = 4.8 + 0.576 = 5.376 mm, but in normalized units the detector works with outer_radius * (1 + 0.12) = pitch * 0.672
  • Inner edge = 3.2 - 0.576 = 2.624 mm, or pitch * 0.328

The ratio of these detected edges defines the key geometric invariant used during inner ring estimation.

Outer-normalized coordinates

Internally, ringgrid expresses all marker geometry in outer-normalized coordinates where the detected outer edge radius equals 1.0. This normalization makes the geometry scale-invariant: the same MarkerSpec parameters apply regardless of the marker’s apparent size in the image.

In these units, the expected inner edge radius is:

r_inner_expected = (inner_radius - ring_width) / (outer_radius + ring_width)
                 = (pitch * 0.328) / (pitch * 0.672)
                 ≈ 0.488

The pitch cancels, so this ratio depends only on the relative proportions of the marker design, not on the physical scale.

The MarkerSpec type

The MarkerSpec struct encodes the expected marker geometry and controls how the inner ring estimator searches for the inner edge:

#![allow(unused)]
fn main() {
pub struct MarkerSpec {
    /// Expected inner radius as fraction of outer radius.
    pub r_inner_expected: f32,
    /// Allowed deviation in normalized radius around `r_inner_expected`.
    pub inner_search_halfwidth: f32,
    /// Expected sign of dI/dr at the inner edge.
    pub inner_grad_polarity: GradPolarity,
    /// Number of radii samples per theta.
    pub radial_samples: usize,
    /// Number of theta samples.
    pub theta_samples: usize,
    /// Aggregator across theta.
    pub aggregator: AngularAggregator,
    /// Minimum fraction of theta samples required for a valid estimate.
    pub min_theta_coverage: f32,
    /// Minimum fraction of theta samples that must agree on
    /// the inner edge location.
    pub min_theta_consistency: f32,
}
}

Key defaults:

FieldDefaultNotes
r_inner_expected0.4880.328 / 0.672
inner_search_halfwidth0.08Search window: [0.408, 0.568]
inner_grad_polarityLightToDarkLight center to dark inner ring
radial_samples64Resolution along radial profiles
theta_samples96Angular samples around the ring
aggregatorMedianRobust to code-band sector outliers
min_theta_coverage0.6At least 60% of angles must be valid
min_theta_consistency0.35At least 35% must agree on edge location

The search_window() method returns the normalized radial interval [r_inner_expected - halfwidth, r_inner_expected + halfwidth] where the inner ring estimator looks for the intensity transition.

Gradient polarity

The GradPolarity enum describes the expected direction of the radial intensity change at a ring edge:

#![allow(unused)]
fn main() {
pub enum GradPolarity {
    DarkToLight,   // dI/dr > 0: intensity increases outward
    LightToDark,   // dI/dr < 0: intensity decreases outward
    Auto,          // try both, pick the more coherent peak
}
}

For the default marker design (dark rings on a light background), the inner edge of the inner ring is a LightToDark transition when traversing radially outward from the marker center: you move from the light center region into the dark inner ring band.

Design constraints

The marker geometry must satisfy several constraints for reliable detection:

  1. Non-overlapping markers: The outer diameter (2 * outer_radius) must be smaller than the minimum center-to-center distance on the hex lattice (pitch * sqrt(3)). The default 4.8 mm radius gives a 9.6 mm diameter versus a ~13.86 mm nearest-neighbor distance.

  2. Sufficient code band width: The gap between inner and outer rings must be wide enough to sample 16 angular sectors with adequate spatial resolution.

  3. Ring width vs. blur: The ring bands must be wide enough to produce detectable gradient peaks after optical blur, but narrow enough not to encroach on the code band.

These relationships are baked into the MarkerSpec defaults and validated by the BoardLayout loader (see Hex Lattice Layout).

16-Sector Coding & Codebook

Each ringgrid marker carries a unique identity encoded as a binary pattern in the annular code band between its inner and outer rings. The code band is divided into 16 equal angular sectors, each rendered as either black or white, forming a 16-bit codeword. The detector ships with a stable 893-codeword baseline profile and an opt-in extended profile for larger ID spaces, while still handling unknown orientation through cyclic matching.

Sector layout

The code band is the annular region between the inner and outer dark ring bands. It is divided into 16 sectors of equal angular extent (22.5 degrees each), numbered 0 through 15 proceeding counterclockwise. Each sector is filled with either a dark (0) or light (1) value, producing a 16-bit binary word.

Because the marker can appear at any in-plane rotation in the image, the absolute angular reference of sector 0 is unknown at detection time. The codebook matching algorithm handles this by trying all 16 cyclic rotations (see below).

The codebook

ringgrid embeds two related profiles:

ProfileSizeMinimum cyclic Hamming distanceIntended use
base893 codewords2Default shipped profile with stable IDs 0..892
extended2180 codewords1Explicit opt-in profile when ID capacity matters more than the baseline ambiguity guarantee

Key codebook properties:

PropertyValue
Codeword length16 bits
Baseline size893 codewords
Extended size2180 codewords
Baseline minimum cyclic Hamming distance2
Extended minimum cyclic Hamming distance1
Generator seed1

The baseline minimum cyclic Hamming distance of 2 means that for any two distinct baseline codewords A and B, the Hamming distance between A and every cyclic rotation of B is at least 2. This guarantees that a single-bit error will not silently produce a different valid baseline codeword, though it is not sufficient for guaranteed single-bit error correction (which would require a minimum distance of 3).

The extended profile keeps the same baseline prefix but appends the remaining rotationally unique 16-bit words whose complement classes are not already claimed by the shipped profile. That expands capacity substantially without introducing new polarity ambiguity beyond the fixed baseline, but still lowers the minimum cyclic Hamming distance to 1 and therefore weakens the baseline profile’s ambiguity guarantee. This is why extended is explicit opt-in.

Additional constraints enforced during codebook generation:

  • No rotational symmetry: no codeword equals any of its own non-trivial cyclic rotations. This ensures that each physical marker has a unique observed pattern regardless of orientation.
  • Pairwise uniqueness under rotation: no two distinct codewords share any cyclic rotation, preventing ambiguous matches.

The codebook is embedded as a compile-time constant array in crates/ringgrid/src/marker/codebook.rs:

#![allow(unused)]
fn main() {
pub const CODEBOOK_BITS: usize = 16;
pub const CODEBOOK_N: usize = 893;
pub const CODEBOOK_MIN_CYCLIC_DIST: usize = 2;
pub const CODEBOOK_SEED: u64 = 1;
pub const CODEBOOK_EXTENDED_N: usize = 2180;
pub const CODEBOOK_EXTENDED_MIN_CYCLIC_DIST: usize = 1;

pub const CODEBOOK: [u16; 893] = [
    0x035D, 0x1F95, 0x0B1D, /* ... */
];

pub const CODEBOOK_EXTENDED: [u16; 2180] = [
    0x035D, 0x1F95, 0x0B1D, /* ... */
];
}

This file is generated by tools/gen_codebook.py and should never be edited by hand. To regenerate:

python3 tools/gen_codebook.py \
    --n 893 --seed 1 \
    --out_json tools/codebook.json \
    --out_rs crates/ringgrid/src/marker/codebook.rs
cargo build  # rebuild after regenerating

Decoding process

The detector decodes a marker’s identity after fitting its outer ellipse. The process has four stages: sampling, thresholding, binarization, and codebook matching.

1. Sampling sector intensities

For each of the 16 sectors, the detector samples pixel intensities at multiple points within the code band. Sampling is controlled by DecodeConfig:

#![allow(unused)]
fn main() {
pub struct DecodeConfig {
    /// Embedded codebook profile.
    pub codebook_profile: CodebookProfile, // default: base
    /// Ratio of code band center radius to outer ellipse semi-major axis.
    pub code_band_ratio: f32,        // default: 0.76
    /// Number of angular samples per sector.
    pub samples_per_sector: usize,   // default: 5
    /// Number of radial rings to sample.
    pub n_radial_rings: usize,       // default: 3
    /// Maximum Hamming distance for a valid decode.
    pub max_decode_dist: u8,         // default: 3
    /// Minimum confidence for a valid decode.
    pub min_decode_confidence: f32,  // default: 0.30
    /// Minimum Hamming margin for a valid decode.
    pub min_decode_margin: u8,       // default: 1
}
}

The sampling radius is code_band_ratio * r_mean, where r_mean is the mean semi-axis of the fitted outer ellipse. Multiple angular samples per sector (spaced evenly within the sector) and multiple radial rings (spanning +/-10% of the center ratio) provide robustness against localized noise.

The mean semi-axis is used rather than the ellipse angle because the ellipse angle reflects perspective distortion of the circular marker, not the board-to-image rotation. Sector angular alignment is handled entirely by the cyclic rotation matching.

Each sector’s intensity is the average of all valid samples (those falling within image bounds).

2. Thresholding

The sampled intensities are binarized using an iterative 2-means clustering algorithm:

  1. Initialize the threshold at the midpoint of the intensity range.
  2. Split sectors into two groups (above and below threshold).
  3. Recompute the threshold as the midpoint of the two group means.
  4. Repeat until convergence (up to 10 iterations).

A minimum contrast check rejects markers where the intensity range is too narrow (less than 0.03 on a [0, 1] scale), which would indicate a featureless or uniform code band.

3. Binarization

Each sector with intensity above the threshold is assigned bit 1; below gets bit 0. This produces a 16-bit observed word.

4. Codebook matching

The observed word is matched against the selected embedded profile. For each codeword, all 16 cyclic rotations are tried, and the Hamming distance is computed for each:

distance = popcount(observed_word XOR rotate(codeword, k))

The match with the smallest Hamming distance wins. If the best match has a high distance or low confidence, the detector also tries the inverted polarity (!observed_word), which handles the case where marker contrast is reversed (dark-on-light vs. light-on-dark).

The better of the normal and inverted matches is selected based on the confidence heuristic:

confidence = clamp(1 - dist/6) * clamp(margin / active_profile_min_cyclic_dist)

where margin = second_best_dist - best_dist. In the shipped baseline profile, active_profile_min_cyclic_dist = 2, so a perfect decode with margin 2 or greater scores 1.0. In the opt-in extended profile that denominator is 1, which makes exact matches easier to accept but reflects the profile’s weaker minimum-distance guarantee. High confidence still requires both a low distance to the best match and a comfortable gap to the runner-up.

A decode is accepted only if:

  • best_dist <= max_decode_dist (default: 3)
  • margin >= min_decode_margin (default: 1)
  • confidence >= min_decode_confidence (default: 0.30)

The generator preserves the committed baseline profile as the fixed prefix, then appends the remaining valid rotational equivalence classes whose complement classes are not already claimed by the shipped profile to form the extended profile. For the committed --n 893 --seed 1 artifacts:

  • baseline minimum cyclic Hamming distance: 2
  • extended minimum cyclic Hamming distance: 1

Decode metrics

Every decode attempt produces a DecodeMetrics record with full diagnostic information:

#![allow(unused)]
fn main() {
pub struct DecodeMetrics {
    /// Raw 16-bit word sampled from the code band.
    pub observed_word: u16,
    /// Best-matching codebook entry index.
    pub best_id: usize,
    /// Cyclic rotation that produced the best match.
    pub best_rotation: u8,
    /// Hamming distance to the best-matching codeword.
    pub best_dist: u8,
    /// Margin: second_best_dist - best_dist.
    pub margin: u8,
    /// Confidence heuristic in [0, 1].
    pub decode_confidence: f32,
}
}

The margin field is particularly useful for assessing decode reliability. A margin of 0 means the best and second-best matches are equally close – the decode is ambiguous. Higher margins indicate increasingly unambiguous matches.

Cyclic matching in detail

Because a marker can appear at any in-plane rotation, the detector does not know which physical sector corresponds to bit 0 of the codeword. The cyclic matching algorithm compensates by testing all 16 rotational alignments:

#![allow(unused)]
fn main() {
pub fn rotate_left_16(word: u16, k: u32) -> u16 {
    word.rotate_left(k % 16)
}
}

For each codeword in the codebook, the matcher rotates the codeword by 0, 1, 2, …, 15 positions and computes the Hamming distance to the observed word at each offset. The rotation that produces the minimum distance is recorded as best_rotation.

This design means the detector never needs to determine the marker’s orientation independently – rotation recovery is a free byproduct of the codebook match.

Polarity fallback

In some imaging conditions the marker contrast may be inverted relative to the expected dark-ring-on-light-background convention. Rather than requiring a fixed polarity, the decoder tries both:

  1. Match observed_word against the codebook (normal polarity).
  2. Match !observed_word (bitwise complement) against the codebook (inverted polarity).
  3. Select whichever match yields higher confidence.

The appended profile entries exclude new complement-equivalent duplicates, so enabling extended does not widen the baseline profile’s existing polarity ambiguity.

The DecodeDiagnostics struct records whether the inverted polarity was used via the inverted_used flag.

Hex Lattice Layout

Ringgrid markers are arranged on a hexagonal lattice, which provides denser packing than a rectangular grid and ensures that each marker has six equidistant neighbors. The lattice geometry is parametrized by three values – rows, columns, and pitch – and marker positions are computed at runtime from these parameters rather than stored as explicit coordinate lists.

Lattice parameters

The hex lattice is fully defined by three parameters:

ParameterDefaultDescription
rows15Number of marker rows
long_row_cols14Number of markers in a long row
pitch_mm8.0 mmCenter-to-center distance between adjacent markers

Rows alternate between long rows (with long_row_cols markers) and short rows (with long_row_cols - 1 markers). This staggering is what produces the hexagonal packing pattern.

For the default board (15 rows, 14 long-row columns), the total marker count is:

8 long rows * 14 + 7 short rows * 13 = 112 + 91 = 203 markers

Axial coordinate system

Each marker position on the lattice is identified by a pair of axial coordinates (q, r), following the standard hex grid convention:

  • r is the row index, centered around zero. For a board with 15 rows, r ranges from -7 to +7.
  • q is the column index within each row, also centered around zero. The range of q depends on the row length.

Axial coordinates are integers and provide a natural addressing scheme for hex grids. They are stored as optional fields on each BoardMarker for diagnostic and visualization purposes.

Cartesian conversion

The conversion from axial coordinates (q, r) to Cartesian positions in millimeters uses the standard hex-to-Cartesian transform:

x = pitch * (sqrt(3) * q + sqrt(3)/2 * r)
y = pitch * (3/2 * r)

In Rust, this is implemented as:

#![allow(unused)]
fn main() {
fn hex_axial_to_xy_mm(q: i32, r: i32, pitch_mm: f32) -> [f32; 2] {
    let qf = q as f64;
    let rf = r as f64;
    let pitch = pitch_mm as f64;
    let x = pitch * (f64::sqrt(3.0) * qf + 0.5 * f64::sqrt(3.0) * rf);
    let y = pitch * (1.5 * rf);
    [x as f32, y as f32]
}
}

The computation is performed in f64 to avoid accumulation of rounding errors across large boards, then truncated to f32 for the final coordinates.

After generation, all marker positions are translated so that the first marker (top-left corner) sits at the origin (0, 0).

Nearest-neighbor distance

On this hex lattice, the nearest-neighbor distance between adjacent marker centers is:

d_nn = pitch * sqrt(3) ≈ 8.0 * 1.732 ≈ 13.86 mm

This distance determines the minimum clearance between markers and constrains the maximum allowed marker diameter (see Ring Structure).

The BoardLayout type

The BoardLayout struct is the runtime representation of a calibration target. It holds the lattice parameters, marker radii, and a generated list of all marker positions:

#![allow(unused)]
fn main() {
pub struct BoardLayout {
    pub name: String,
    pub pitch_mm: f32,
    pub rows: usize,
    pub long_row_cols: usize,
    pub marker_outer_radius_mm: f32,
    pub marker_inner_radius_mm: f32,
    pub markers: Vec<BoardMarker>,
    // internal: fast ID -> index lookup
}
}

Key methods:

MethodReturnsDescription
default()BoardLayoutDefault 15x14 board with 203 markers
from_json_file(path)Result<BoardLayout>Load from a JSON spec file
xy_mm(id)Option<[f32; 2]>Look up Cartesian position by marker ID
n_markers()usizeTotal number of markers
marker_ids()Iterator<usize>Iterate over all marker IDs
marker_bounds_mm()Option<([f32;2], [f32;2])>Axis-aligned bounding box
marker_span_mm()Option<[f32; 2]>Width and height of the marker field

BoardLayout maintains an internal HashMap<usize, usize> for O(1) lookup of marker positions by ID, built automatically during construction.

The BoardMarker type

Each marker on the board is represented by:

#![allow(unused)]
fn main() {
pub struct BoardMarker {
    pub id: usize,
    pub xy_mm: [f32; 2],
    pub q: Option<i16>,
    pub r: Option<i16>,
}
}

The id field is the marker’s codebook index (0 through 892 for the default board). Markers are assigned IDs sequentially in row-major order during generation. The q and r fields store the axial hex coordinates.

JSON schema

Board layouts are specified in JSON files using the ringgrid.target.v4 schema. The schema is deliberately parametric: it contains only the lattice parameters, and marker positions are generated at runtime. This avoids the maintenance burden and potential inconsistencies of storing per-marker coordinate lists.

Example JSON for the default board:

{
    "schema": "ringgrid.target.v4",
    "name": "ringgrid_200mm_hex",
    "pitch_mm": 8.0,
    "rows": 15,
    "long_row_cols": 14,
    "marker_outer_radius_mm": 4.8,
    "marker_inner_radius_mm": 3.2,
    "marker_ring_width_mm": 1.152
}

Schema fields:

FieldTypeDescription
schemastringMust be "ringgrid.target.v4"
namestringHuman-readable target name
pitch_mmfloatCenter-to-center marker spacing
rowsintNumber of rows in the lattice
long_row_colsintMarkers per long row
marker_outer_radius_mmfloatOuter ring radius
marker_inner_radius_mmfloatInner ring radius
marker_ring_width_mmfloatFull printed ring width

The loader enforces strict validation via #[serde(deny_unknown_fields)]: any extra fields (such as legacy origin_mm, board_size_mm, or explicit markers lists) cause a parse error. This prevents silent use of outdated board specifications.

Validation rules

The BoardLayout loader validates several geometric constraints:

  1. Positive dimensions: pitch_mm, marker_outer_radius_mm, marker_inner_radius_mm, and marker_ring_width_mm must all be finite and positive.
  2. Inner < outer: The inner radius must be strictly less than the outer radius.
  3. Positive code band: The outer edge of the inner ring must stay inside the inner edge of the outer ring, so the annular code band has non-zero width.
  4. Non-overlapping printed markers: The full printed marker diameter, including ring stroke width, must be smaller than the nearest-neighbor distance (pitch * sqrt(3)).
  5. Sufficient columns: When rows > 1, long_row_cols must be at least 2 (to allow short rows with long_row_cols - 1 >= 1 markers).

Board generation

Board specification files are generated by the Python utility tools/gen_board_spec.py:

python3 tools/gen_board_spec.py \
    --pitch_mm 8.0 \
    --rows 15 \
    --long_row_cols 14 \
    --board_mm 200.0 \
    --json_out tools/board/board_spec.json

The generated JSON file is then loaded at runtime by the detector via BoardLayout::from_json_file(), or the BoardLayout::default() constructor can be used without any file for the standard 15x14 board.

Why Rings?

Ring markers are a deliberate design choice that addresses fundamental limitations of other fiducial patterns. This chapter explains the three key advantages that motivate the ring geometry.

Subpixel Edge Detection

The boundary of a circle (or its perspective projection — an ellipse) produces a strong, continuous intensity gradient at every point along the edge. This is fundamentally different from corner-based features:

  • Corners (checkerboard intersections, square marker corners) are localized features. Their position is estimated from a small neighborhood, and subpixel accuracy depends on the sharpness of the corner response.
  • Ring edges are extended features. The detector can sample hundreds of edge points along radial rays emanating from the approximate center, then fit an ellipse to all of them simultaneously.

The ringgrid detector uses gradient-based edge sampling: for each candidate center, it casts radial rays outward at uniformly spaced angles and locates the intensity transition along each ray using the Scharr gradient magnitude. This yields a dense set of edge points — typically 60–200 per marker — distributed around the full circumference.

These edge points are then passed to the Fitzgibbon direct least-squares ellipse fitter, which solves a constrained eigenvalue problem to find the best-fit ellipse in a single algebraic step (no iterative optimization). The resulting ellipse center achieves subpixel accuracy because:

  1. The fit uses many points (overdetermined system), averaging out per-point noise
  2. Points are distributed around the full ellipse, constraining all five parameters
  3. The algebraic constraint guarantees an ellipse (not a hyperbola or degenerate conic)

In synthetic benchmarks with blur σ = 0.8 px, ringgrid achieves mean center error of 0.054 px with projective center correction enabled.

Projective Center Correction

Under perspective projection, a circle in 3D projects to an ellipse in the image. A critical subtlety: the center of the projected ellipse is not the projection of the circle’s center. This projective bias grows with the viewing angle and distance from the optical axis.

For corner-based markers, this is not an issue — corners project correctly. But for any detector that fits a conic (ellipse) to estimate a circle’s center, the projective bias introduces systematic error.

ringgrid solves this problem using two concentric rings. When both the outer and inner ellipses are successfully fitted, the detector has two conics that correspond to two concentric circles in 3D. The key mathematical insight is:

The conic pencil spanned by two concentric circle projections contains degenerate conics (pairs of lines) that intersect at the true projected center.

This is the projective center recovery algorithm (detailed in the Mathematical Foundations chapter). It recovers the unbiased projected center without requiring camera intrinsics — purely from the geometry of the two fitted ellipses.

The improvement is measurable: on clean synthetic images, projective center correction reduces the mean center error from 0.072 px to 0.054 px — a 25% improvement in localization accuracy.

Large Identification Capacity

Each marker carries a unique identity encoded in a 16-sector binary code band between the inner and outer rings. The shipped baseline codebook contains 893 codewords with minimum cyclic Hamming distance 2, while an opt-in extended profile expands that to 2180 codewords at minimum cyclic Hamming distance 1. Both profiles enforce rotational uniqueness and pairwise uniqueness under cyclic rotation, and the extended profile avoids new complement-equivalent duplicates beyond the fixed baseline.

This design provides several advantages over other encoding approaches:

PropertyringgridArUco 4x4ArUco 6x6Checkerboard
Unique IDs893502500
Rotation invariantYesNo (4 orientations)NoN/A
Error toleranceHamming distanceHamming distanceHamming distanceN/A
Encoding mechanismAngular sectorsBinary gridBinary gridNone

Key properties of the coding scheme:

  • Rotation invariance: The 16-sector code is sampled relative to the marker’s geometry, and the decoder tries all 16 cyclic rotations. No marker orientation assumption is needed.
  • Polarity invariance: The decoder also checks the inverted contrast pattern, handling both dark-on-light and light-on-dark printing.
  • Error tolerance: The codebook’s minimum cyclic Hamming distance of 2 prevents a single-bit error from silently mapping to a different valid codeword, though it does not guarantee single-bit correction.

Comparison with Other Calibration Targets

Checkerboards

Checkerboards are the classic calibration target. They offer excellent corner localization via saddle-point refinement, but have no identity encoding. This means:

  • The full board (or a known subset) must be visible for correspondence
  • Automatic detection fails with partial occlusion
  • Multiple boards in one image cannot be disambiguated

ringgrid markers each carry a unique ID, enabling detection under partial visibility and multi-board setups.

ArUco / AprilTag

ArUco and AprilTag markers encode identity in a binary grid printed inside a square border. Detection relies on finding the square contour and computing a homography from its four corners. Limitations:

  • Corner accuracy is limited by contour detection precision
  • The square geometry provides only 4 points per marker for center estimation
  • Dense packing is limited by the need for white borders between markers

ringgrid markers provide hundreds of edge points per marker, denser packing on a hex lattice, and rotation-invariant coding.

Concentric Circles (CCT)

Concentric circle targets (e.g., Huo et al. 2020) share some advantages with ringgrid — subpixel edge fitting and projective center correction. ringgrid adds:

  • Binary coding for unique identification (CCTs typically rely on geometric arrangement for correspondence)
  • A hex lattice layout for maximum marker density
  • A large codebook (893 IDs) enabling scalable target designs

Pipeline Overview

The ringgrid detection pipeline transforms a grayscale image into a set of identified marker detections with sub-pixel centers, fitted ellipses, decoded IDs, and an optional board-to-image homography. The pipeline is structured in two major phases executed in sequence, with projective center correction and structural ID correction in finalize.

Two-Phase Architecture

Phase 1: Fit-Decode

Orchestrated by pipeline/fit_decode.rs, this phase takes raw proposals and produces individually decoded markers:

StageNameDescription
1ProposalScharr gradient voting + NMS produces candidate centers
2Outer EstimateRadial profile peak detection yields radius hypotheses
3Outer FitRANSAC ellipse fitting on sampled edge points
4Decode16-sector code sampling and codebook matching
5Inner EstimateInner ring ellipse fitting from outer prior
6DedupSpatial and ID-based deduplication

Stages 2–5 are executed per-proposal inside process_candidate(). A proposal that fails at any stage is rejected with a diagnostic reason string. Successfully built markers are collected, then deduplicated in stage 6.

Phase 2: Finalize

Orchestrated by pipeline/finalize.rs, this phase applies global geometric reasoning to improve and extend the detection set:

OrderNameDescription
1Projective CenterCorrect fit-decode marker centers (once per marker)
2ID CorrectionStructural consistency scrub/recovery of decoded IDs
3Global FilterOptional RANSAC homography from decoded markers with known board positions
4CompletionOptional conservative fits at missing H-projected IDs (+ projective center for new markers)
5Final H RefitOptional refit homography from all corrected centers

When use_global_filter is false, finalize still runs projective center + ID correction, then returns immediately, skipping the homography-dependent stages (global filter/completion/final refit).

Projective Center Correction

Projective center correction recovers the true projected center of a ring marker from its inner and outer ellipse pair, compensating for the perspective bias inherent in ellipse center estimation. It is applied once per marker at two points in the pipeline:

  1. Before global filter: Corrects all fit-decode markers so that downstream geometric stages operate on unbiased centers.
  2. After completion: Newly added completion markers receive their own correction. Only the slice of markers added since the last correction is processed.

Each marker is corrected exactly once. apply_projective_centers() from detector/center_correction.rs requires both inner and outer ellipses. Markers without a valid inner ellipse are skipped.

Pipeline Entry Points

All detection is accessed through the Detector struct in api.rs, which delegates to the entry points in pipeline/run.rs:

detect_single_pass

The simplest mode. Runs proposal generation followed by the full fit-decode and finalize pipeline without any pixel mapper:

proposals = find_proposals(gray, config)
fit_markers = fit_decode::run(gray, config, None, proposals)
result = finalize::run(gray, fit_markers, config, None)

detect_with_mapper

Two-pass detection with an external pixel mapper (e.g., from known camera intrinsics):

  1. Pass 1: Run detect_single_pass without the mapper to get initial detections.
  2. Pass 2: Extract seed proposals from pass-1 detections, then re-run the full pipeline with the mapper active.

The mapper transforms between image pixel coordinates and a distortion-corrected “working” coordinate frame. During pass 2, edge sampling and decoding operate in working space, producing more accurate fits under lens distortion. Final marker centers are mapped back to image space; the homography lives in the working frame.

detect_with_self_undistort

Estimates a division-model distortion correction from the detected markers, then optionally re-runs detection with the estimated mapper:

  1. Baseline pass: Run detect_single_pass.
  2. Self-undistort estimation: If enabled and enough markers with edge points are available, estimate a DivisionModel mapper from the ellipse edge points.
  3. Pass 2 (conditional): If estimation succeeded, re-run as a seeded pass-2 with the estimated mapper.

The self-undistort result is attached to DetectionResult.self_undistort regardless of whether pass 2 was applied.

detect_adaptive and detect_multiscale

Adaptive scale entry points run the same fit/decode and finalize logic, but over one or more scale tiers:

  1. Build tiers (automatic probe, hint-derived, or explicit).
  2. Run per-tier fit/decode + projective center + ID correction.
  3. Merge markers across tiers with size-aware dedup.
  4. Run global filter + completion + final homography refit once.

See Adaptive Scale Detection.

Seed Injection in Two-Pass Modes

When a pass-2 runs (either detect_with_mapper or detect_with_self_undistort), pass-1 detection centers become seed proposals for pass-2. Seeds are injected with a high score (seed_score = 1e12 by default) so they are prioritized during candidate selection. The SeedProposalParams configuration controls:

  • merge_radius_px: Radius for merging seeds with detector-found proposals (default: 3.0 px).
  • max_seeds: Optional cap on the number of seeds consumed (default: 512).

Coordinate Frames

The pipeline maintains two coordinate frames:

  • Image frame: Raw pixel coordinates in the input image.
  • Working frame: Distortion-corrected coordinates when a PixelMapper is active; identical to image frame when no mapper is present.

Edge sampling, ellipse fitting, decoding, and homography estimation all operate in the working frame. The final DetectedMarker.center is always in image space. When a mapper is active, center_mapped preserves the working-frame center, and homography_frame is set to DetectionFrame::Working.

Output Structure

The pipeline produces a DetectionResult containing:

  • detected_markers: The final list of DetectedMarker structs.
  • homography: Optional 3x3 board-to-image homography matrix.
  • ransac: Optional RansacStats for the homography fit.
  • image_size: Dimensions of the input image.
  • center_frame: Always DetectionFrame::Image.
  • homography_frame: Image or Working depending on mapper presence.
  • self_undistort: Optional self-undistort estimation result.

For the serialized JSON shape used by the CLI and examples, see Detection Output Format.

Source: pipeline/run.rs, pipeline/fit_decode.rs, pipeline/finalize.rs

Proposal Generation

The proposal stage identifies candidate marker center positions in the image using gradient-based radial symmetry voting. Ring markers produce strong radially-symmetric gradient patterns at their centers, making gradient voting an effective detector that does not require template matching or multi-scale search.

The proposal module lives in crates/ringgrid/src/proposal/ and has a standalone API with no ringgrid-specific dependencies in its core types. For the public proposal-only API and heatmap workflow, see Proposal Diagnostics.

Algorithm

Scharr Gradient Computation

The first step computes horizontal and vertical image derivatives using the 3x3 Scharr kernels, which provide better rotational symmetry than Sobel kernels:

        [ -3   0   3 ]            [ -3  -10  -3 ]
Kx =    [ -10  0  10 ]    Ky =    [  0    0   0 ]
        [ -3   0   3 ]            [  3   10   3 ]

The implementation uses imageproc::gradients::horizontal_scharr and vertical_scharr to produce i16 gradient images gx and gy.

Edge Thinning (Canny-style Gradient NMS)

When edge_thinning is enabled (default: true), a Canny-style non-maximum suppression pass thins multi-pixel edge bands down to single-pixel ridges before voting. For each pixel with non-zero gradient:

  1. Quantize the gradient direction to one of 4 directions (0, 45, 90, 135 degrees) using integer ratio tests — no atan2 needed.
  2. Compare the pixel’s gradient magnitude squared against its two neighbors along the quantized direction.
  3. Suppress (zero out) pixels that are not local maxima along the gradient direction.

This typically reduces the strong-edge count by 60–80%, which proportionally reduces the cost of the voting loop — the dominant expense in proposal generation. The thinning uses integer i32 magnitude-squared comparisons throughout to avoid floating-point overhead.

Gradient Magnitude Thresholding

The maximum gradient magnitude across the image is computed, and a threshold is set as a fraction of this maximum:

threshold = grad_threshold * max(sqrt(gx^2 + gy^2))

Pixels with gradient magnitude below this threshold are ignored, suppressing noise in flat regions. The default grad_threshold is 0.05 (5% of max gradient).

Radial Symmetry Voting

For each pixel with a sufficiently strong gradient, the algorithm casts votes into an accumulator image along both the positive and negative gradient directions. The key insight is that gradient vectors on a ring boundary point radially toward (or away from) the ring center.

For each qualifying pixel at position (x, y) with gradient (gx, gy) and magnitude mag:

  1. Compute the unit gradient direction: (dx, dy) = (gx/mag, gy/mag)
  2. For each sign in {-1, +1}:
    • Walk along the direction sign * (dx, dy) at integer radius steps from r_min to r_max
    • At each voted position, deposit mag into the accumulator using bilinear interpolation

Bilinear interpolation ensures sub-pixel accuracy in the accumulator. The vote weight is the gradient magnitude, so stronger edges contribute more to the accumulator peak.

Voting in both directions (positive and negative gradient) ensures that both the inner-to-outer and outer-to-inner transitions of a ring contribute to the same center peak, regardless of contrast polarity.

Accumulator Smoothing

The raw accumulator is smoothed with a Gaussian blur (sigma controlled by accum_sigma, default: 2.0 px). This merges nearby votes that are slightly misaligned due to discretization, producing cleaner peaks.

Two-Step Non-Maximum Suppression

Peaks are extracted from the smoothed accumulator in two steps, controlled by a single user-facing parameter min_distance:

Step 1 — Local NMS peak extraction:

  1. Use an internal NMS radius of min(min_distance, 10.0) pixels, capped for efficiency (offset count scales as pi * r^2).
  2. Scan all pixels outside a border margin. Skip pixels below min_vote_frac * max_accumulator_value (default: 10% of max).
  3. A pixel is a local maximum if no neighbor within the NMS radius has a strictly higher value (ties broken by pixel index for determinism).

Step 2 — Greedy distance suppression:

  1. Sort NMS survivors by score (descending).
  2. Greedily accept proposals, rejecting any that fall within min_distance pixels of an already-accepted proposal.
  3. Accepted peaks become Proposal structs with (x, y, score).

If max_candidates is set, the list is truncated after greedy suppression.

Optional Downscaling

When the ringgrid pipeline uses a wide marker diameter prior, the proposal stage can optionally downscale the image before voting to reduce cost. This is controlled by ProposalDownscale on DetectConfig:

VariantBehavior
AutoFactor from floor(d_min / 20.0) clamped to [1, 4]
Off (default)No downscaling
Factor(n)Explicit integer factor (1–4)

When active, the image is resized with bilinear interpolation, proposal config parameters (r_min, r_max, min_distance) are scaled down proportionally, and resulting proposal coordinates are scaled back to full resolution. All downstream stages (fit, decode) operate at full resolution.

CLI: --proposal-downscale auto|off|2|4

Seed Injection in Two-Pass Modes

In two-pass detection modes (detect_with_mapper, detect_with_self_undistort), the pass-1 detection centers become seed proposals for pass-2. Seeds are assigned a very high score (seed_score = 1e12 by default) to ensure they are evaluated before gradient-detected proposals.

This mechanism serves two purposes:

  1. Re-detection with improved geometry: Pass-2 runs with a pixel mapper that corrects for lens distortion, so re-fitting at known centers produces more accurate ellipses.
  2. Recovery of weak detections: Markers that were detected in pass-1 but might be below threshold in the working frame still get a chance to be evaluated.

The SeedProposalParams configuration controls seed injection:

  • merge_radius_px (default: 3.0): Prevents duplicate proposals when a seed and a gradient-detected proposal coincide.
  • max_seeds (default: 512): Caps the number of seeds to prevent excessive computation.

Configuration

The ProposalConfig struct controls all proposal parameters:

ParameterDefaultDescription
r_min3.0Minimum voting radius in pixels
r_max12.0Maximum voting radius in pixels
min_distance10.0Minimum distance between output proposals (pixels)
grad_threshold0.05Gradient magnitude threshold (fraction of max)
min_vote_frac0.1Minimum accumulator value (fraction of max)
accum_sigma2.0Gaussian sigma for accumulator smoothing
edge_thinningtrueApply Canny-style gradient NMS before voting
max_candidatesNoneOptional cap on proposals returned

These defaults are overridden by DetectConfig when a MarkerScalePrior is set. The scale prior drives:

  • r_min = max(0.4 * radius_min_px, 2.0)
  • r_max = 1.7 * radius_max_px
  • min_distance — derived from marker spacing and diameter prior

Additionally, max_candidates in ProposalConfig limits the total proposals emitted, while max_candidates in fit_decode.rs separately caps how many proposals enter the fit-decode loop (sorted by score, highest first).

Standalone API

The proposal module exposes a standalone API for general-purpose ellipse/circle center detection, independent of ringgrid’s marker-specific pipeline:

#![allow(unused)]
fn main() {
use ringgrid::proposal::{find_ellipse_centers, find_ellipse_centers_with_heatmap, ProposalConfig};

let config = ProposalConfig { r_min: 5.0, r_max: 30.0, min_distance: 15.0, ..Default::default() };
let proposals = find_ellipse_centers(&gray_image, &config);
let result = find_ellipse_centers_with_heatmap(&gray_image, &config);  // includes heatmap
}

Connection to Next Stage

Each accepted proposal provides a candidate center position (x, y) and a score. In the fit-decode phase, each proposal is passed through the outer radius estimation stage to determine the expected ring size before edge sampling and ellipse fitting.

Source: proposal/ module (mod.rs, config.rs, gradient.rs, voting.rs, nms.rs)

Outer Radius Estimation

Before fitting an ellipse to the outer ring edge, the pipeline needs a radius estimate to anchor the search. The outer radius estimator samples radial intensity profiles around the proposal center and identifies the outer ring edge as a peak in the aggregated radial derivative.

Why This Stage Exists

A ring marker has multiple concentric edges (inner ring, code band boundaries, outer ring). Without guidance, an edge sampler might lock onto the wrong edge. This estimator uses the MarkerScalePrior to focus the search on a narrow window around the expected outer radius, avoiding confusion with stronger inner or code-band edges.

Algorithm

Radial Intensity Sampling

From the proposal center, the estimator casts theta_samples (default: 48) radial rays evenly spaced in angle. Along each ray, radial_samples (default: 64) intensity values are sampled at uniform radial steps within a search window:

window = [r_expected - search_halfwidth, r_expected + search_halfwidth]

where r_expected is the nominal outer radius from MarkerScalePrior and search_halfwidth_px (default: 4.0 px) defines the search extent. The window minimum is clamped to at least 1.0 px.

When a PixelMapper is active, sampling is distortion-aware: the DistortionAwareSampler maps working-frame coordinates to image-frame coordinates for pixel lookup, using bilinear interpolation.

Radial Derivative Computation

For each ray, the sampled intensity profile is differentiated using central differences to produce a dI/dr curve:

d[i] = (I[i+1] - I[i-1]) / (2 * r_step)   for interior samples
d[0] = (I[1] - I[0]) / r_step               forward difference at boundary
d[N-1] = (I[N-1] - I[N-2]) / r_step         backward difference at boundary

A 3-point moving average smooth is applied to reduce noise.

Theta Coverage Check

Rays that go out of image bounds are discarded. If the fraction of valid rays falls below min_theta_coverage (default: 0.6), the estimate fails. This prevents unstable results when the marker is partially occluded or near the image boundary.

Polarity Selection and Aggregation

The outer ring edge has a characteristic sign in dI/dr depending on contrast polarity:

  • Dark-to-light (Polarity::Pos): Moving outward, intensity increases at the outer edge (dark ring interior to bright background).
  • Light-to-dark (Polarity::Neg): The opposite convention.

The grad_polarity setting (default: DarkToLight) determines which polarities are tried. In Auto mode, both are evaluated and the best is selected.

For each polarity candidate, the per-theta derivative curves are aggregated at each radial sample using the configured AngularAggregator:

  • Median (default): Robust to outlier rays from code-band sectors.
  • TrimmedMean: Trims a configurable fraction of extreme values before averaging.

Peak Detection

Local maxima in the aggregated response (or its negation for Neg polarity) are identified. Peaks at the search window boundaries are excluded. Each peak is evaluated for theta consistency: the fraction of per-theta peaks that fall within a tolerance of the aggregated peak radius. Peaks with theta consistency below min_theta_consistency (default: 0.35) are rejected.

Multiple Hypotheses

When allow_two_hypotheses is enabled (default: true), the estimator may return up to two hypotheses if the runner-up peak has at least second_peak_min_rel (default: 85%) of the best peak’s strength. Multiple hypotheses improve robustness when the expected radius is slightly off: both candidates are evaluated in the outer fit stage and the better one is selected.

Output

The OuterEstimate struct contains:

  • r_outer_expected_px: The expected radius from the scale prior.
  • search_window_px: The [min, max] radial search window.
  • polarity: The selected contrast polarity.
  • hypotheses: Up to two OuterHypothesis structs, sorted best-first, each with r_outer_px, peak_strength, and theta_consistency.
  • status: Ok or Failed with a diagnostic reason.

Configuration

The OuterEstimationConfig struct controls this stage:

ParameterDefaultDescription
search_halfwidth_px4.0Search half-width around expected radius
radial_samples64Number of radial samples per ray
theta_samples48Number of angular rays
aggregatorMedianAngular aggregation method
grad_polarityDarkToLightExpected edge polarity
min_theta_coverage0.6Minimum fraction of valid rays
min_theta_consistency0.35Minimum fraction of rays agreeing with peak
allow_two_hypothesestrueEmit runner-up hypothesis if strong enough
second_peak_min_rel0.85Runner-up must be this fraction of best peak
refine_halfwidth_px1.0Per-theta local refinement half-width

When DetectConfig derives parameters from MarkerScalePrior, the search halfwidth is expanded to cover the full diameter range.

Connection to Adjacent Stages

The outer estimate receives the proposal center from the proposal stage and the expected radius from MarkerScalePrior. Its hypotheses are consumed by the outer ellipse fit stage, which samples edge points near each hypothesis radius and fits ellipses to evaluate which hypothesis produces the best detection.

Source: ring/outer_estimate.rs, ring/radial_profile.rs

Outer Ellipse Fit

Once a radius hypothesis is available from the outer estimation stage, the detector fits an ellipse to the outer ring boundary using RANSAC with the Fitzgibbon direct least-squares solver.

Edge Point Sampling

For each proposal center, the detector casts radial rays outward at uniformly spaced angles and locates the intensity transition along each ray. The edge detection uses the Scharr gradient magnitude — each ray is sampled at sub-pixel resolution, and the point of maximum gradient response within the expected radius range is recorded as an edge point.

Key parameters from EdgeSampleConfig:

ParameterDescription
n_raysNumber of uniformly spaced radial rays (typically 64–128)
r_minMinimum search radius in pixels
r_maxMaximum search radius in pixels

When a PixelMapper is active, edge sampling operates in the working (undistorted) frame. The ray endpoints are mapped from working coordinates to image coordinates for pixel lookups, then the edge points are recorded in working-frame coordinates.

RANSAC Ellipse Fitting

The edge points are passed to a RANSAC loop that uses the Fitzgibbon ellipse fitter as the minimal solver:

  1. Sample: randomly select 6 edge points (the minimum for Fitzgibbon)
  2. Fit: compute the direct least-squares ellipse
  3. Score: count inliers using Sampson distance as the error metric
  4. Iterate: repeat for max_iters iterations, keeping the best model
  5. Refit: fit a final ellipse from all inliers of the best model

The Sampson distance provides a first-order approximation of the geometric distance from a point to the conic. It is cheaper to compute than the true geometric distance while being a much better approximation than algebraic distance.

See RANSAC Robust Estimation and Fitzgibbon Ellipse Fitting for mathematical details.

Validation Gates

After RANSAC fitting, the resulting ellipse must pass several validation checks:

GateDefaultPurpose
Semi-axis boundsmin_semi_axis = 3.0, max_semi_axis = 15.0 (derived from scale prior)Reject fits that are too small or too large
Aspect ratiomax_aspect_ratio = 3.0Reject highly elongated fits (likely not a ring)
Inlier ratioMinimum fraction of edge points that are inliersReject poor fits

These bounds are automatically derived from the MarkerScalePrior via apply_marker_scale_prior.

Output

A successful outer fit produces an Ellipse struct:

#![allow(unused)]
fn main() {
pub struct Ellipse {
    pub cx: f64,    // center x
    pub cy: f64,    // center y
    pub a: f64,     // semi-major axis
    pub b: f64,     // semi-minor axis
    pub angle: f64, // rotation angle (radians)
}
}

The ellipse center (cx, cy) serves as the initial marker center estimate. This center is later refined by projective center correction if both inner and outer ellipses are available.

Source: detector/outer_fit.rs, ring/edge_sample.rs, conic/ransac.rs

Code Decoding

After the outer ellipse is fitted, the detector samples the code band — the annular region between the inner and outer rings — to read the marker’s 16-sector binary code and match it against the active embedded codebook profile. The shipped default is the 893-codeword base profile; extended is explicit opt-in.

Code Band Sampling

The code band sits at a radius that is a configurable fraction of the outer ellipse size. The code_band_ratio parameter (default derived from marker geometry, typically ~0.74) defines where the sampling circle lies relative to the outer ellipse.

For each of the 16 angular sectors, the decoder:

  1. Computes the sector center angle: θ_k = k × 2π/16 for k = 0..15
  2. Samples pixel intensities at multiple points within the sector (oversampled in both angular and radial directions)
  3. Aggregates samples to produce a single intensity value per sector

This multi-sample approach provides robustness against noise, blur, and slight geometric inaccuracies in the ellipse fit.

Binarization

The 16 sector intensities are converted to binary using an iterative 2-means threshold:

  1. Initialize threshold at the mean of all sector intensities
  2. Split sectors into two groups (above/below threshold)
  3. Recompute threshold as the mean of the group means
  4. Repeat until convergence

This local thresholding adapts to the actual contrast of each marker, handling varying illumination across the image.

Cyclic Codebook Matching

The 16-bit binary word is matched against the selected embedded codebook profile with cyclic rotation search:

  • For each of the 16 possible rotational offsets, compute the Hamming distance between the observed word and each codebook entry
  • Also check the inverted (bitwise NOT) word at each rotation, handling both dark-on-light and light-on-dark contrast
  • Select the best match: the (codeword, rotation, polarity) triple with minimum Hamming distance

The best match is accepted based on:

  • Hamming distance (best_dist): number of bit disagreements with the closest codeword
  • Margin (margin): gap between the best and second-best Hamming distances
  • Decode confidence: clamp(1 - dist/6) × clamp(margin / active_profile_min_cyclic_dist), a heuristic combining closeness and uniqueness. For the shipped base profile, the minimum cyclic Hamming distance is 2; for the opt-in extended profile it is 1.

DecodeMetrics

The decoding stage produces a DecodeMetrics struct:

FieldTypeMeaning
observed_wordu16The raw 16-bit word before matching
best_idusizeMatched codebook entry ID
best_rotationu8Rotation offset (0–15 sectors)
best_distu8Hamming distance to best match
marginu8Gap to second-best match
decode_confidencef32Combined confidence score in [0, 1]

A best_dist of 0 means a perfect match. In the shipped base profile, minimum cyclic Hamming distance is 2, so a distance of 1 is still unambiguous. The opt-in extended profile weakens that minimum distance to 1 in exchange for more available IDs.

Source: marker/decode.rs, marker/codec.rs, marker/codebook.rs

Inner Ellipse Estimation

The inner ellipse fit provides the second conic needed for projective center correction. It uses the outer ellipse as a geometric prior to constrain the search region.

Search Region

The expected inner ring radius is defined by MarkerSpec.r_inner_expected — the ratio of the inner edge radius to the outer edge radius in normalized coordinates. For the default marker geometry:

  • r_inner_expected = 0.328 / 0.672 ≈ 0.488

The search window extends ±inner_search_halfwidth (default 0.08) around this expected ratio, scaled by the fitted outer ellipse size.

Edge Sampling

Edge points for the inner ring are sampled along radial rays from the marker center, looking for intensity transitions within the inner search window. The gradient polarity (inner_grad_polarity) constrains which transitions are accepted — by default LightToDark, matching the synthetic marker convention where the inner ring boundary transitions from light (inside) to dark (ring band).

The radial profile is aggregated across theta samples using the configured aggregator (median or trimmed mean). A minimum min_theta_coverage fraction of rays must produce valid edge detections for the estimate to proceed.

RANSAC Ellipse Fitting

The inner edge points are fitted with RANSAC using the same Fitzgibbon solver as the outer fit, but with separate configuration via InnerFitConfig:

ParameterDefaultPurpose
min_points20Minimum edge points to attempt fit
min_inlier_ratio0.5Minimum RANSAC inlier fraction
max_rms_residual1.0 pxMaximum RMS Sampson residual
max_center_shift_px12.0 pxMaximum center offset from outer fit
max_ratio_abs_error0.15Maximum deviation of recovered scale ratio from radial hint
ransac.max_iters200RANSAC iterations
ransac.inlier_threshold1.5 pxSampson distance inlier threshold
ransac.min_inliers8Minimum inlier count

Validation

After fitting, the inner ellipse is validated against the outer ellipse:

  1. Center consistency: the inner ellipse center must be within max_center_shift_px of the outer ellipse center
  2. Scale ratio: the ratio of inner to outer semi-axes must be close to r_inner_expected (within max_ratio_abs_error)
  3. Fit quality: RMS residual must be below max_rms_residual

If validation fails, the inner ellipse is rejected and projective center correction will not be available for this marker. The marker can still be detected using only the outer ellipse center.

Source: ring/inner_estimate.rs, detector/inner_fit.rs

Deduplication

Multiple proposals can converge on the same physical marker, producing duplicate detections. The deduplication stage removes these duplicates while preserving the highest-quality detection for each marker.

Two-Pass Deduplication

Deduplication operates in two passes:

Pass 1: Spatial Dedup

Markers are sorted by confidence (descending). For each marker, any lower-confidence marker whose center is within dedup_radius pixels is suppressed. This is analogous to non-maximum suppression — only the strongest detection survives in each spatial neighborhood.

The dedup_radius is automatically derived from the MarkerScalePrior to approximately match the expected marker size.

Pass 2: ID Dedup

After spatial dedup, markers that decoded to the same codebook ID are further deduplicated. When two or more markers share the same decoded ID, only the one with the highest confidence is retained.

This pass handles cases where spatially separated proposals happen to decode to the same codeword — which can occur with poor-quality fits at the image periphery.

Ordering

The output of deduplication is a list of unique, high-confidence markers sorted by confidence. This ordering matters for downstream stages:

  • The global filter uses decoded markers to build homography correspondences
  • Completion attempts fits for missing IDs

Higher-confidence markers contribute more reliably to these stages.

Source: detector/dedup.rs

Projective Center & Global Filter

This chapter covers two key stages that work together: projective center correction and global homography filtering.

Projective Center Correction

Under perspective projection, the center of a fitted ellipse is biased — it does not correspond to the true projection of the circle’s center. This bias grows with the viewing angle.

ringgrid corrects this bias using the conic pencil approach: when both inner and outer ellipses are available for a marker, the two conics constrain a pencil whose degenerate members intersect at the true projected center. See Projective Center Recovery for the full derivation.

Once-Per-Marker Application

Projective center correction is applied once per marker at two points in the pipeline:

WhenWhich markers
Before global filterAll markers from fit-decode stage
After completionCompletion-added markers only

Each marker is corrected exactly once — fit-decode markers before the global filter, and completion markers after they are added.

Configuration

ProjectiveCenterParams controls the correction:

ParameterDefaultPurpose
enabletrueMaster switch
use_expected_ratiotrueUse r_inner_expected as eigenvalue prior
ratio_penalty_weight1.0Weight for ratio-prior penalty
max_center_shift_pxDerived from scale priorReject corrections that shift the center too far
max_selected_residualSome(0.25)Reject candidates with high geometric residual
min_eig_separationSome(1e-6)Reject when eigenvalues are too close (unstable)

When correction is rejected (gates not met), the original ellipse-fit center is preserved.

Global Homography Filter

Once enough markers are decoded and center-corrected, the detector estimates a board-to-image homography using RANSAC. This serves two purposes:

  1. Outlier rejection: markers that are inconsistent with the dominant planar mapping are discarded
  2. Enable downstream stages: completion requires a valid homography

Requirements

The global filter requires:

  • At least 4 decoded markers with known board positions (from BoardLayout)
  • use_global_filter = true in DetectConfig

When fewer than 4 decoded markers are available, the global filter is skipped and homography-dependent finalize stages do not run.

Algorithm

  1. Build correspondences: for each decoded marker, pair its board position (xy_mm) with its detected center
  2. Run RANSAC homography fitting (see DLT Homography):
    • Sample 4 random correspondences
    • Estimate H via DLT with Hartley normalization
    • Count inliers (reprojection error < inlier_threshold)
    • Keep the model with most inliers
    • Refit from all inliers
  3. Discard outlier markers

Configuration

RansacHomographyConfig:

ParameterDefaultPurpose
max_iters2000Maximum RANSAC iterations
inlier_threshold5.0 pxReprojection error threshold
min_inliers6Minimum inliers for a valid model
seed0Random seed for reproducibility

Output

The global filter produces:

  • A fitted homography matrix H (3x3, stored in DetectionResult.homography)
  • RansacStats with inlier counts and error statistics
  • A filtered marker set containing only inliers

Short-Circuit

When use_global_filter = false, finalization still applies projective center correction and structural ID correction, then returns without homography-based post-processing (no global filter/completion/final refit).

Source: detector/center_correction.rs, detector/global_filter.rs, homography/core.rs

ID Correction

The id_correction stage runs in pipeline/finalize.rs after projective-center correction and before optional global homography filtering.

It enforces structural consistency between decoded marker IDs and the board’s hex-lattice topology, while recovering safe missing IDs.

Inputs and Outputs

Inputs:

  • marker centers in image pixels (DetectedMarker.center)
  • decoded IDs (DetectedMarker.id)
  • board layout topology/coordinates (BoardLayout)
  • IdCorrectionConfig

Outputs:

  • corrected IDs in-place (DetectedMarker.id)
  • unresolved markers either kept with id=None or removed (remove_unverified)
  • diagnostics in IdCorrectionStats

Frame semantics:

  • local consistency geometry uses image-pixel centers
  • board lookups and adjacency use board-space marker coordinates/topology

Trust Model and Soft Lock

Markers are bootstrapped into trust classes:

  • AnchorStrong: exact decodes (best_dist=0, sufficient margin)
  • AnchorWeak: valid decoded IDs with enough decode confidence
  • recovered markers: assigned later by local/homography recovery

Soft-lock policy (soft_lock_exact_decode=true):

  • exact decodes are not normally overridden
  • they may still be cleared under strict contradiction evidence

Stage Flow

  1. Bootstrap trusted anchors from decoded IDs.
  2. Pre-consistency scrub clears IDs that contradict local hex-neighbor structure.
  3. Local recovery iteratively votes unresolved markers from trusted neighbors using local-scale gates derived from marker ellipse radii.
  4. Homography fallback (optional) seeds unresolved markers with a rough, gated board-to-image model built from trusted anchors.
  5. Post-consistency sweep + refill repeats scrub/recovery to remove late contradictions and fill safe holes.
  6. Cleanup/conflict resolution clears/removes unresolved markers and resolves duplicate IDs deterministically.

Local Consistency Rules

For each marker, nearby IDs are evaluated as:

  • support edges: neighbors that are 1-hop neighbors on the board
  • contradiction edges: nearby IDs that are not board neighbors

Assignments are accepted only when support/contradiction evidence passes configured gates (consistency_*, vote gates).

Homography Fallback

Fallback is conservative:

  • requires enough trusted seeds and RANSAC inliers
  • uses reprojection gate (h_reproj_gate_px)
  • cannot override soft-locked exact decodes
  • deterministic assignment order (error then ID tie-break)

Determinism and Diagnostics

id_correction is deterministic for a fixed input/config:

  • deterministic tie-breaks in voting and assignment
  • fixed RANSAC seed for fallback homography

IdCorrectionStats reports corrections, recoveries, cleared IDs, unverified reasons, and residual inconsistency count.

Completion & Final Refit

The completion stage attempts to detect markers that the initial pipeline missed — typically markers at the image periphery, under heavy blur, or with low contrast. It runs after projective-center correction, structural ID correction, and global homography filtering. It uses the homography to predict where missing markers should be and attempts conservative local fits at those locations.

Completion Algorithm

For each marker ID in the BoardLayout that was not detected in the initial pipeline:

  1. Project: use the homography to map the board position to image coordinates
  2. Boundary check: skip if the projected position is too close to the image edge (within image_margin_px)
  3. Local fit: run edge sampling and RANSAC ellipse fitting within a limited ROI (roi_radius_px) around the projected center
  4. Decode: attempt code decoding at the fitted position
  5. Gate: accept the detection only if it passes conservative quality gates

Conservative Gates

Completion uses stricter acceptance criteria than the initial detection to avoid false positives:

ParameterDefaultPurpose
reproj_gate_px3.0 pxMax distance between fitted center and H-projected position
min_fit_confidence0.45Minimum fit quality score
min_arc_coverage0.35Minimum fraction of rays with valid edge detections
roi_radius_px24.0 px (derived from scale prior)Edge sampling extent
image_margin_px10.0 pxSkip attempts near image boundary
max_attemptsNone (unlimited)Optional cap on completion attempts

The reproj_gate_px is the most important gate — it ensures that completed markers are geometrically consistent with the homography. A tight gate (default 3.0 px) prevents false detections from being added.

Projective Center for Completion Markers

After completion, projective center correction is applied to the newly completed markers only. Previously corrected markers retain their corrections. Each marker is corrected exactly once.

Final Homography Refit

With the expanded marker set (original + completed), the homography is refit from all corrected centers. This final refit:

  1. Uses all available markers for maximum accuracy
  2. Accepts the refit only if the mean reprojection error improves
  3. Updates DetectionResult.homography and DetectionResult.ransac

Disabling Completion

Set completion.enable = false in DetectConfig or use --no-complete in the CLI to skip completion entirely. This is useful when:

  • You only want high-confidence initial detections
  • Processing speed is more important than recall
  • The homography is unreliable (few decoded markers)

Completion also requires a valid homography — if the global filter did not produce one (fewer than 4 decoded markers), completion is automatically skipped.

Source: detector/completion.rs, pipeline/finalize.rs

Fitzgibbon Ellipse Fitting

This chapter describes the direct least-squares ellipse fitting method used in ringgrid, based on the work of Fitzgibbon, Pilu, and Fisher (1999). The method is the workhorse behind every ellipse fit in the detection pipeline – from outer ring RANSAC fits to inner ring estimation and completion refits.

Source: crates/ringgrid/src/conic/fit.rs, crates/ringgrid/src/conic/types.rs

The General Conic Equation

Any conic section (ellipse, hyperbola, parabola, or degenerate line pair) can be described by the implicit equation:

A x² + B xy + C y² + D x + E y + F = 0

We collect the six coefficients into a vector:

a = [A, B, C, D, E, F]ᵀ

The conic type is determined by the discriminant of the quadratic part:

  • Ellipse: B² - 4AC < 0
  • Parabola: B² - 4AC = 0
  • Hyperbola: B² - 4AC > 0

The conic equation can also be written in matrix form. Given a point in homogeneous coordinates x = [x, y, 1]ᵀ, the conic locus is xᵀ Q x = 0 where:

Q = | A    B/2  D/2 |
    | B/2  C    E/2 |
    | D/2  E/2  F   |

This symmetric 3x3 matrix Q is the conic matrix used extensively in the projective center recovery algorithm (see the Projective Center Recovery chapter).

The Design Matrix

Given n data points (x_i, y_i) for i = 1, ..., n, we construct the design matrix D where each row encodes the conic monomials evaluated at one point:

    | x₁²  x₁y₁  y₁²  x₁  y₁  1 |
D = | x₂²  x₂y₂  y₂²  x₂  y₂  1 |
    |  ⋮     ⋮     ⋮    ⋮   ⋮   ⋮ |
    | xₙ²  xₙyₙ  yₙ²  xₙ  yₙ  1 |

This is an n x 6 matrix. For a point lying exactly on the conic, row i dotted with the coefficient vector a gives zero: D_i · a = 0. In the presence of noise, Da will not be exactly zero, and the entries of Da are the algebraic distances of the points to the conic.

The Constrained Minimization Problem

The fitting objective is to minimize the sum of squared algebraic distances:

minimize  ||D a||²  =  aᵀ Dᵀ D a  =  aᵀ S a

subject to the constraint that the conic is an ellipse. Without a constraint, the trivial solution a = 0 minimizes the objective.

Fitzgibbon et al. encode the ellipse constraint 4AC - B² > 0 via the constraint matrix:

C₁ = | 0   0   2 |
     | 0  -1   0 |
     | 2   0   0 |

This matrix acts on the quadratic coefficient sub-vector a₁ = [A, B, C]ᵀ. The quadratic form a₁ᵀ C₁ a₁ = 4AC - B² is positive if and only if the conic is an ellipse. The constraint is:

aᵀ C a = 1

where C is the 6x6 block-diagonal matrix with C₁ in the upper-left 3x3 block and zeros elsewhere. This normalizes the scale of a and forces the ellipse condition simultaneously.

The Scatter Matrix and Its Partition

Define the scatter matrix (or normal matrix):

S = Dᵀ D    (6 x 6, symmetric positive semi-definite)

Partition S into four 3x3 blocks corresponding to the quadratic terms [A, B, C] and the linear terms [D, E, F]:

S = | S₁₁  S₁₂ |
    | S₂₁  S₂₂ |

where:

  • S₁₁ (3x3): quadratic-quadratic cross-products
  • S₁₂ (3x3): quadratic-linear cross-products; S₂₁ = S₁₂ᵀ
  • S₂₂ (3x3): linear-linear cross-products

Similarly, partition the coefficient vector:

a = | a₁ |    a₁ = [A, B, C]ᵀ
    | a₂ |    a₂ = [D, E, F]ᵀ

Reduction to a 3x3 Eigenvalue Problem

The Lagrangian of the constrained problem is:

L(a, λ) = aᵀ S a - λ (aᵀ C a - 1)

Setting ∂L/∂a = 0:

S a = λ C a

Expanding in blocks:

S₁₁ a₁ + S₁₂ a₂ = λ C₁ a₁       ... (1)
S₂₁ a₁ + S₂₂ a₂ = 0               ... (2)

Equation (2) gives zero because the constraint matrix C has zeros in the lower-right block. Solving (2) for a₂:

a₂ = -S₂₂⁻¹ S₂₁ a₁

Substituting back into (1):

(S₁₁ - S₁₂ S₂₂⁻¹ S₂₁) a₁ = λ C₁ a₁

Define the reduced matrix:

M = S₁₁ - S₁₂ S₂₂⁻¹ S₂₁

This is the Schur complement of S₂₂ in S. The problem becomes the 3x3 generalized eigenvalue problem:

M a₁ = λ C₁ a₁

Or equivalently, multiplying both sides by C₁⁻¹:

C₁⁻¹ M a₁ = λ a₁

where:

C₁⁻¹ = |  0    0   1/2 |
        |  0   -1    0  |
        | 1/2   0    0  |

Note that C₁⁻¹ M is generally not symmetric, so a standard symmetric eigendecomposition cannot be used. In ringgrid, this is solved via real Schur decomposition of the 3x3 matrix C₁⁻¹ M (implemented in conic/eigen.rs).

Selecting the Correct Eigenvalue

The 3x3 system produces three eigenvalue-eigenvector pairs. The correct solution is the eigenvector a₁ whose eigenvalue λ satisfies the positive-definiteness condition of the ellipse constraint:

a₁ᵀ C₁ a₁ = 4AC - B² > 0

In other words, we select the eigenpair where the constraint value is positive. Among the three eigenvalues of C₁⁻¹ M, exactly one will have this property when a valid ellipse solution exists.

Recovering the Full Coefficient Vector

Once a₁ = [A, B, C]ᵀ is determined, the linear coefficients are recovered by back-substitution:

a₂ = -S₂₂⁻¹ S₂₁ a₁

This gives the complete conic coefficient vector a = [A, B, C, D, E, F]ᵀ.

Hartley-Style Normalization

Numerical stability is critical. When image coordinates are in the range of hundreds of pixels, the entries of the design matrix D span many orders of magnitude (from terms around 10⁵ to the constant 1). This makes the scatter matrix S ill-conditioned.

Ringgrid applies Hartley-style normalization before fitting:

  1. Compute the centroid (m_x, m_y) of the input points
  2. Compute the mean distance d of points from the centroid
  3. Set scale factor s = √2 / d
  4. Transform each point: x' = s(x - m_x), y' = s(y - m_y)

After this transformation, the points are centered at the origin with mean distance √2 from the origin. The design matrix entries are all of order O(1), dramatically improving the condition number of S.

The normalization is computed in normalization_params():

#![allow(unused)]
fn main() {
let scale = if mean_dist > 1e-15 {
    std::f64::consts::SQRT_2 / mean_dist
} else {
    1.0
};
}

Denormalization of Conic Coefficients

The fitting is performed in normalized coordinates, producing conic coefficients [A', B', C', D', E', F']. These must be mapped back to original image coordinates.

Given the normalization transform x' = s(x - m_x), y' = s(y - m_y), we substitute into the normalized conic equation A'x'² + B'x'y' + C'y'² + D'x' + E'y' + F' = 0:

A'[s(x - m_x)]² + B'[s(x - m_x)][s(y - m_y)] + C'[s(y - m_y)]²
    + D'[s(x - m_x)] + E'[s(y - m_y)] + F' = 0

Expanding and collecting terms by monomial:

A = A' s²
B = B' s²
C = C' s²
D = -2A' s² m_x - B' s² m_y + D' s
E = -B' s² m_x - 2C' s² m_y + E' s
F = A' s² m_x² + B' s² m_x m_y + C' s² m_y² - D' s m_x - E' s m_y + F'

These formulas are implemented directly in denormalize_conic().

Conversion to Geometric Ellipse Parameters

The conic coefficients [A, B, C, D, E, F] define the ellipse implicitly. For practical use, we convert to the geometric representation (c_x, c_y, a, b, θ) where (c_x, c_y) is the center, a and b are the semi-major and semi-minor axes, and θ is the rotation angle.

Center

The center is the point where the gradient of the conic equation vanishes (apart from the constant terms). Setting the partial derivatives to zero:

∂f/∂x = 2Ax + By + D = 0
∂f/∂y = Bx + 2Cy + E = 0

This 2x2 linear system has the solution:

c_x = (BE - 2CD) / (4AC - B²)
c_y = (BD - 2AE) / (4AC - B²)

The denominator 4AC - B² is positive for an ellipse (it equals the negative discriminant).

Rotation Angle

The orientation of the ellipse axes is determined by the eigenvectors of the 2x2 quadratic-part matrix:

M₂ = | A    B/2 |
     | B/2  C   |

The rotation angle of the major axis from the positive x-axis is:

θ = (1/2) atan2(B, A - C)

with a special case when A = C (the ellipse axes are at 45 degrees).

Semi-Axes

The eigenvalues of M₂ are:

λ₁ = (A + C + √((A-C)² + B²)) / 2
λ₂ = (A + C - √((A-C)² + B²)) / 2

The value of the conic function at the center is:

F' = A c_x² + B c_x c_y + C c_y² + D c_x + E c_y + F

The squared semi-axes are:

a² = -F' / λ₁
b² = -F' / λ₂

For a valid ellipse, both must be positive, which requires F' and the eigenvalues to have opposite signs.

The ellipse is canonicalized so that a >= b (semi-major axis first), swapping axes and adjusting the angle by π/2 if necessary. The angle is normalized to (-π/2, π/2].

Sampson Distance

The Sampson distance provides a first-order approximation to the geometric (Euclidean) distance from a point to the nearest point on a conic. It is much cheaper to compute than true geometric distance and is used as the error metric in RANSAC ellipse fitting.

For a conic f(x, y) = Ax² + Bxy + Cy² + Dx + Ey + F, the gradient at (x, y) is:

∇f = (∂f/∂x, ∂f/∂y) = (2Ax + By + D, Bx + 2Cy + E)

The Sampson distance is defined as:

d_S(x, y) = |f(x, y)| / ||∇f(x, y)||

where ||∇f|| is the Euclidean norm of the gradient:

||∇f|| = √((2Ax + By + D)² + (Bx + 2Cy + E)²)

Geometrically, this divides the algebraic distance by the “speed” at which the conic function changes in the direction normal to the curve. For points near the conic, this closely approximates the true orthogonal distance in pixels.

Ringgrid implements this in Ellipse::sampson_distance():

#![allow(unused)]
fn main() {
pub fn sampson_distance(&self, x: f64, y: f64) -> f64 {
    let c = self.to_conic();
    let [ca, cb, cc, cd, ce, _cf] = c.0;
    let alg = c.algebraic_distance(x, y);
    let gx = 2.0 * ca * x + cb * y + cd;
    let gy = cb * x + 2.0 * cc * y + ce;
    let grad_mag_sq = gx * gx + gy * gy;
    if grad_mag_sq < 1e-30 {
        return alg.abs();
    }
    alg.abs() / grad_mag_sq.sqrt()
}
}

The Sampson distance has units of pixels (assuming coordinates are in pixels), making it directly interpretable as an inlier threshold in RANSAC.

Minimum Point Count

The general conic has 6 coefficients but only 5 degrees of freedom (the overall scale is irrelevant). Thus, 5 points in general position determine a unique conic. However, the Fitzgibbon method imposes the ellipse constraint, which adds one equation, so the minimum number of points is 6. The implementation enforces this:

#![allow(unused)]
fn main() {
if n < 6 {
    return None;
}
}

With fewer than 6 points, the scatter matrix S does not have sufficient rank to reliably partition and invert S₂₂.

Summary of the Algorithm

  1. Normalize the input points (Hartley-style: center and scale)
  2. Build the n x 6 design matrix D in normalized coordinates
  3. Compute the scatter matrix S = Dᵀ D and partition into 3x3 blocks
  4. Compute the reduced matrix M = S₁₁ - S₁₂ S₂₂⁻¹ S₂₁
  5. Solve the eigenvalue problem C₁⁻¹ M a₁ = λ a₁
  6. Select the eigenvector with positive ellipse constraint: a₁ᵀ C₁ a₁ > 0
  7. Recover linear coefficients: a₂ = -S₂₂⁻¹ S₂₁ a₁
  8. Denormalize the conic coefficients to original coordinates
  9. Validate the result (check it is a proper ellipse with finite positive semi-axes)
  10. Optionally convert to geometric parameters (c_x, c_y, a, b, θ)

Reference

Fitzgibbon, A., Pilu, M., and Fisher, R. B. “Direct Least Square Fitting of Ellipses.” IEEE Transactions on Pattern Analysis and Machine Intelligence, 21(5):476–480, 1999.

RANSAC Robust Estimation

RANSAC (Random Sample Consensus) is a general framework for fitting models from data contaminated with outliers. ringgrid uses RANSAC in two contexts: ellipse fitting from edge points and homography estimation from marker correspondences.

The RANSAC Algorithm

Given N data points, a model that requires at least m points to fit, and an inlier threshold ε:

best_model = None
best_inlier_count = 0

for iteration in 1..max_iters:
    1. Randomly select m points from the dataset
    2. Fit a model from the m-point minimal sample
    3. For each remaining point, compute the error to the model
    4. Count inliers: points with error < ε
    5. If inlier_count > best_inlier_count:
         best_model = model
         best_inlier_count = inlier_count
    6. (Optional) Early exit if inlier_count / N > 0.9

Final: refit the model from all inliers of best_model

The final refit step is critical — the initial model was fit from only m points, but the refit uses all inliers, yielding a more accurate estimate.

Expected Iterations

The number of iterations needed to find an all-inlier sample with probability p (typically 0.99) depends on the inlier ratio w:

k = log(1 - p) / log(1 - w^m)
Inlier ratio wm = 4 (homography)m = 6 (ellipse)
0.958
0.71647
0.571292
0.34935,802

ringgrid defaults to 2000 iterations for homography RANSAC and 200–500 for ellipse RANSAC, which is sufficient for typical inlier ratios.

Ellipse RANSAC

Minimal sample size: 6 points (the minimum for Fitzgibbon direct ellipse fit)

Model fitting: the Fitzgibbon algorithm solves a constrained generalized eigenvalue problem to produce an ellipse in a single algebraic step.

Error metric: Sampson distance, a first-order approximation of the geometric (orthogonal) distance from a point to a conic.

For a conic with coefficients a = [A, B, C, D, E, F] and a point (x, y):

f(x, y) = Ax² + Bxy + Cy² + Dx + Ey + F    (algebraic distance)

∇f = [2Ax + By + D, Bx + 2Cy + E]           (gradient)

d_Sampson = f(x, y) / ||∇f(x, y)||           (Sampson distance)

The Sampson distance approximates the signed geometric distance. For inlier classification, the absolute value |d_Sampson| is compared against the threshold.

Configuration (RansacConfig):

ParameterTypical valuePurpose
max_iters200–500Iteration budget
inlier_threshold1.0–2.0 pxSampson distance threshold
min_inliers8Minimum inlier count for acceptance
seedFixedReproducible random seed

Source: conic/ransac.rs

Homography RANSAC

Minimal sample size: 4 point correspondences

Model fitting: DLT with Hartley normalization

Error metric: reprojection error

error = ||project(H, src) - dst||₂

where project(H, [x, y]) computes the projective mapping H·[x, y, 1]ᵀ and dehomogenizes.

Algorithm specifics in ringgrid:

  1. Sample 4 distinct random correspondences
  2. Fit H via DLT
  3. Count inliers (reprojection error < inlier_threshold)
  4. Track best model
  5. Early exit when >90% of points are inliers
  6. After all iterations, refit from all inliers of the best model
  7. Recompute inlier mask with the refit H

Configuration (RansacHomographyConfig):

ParameterDefaultPurpose
max_iters2000Iteration budget
inlier_threshold5.0 pxReprojection error threshold
min_inliers6Minimum inlier count
seed0Reproducible random seed

Output (RansacStats):

FieldMeaning
n_candidatesTotal correspondences fed to RANSAC
n_inliersInliers after final refit
threshold_pxThreshold used
mean_err_pxMean inlier reprojection error
p95_err_px95th percentile reprojection error

Source: homography/core.rs

DLT Homography

The Direct Linear Transform (DLT) estimates a 2D projective transformation (homography) from point correspondences. ringgrid uses DLT to compute the board-to-image mapping from decoded marker positions.

The Homography Model

A homography H is a 3×3 matrix that maps points between two projective planes:

[x']     [h₁₁ h₁₂ h₁₃] [x]
[y'] ~ H·[x, y, 1]ᵀ = [h₂₁ h₂₂ h₂₃] [y]
[w']     [h₃₁ h₃₂ h₃₃] [1]

The ~ denotes equality up to scale. The actual image coordinates are obtained by dehomogenizing:

x' = h₁₁x + h₁₂y + h₁₃
     ─────────────────────
     h₃₁x + h₃₂y + h₃₃

y' = h₂₁x + h₂₂y + h₂₃
     ─────────────────────
     h₃₁x + h₃₂y + h₃₃

H has 8 degrees of freedom (9 entries minus 1 for overall scale). Each point correspondence provides 2 equations, so a minimum of 4 correspondences are needed.

DLT Construction

From a correspondence (sx, sy) → (dx, dy), cross-multiplying to eliminate the unknown scale gives two linear equations in the 9 entries of h = [h₁₁, h₁₂, …, h₃₃]ᵀ:

Row 2i:    [  0   0   0  | -sx  -sy  -1  | dy·sx  dy·sy  dy ] · h = 0
Row 2i+1:  [ sx  sy   1  |   0    0   0  | -dx·sx -dx·sy -dx] · h = 0

Stacking n correspondences produces a 2n × 9 matrix A. The solution h minimizes ||Ah|| subject to ||h|| = 1.

Solution via Eigendecomposition

The minimizer of ||Ah||² subject to ||h|| = 1 is the eigenvector of AᵀA corresponding to its smallest eigenvalue.

ringgrid computes the 9×9 symmetric matrix AᵀA, then uses SymmetricEigen to find all eigenvalues and eigenvectors. The eigenvector associated with the smallest eigenvalue is reshaped into the 3×3 homography matrix.

Note: this is mathematically equivalent to taking the last right singular vector from the SVD of A, but computing the 9×9 eigensystem is more efficient than thin-SVD of a 2n×9 matrix.

Hartley Normalization

Raw point coordinates can have very different scales (e.g., board coordinates in mm vs. image coordinates in pixels), leading to poor numerical conditioning of AᵀA. Hartley normalization addresses this:

For each point set (source and destination):

  1. Compute the centroid (cx, cy) of the point set
  2. Compute the mean distance from the centroid
  3. Construct a normalizing transform T that:
    • Translates the centroid to the origin
    • Scales so the mean distance from the origin equals √2
T = [s  0  -s·cx]
    [0  s  -s·cy]
    [0  0    1  ]

where s = √2 / mean_distance

The DLT is then solved in normalized coordinates, and the result is denormalized:

H = T_dst⁻¹ · H_normalized · T_src

This normalization dramatically improves numerical stability and is essential for accurate results.

Normalization of H

After denormalization, H is rescaled so that h₃₃ = 1 (when |h₃₃| is not too small). This conventional normalization makes the homography directly usable for projection without an extra scale factor.

Reprojection Error

The quality of a fitted homography is measured by reprojection error — the Euclidean distance between the projected source point and the observed destination point:

error_i = ||project(H, src_i) - dst_i||₂

In ringgrid, reprojection errors are reported in pixels and used for:

  • RANSAC inlier classification
  • Homography quality assessment (RansacStats.mean_err_px, p95_err_px)
  • Accept/reject decisions for H refits

Source: homography/core.rs, homography/utils.rs

Projective Center Recovery

This chapter derives the algorithm that recovers the true projected center of a circle from two concentric circle projections, without requiring camera intrinsics.

The Problem

Under perspective projection, a circle in 3D projects to an ellipse in the image. The center of the projected ellipse is not the projection of the circle’s 3D center. This projective bias is systematic — it pushes the apparent center away from the image center — and grows with the viewing angle and distance from the optical axis.

For calibration applications where subpixel center accuracy is required, this bias must be corrected.

Concentric Circles and the Conic Pencil

Consider two concentric circles in the target plane with radii r_inner and r_outer. Under perspective projection, they map to two conics (ellipses) Q_inner and Q_outer in the image plane.

The conic pencil spanned by these two conics is the family:

Q(λ) = Q_outer - λ · Q_inner

where λ is a scalar parameter. Each member of the pencil is a 3×3 symmetric matrix representing a conic.

Key Insight

The pencil contains degenerate members — conics with determinant zero — that factor into pairs of lines. These degenerate conics correspond to eigenvalues λ of the generalized eigenvalue problem:

Q_outer · v = λ · Q_inner · v

equivalently:

(Q_outer · Q_inner⁻¹) · v = λ · v

The true projected center lies at the intersection of the line pairs from the degenerate pencil members.

Algorithm

The algorithm as implemented in ringgrid:

Step 1: Normalize Conics

Both conic matrices are normalized to unit Frobenius norm. This improves numerical stability for subsequent eigenvalue computation.

Step 2: Compute Eigenvalues

Form the matrix A = Q_outer · Q_inner⁻¹ and compute its three eigenvalues λ₁, λ₂, λ₃ (which may be complex).

Step 3: Find Candidate Centers

For each eigenvalue λᵢ, compute the candidate center using two methods:

Method A (Wang et al.): Find the null vector u of (A - λᵢI), then compute p = Q_inner⁻¹ · u. The candidate center in image coordinates is (p₁/p₃, p₂/p₃).

Method B: Find the null vector of (Q_outer - λᵢ · Q_inner) directly, and dehomogenize.

Both methods are algebraically equivalent but may differ numerically; ringgrid tries both and selects the best.

Step 4: Score Candidates

Each candidate center p is scored by combining several criteria:

Geometric residual: Measures how well p lies on the pole-polar relationship with both conics. Computed as the normalized cross-product of Q₁·p and Q₂·p:

residual = ||( Q₁·p ) × ( Q₂·p )|| / (||Q₁·p|| · ||Q₂·p||)

A true projective center yields residual ≈ 0.

Eigenvalue separation: The gap between λᵢ and its nearest neighbor. Well-separated eigenvalues indicate a stable solution; degenerate (repeated) eigenvalues are numerically unstable.

Imaginary-part penalty: Small weights penalize complex eigenvalues and eigenvectors, since the true solution should be real.

Ratio prior: When the expected radius ratio k = r_inner/r_outer is known, the eigenvalue should be close to k². The penalty |λ - k²| biases selection toward the physically expected solution.

The total score combines these terms:

score = residual + w_imag_λ · |Im(λ)| + w_imag_v · ||Im(v)|| + w_ratio · |λ - k²|

Step 5: Select Best Candidate

Candidates are compared by eigenvalue separation first (preferring well-separated eigenvalues), then by score. The candidate with the best combined criterion is selected.

Gates in the Detection Pipeline

The detector applies additional gates via ProjectiveCenterParams:

GatePurpose
max_center_shift_pxReject if correction moves center too far from ellipse-fit center
max_selected_residualReject if geometric residual is too high (unreliable solution)
min_eig_separationReject if eigenvalues are nearly degenerate (unstable)

When any gate rejects the correction, the original ellipse-fit center is preserved.

Accuracy

On synthetic data with clean conics (no noise), the algorithm recovers the true projected center to machine precision (~1e-8 px). With noisy ellipse fits from real edge points, typical corrections are on the order of 0.01–0.5 px, depending on the perspective distortion.

The algorithm is scale-invariant: scaling either conic by a constant does not affect the result.

References

  • Wang, Y., et al. “Projective Correction of Circular Targets.” 2019.

Source: ring/projective_center.rs

Division Distortion Model

The division model is a single-parameter radial distortion model used by ringgrid’s self-undistort mode. Its simplicity makes it suitable for blind estimation from detected markers when no external camera calibration is available.

The Model

The division model maps distorted (observed) pixel coordinates to undistorted coordinates:

x_u = cx + (x_d - cx) / (1 + λ · r²)
y_u = cy + (y_d - cy) / (1 + λ · r²)

where:

  • (x_d, y_d) are distorted image coordinates
  • (x_u, y_u) are undistorted (working-frame) coordinates
  • (cx, cy) is the distortion center (assumed at the image center)
  • r² = (x_d - cx)² + (y_d - cy)² is the squared radial distance from the distortion center
  • λ is the single distortion parameter

Sign convention:

  • λ < 0 → barrel distortion (most common in wide-angle lenses)
  • λ > 0 → pincushion distortion
  • λ = 0 → no distortion (identity mapping)

Forward and Inverse Mapping

Forward (distorted → undistorted): The division model has a closed-form forward mapping, making undistortion cheap to compute.

Inverse (undistorted → distorted): There is no closed-form inverse. ringgrid uses an iterative fixed-point method:

Initialize: (dx, dy) = (ux, uy)

Repeat up to 20 iterations:
    r² = dx² + dy²
    factor = 1 + λ · r²
    dx_new = ux · factor
    dy_new = uy · factor
    if ||(dx_new, dy_new) - (dx, dy)|| < 1e-12: break
    (dx, dy) = (dx_new, dy_new)

Result: (cx + dx, cy + dy)

This converges quickly for typical distortion magnitudes.

PixelMapper Implementation

DivisionModel implements the PixelMapper trait:

#![allow(unused)]
fn main() {
impl PixelMapper for DivisionModel {
    fn image_to_working_pixel(&self, image_xy: [f64; 2]) -> Option<[f64; 2]> {
        Some(self.undistort_point(image_xy))  // closed-form forward
    }

    fn working_to_image_pixel(&self, working_xy: [f64; 2]) -> Option<[f64; 2]> {
        self.distort_point(working_xy)  // iterative inverse
    }
}
}

Self-Undistort Estimation

When config.self_undistort.enable = true, ringgrid estimates the optimal λ from the detected markers:

Estimation Flow

  1. Baseline detection: run the standard pipeline (no distortion correction) to detect initial markers
  2. Check prerequisites: need at least min_markers (default 6) markers with both inner and outer edge points
  3. Optimize λ: search for the λ that minimizes an objective function over a bounded range [lambda_min, lambda_max] (default [-8e-7, 8e-7])
  4. Accept/reject: apply gates to decide if the estimated correction is meaningful
  5. Pass-2 detection: if accepted, re-run detection with the estimated DivisionModel as the pixel mapper

Objective Function

The optimizer evaluates candidate λ values by:

Primary objective (when ≥4 decoded IDs exist): homography self-consistency in the working frame. For each candidate λ, undistort all marker centers, refit a homography, and measure the mean reprojection error.

Fallback objective (when fewer decoded IDs exist): conic consistency. For each candidate λ, re-sample edge points in the undistorted frame, refit inner/outer ellipses, and measure the Sampson residuals. Better distortion correction produces more circular (lower-residual) ellipse fits.

Accept/Reject Gates

The estimated λ is accepted only if:

  • The objective at λ is meaningfully better than at λ=0 (identity)
  • |λ| is non-trivial (not too close to zero)
  • λ is not at the boundary of the search range (boundary solutions are unreliable)

The SelfUndistortResult struct reports:

FieldMeaning
modelThe estimated DivisionModel (λ, cx, cy)
appliedWhether the correction was accepted and applied
objective_at_zeroObjective value with no correction
objective_at_lambdaObjective value at the estimated λ
n_markers_usedNumber of markers contributing to the estimation

Comparison with Brown-Conrady

PropertyDivision ModelBrown-Conrady
Parameters1 (λ)5 (k1, k2, p1, p2, k3)
Requires intrinsicsNo (center at image center)Yes (fx, fy, cx, cy)
Used bySelf-undistort modedetect_with_mapper with CameraModel
Forward mappingClosed-formClosed-form
Inverse mappingIterativeIterative
AccuracyCaptures dominant radial distortionFull radial + tangential model

The division model is intentionally simple — it captures the dominant barrel/pincushion distortion with a single parameter, making it robust for blind estimation. For higher accuracy, provide a full camera model via detect_with_mapper.

Configuration

SelfUndistortConfig:

ParameterDefaultPurpose
enablefalseEnable self-undistort estimation
lambda_range[-8e-7, 8e-7]Search bounds for λ
min_markers6Minimum markers with inner+outer edges

Source: pixelmap/distortion.rs, pixelmap/self_undistort.rs

DetectConfig

DetectConfig is the top-level configuration struct for the ringgrid detection pipeline. It aggregates all sub-configurations – from proposal generation and edge sampling through homography RANSAC and self-undistort – into a single value that drives every stage of detection.

Construction

DetectConfig is designed to be built from a BoardLayout (target geometry) and an optional scale prior. Three recommended constructors cover the common cases:

#![allow(unused)]
fn main() {
use ringgrid::{BoardLayout, DetectConfig, MarkerScalePrior};
use std::path::Path;

let board = BoardLayout::from_json_file(Path::new("board_spec.json")).unwrap();

// 1. Default scale prior (14--66 px diameter range)
let cfg = DetectConfig::from_target(board.clone());

// 2. Explicit scale range
let scale = MarkerScalePrior::new(24.0, 48.0);
let cfg = DetectConfig::from_target_and_scale_prior(board.clone(), scale);

// 3. Fixed marker diameter hint (min == max)
let cfg = DetectConfig::from_target_and_marker_diameter(board.clone(), 32.0);
}

All three constructors call two internal derivation functions:

  • apply_target_geometry_priors – derives marker_spec.r_inner_expected and decode.code_band_ratio from the board’s inner/outer radius ratio.
  • apply_marker_scale_prior – derives proposal radii, edge sampling range, ellipse validation bounds, completion ROI, and projective-center shift gate from the scale prior. See MarkerScalePrior for the full derivation rules.

The Detector wrapper

Most users interact with DetectConfig through the Detector struct, which wraps a config and exposes detection methods:

#![allow(unused)]
fn main() {
use ringgrid::{BoardLayout, Detector, MarkerScalePrior};
use std::path::Path;

let board = BoardLayout::from_json_file(Path::new("board_spec.json")).unwrap();

// Convenience constructors mirror DetectConfig
let det = Detector::new(board.clone());                           // default scale
let det = Detector::with_marker_scale(board.clone(),
              MarkerScalePrior::new(24.0, 48.0));                 // explicit range
let det = Detector::with_marker_diameter_hint(board.clone(), 32.0); // fixed size

// One-step from JSON file
let det = Detector::from_target_json_file(Path::new("board_spec.json")).unwrap();

// Full config control
let cfg = DetectConfig::from_target(board);
let det = Detector::with_config(cfg);

// Detect
let result = det.detect(&image);

// Adaptive multi-scale APIs (wide size variation scenes)
let result = det.detect_adaptive(&image);
let result = det.detect_adaptive_with_hint(&image, Some(32.0));
}

Post-construction tuning

After building a Detector, use config_mut() to override individual fields:

#![allow(unused)]
fn main() {
let mut det = Detector::new(board);
det.config_mut().completion.enable = false;
det.config_mut().use_global_filter = false;
det.config_mut().self_undistort.enable = true;
}

Calling set_marker_scale_prior() or set_marker_diameter_hint_px() on DetectConfig re-derives all scale-coupled parameters automatically.

Field reference

FieldTypeDefaultPurpose
marker_scaleMarkerScalePrior14.0–66.0 pxExpected marker diameter range in pixels. Drives derivation of many downstream parameters.
outer_estimationOuterEstimationConfig(see sub-configs)Outer-edge radius hypothesis generation from radial profile peaks.
proposalProposalConfig(derived from scale)Scharr gradient voting and NMS proposal generation. r_min, r_max, nms_radius are auto-derived.
seed_proposalsSeedProposalParamsmerge=3.0, score=1e12, max=512Controls seed injection for multi-pass detection.
edge_sampleEdgeSampleConfig(derived from scale)Radial edge sampling range and ray count. r_min, r_max are auto-derived.
decodeDecodeConfig(derived from board)16-sector code sampling. code_band_ratio is auto-derived from board geometry; codebook_profile defaults to base.
marker_specMarkerSpec(derived from board)Marker geometry specification. r_inner_expected is auto-derived from board inner/outer radius ratio.
inner_fitInnerFitConfig(see sub-configs)Robust inner ellipse fitting: RANSAC params, validation gates.
circle_refinementCircleRefinementMethodProjectiveCenterCenter correction strategy selector: None or ProjectiveCenter.
projective_centerProjectiveCenterParams(see sub-configs)Projective center recovery gates and tuning. max_center_shift_px is auto-derived from scale.
completionCompletionParams(see sub-configs)Completion at missing H-projected board positions. roi_radius_px is auto-derived from scale.
min_semi_axisf643.0Minimum semi-axis length (px) for a valid outer ellipse. Auto-derived from scale.
max_semi_axisf6415.0Maximum semi-axis length (px) for a valid outer ellipse. Auto-derived from scale.
max_aspect_ratiof643.0Maximum aspect ratio (a/b) for a valid ellipse.
dedup_radiusf646.0NMS deduplication radius (px) for final markers.
use_global_filterbooltrueEnable RANSAC homography global filter (requires board layout with marker positions).
ransac_homographyRansacHomographyConfigiters=2000, thresh=5.0RANSAC parameters for homography estimation.
boardBoardLayoutemptyBoard layout defining marker positions and geometry.
id_correctionIdCorrectionConfigenabledStructural consistency verification/recovery of decoded IDs before global filter.
self_undistortSelfUndistortConfigdisabledSelf-undistort estimation from conic consistency of detected ring edges.

Fields marked “auto-derived” are overwritten by the constructors. If you modify marker_scale after construction, call set_marker_scale_prior() to re-derive them.

Source

crates/ringgrid/src/detector/config.rs, crates/ringgrid/src/api.rs

MarkerScalePrior

MarkerScalePrior tells the detector the expected range of marker outer diameters in working-frame pixels. This single prior drives the derivation of proposal search radii, edge sampling extent, ellipse validation bounds, completion ROI size, and projective-center shift gates.

Fields

FieldTypeDefaultDescription
diameter_min_pxf3214.0Minimum expected marker outer diameter in pixels.
diameter_max_pxf3266.0Maximum expected marker outer diameter in pixels.

Constructors

#![allow(unused)]
fn main() {
use ringgrid::MarkerScalePrior;

// Explicit range
let scale = MarkerScalePrior::new(24.0, 48.0);

// Fixed size (min == max)
let scale = MarkerScalePrior::from_nominal_diameter_px(32.0);
}

new(min, max) accepts any order; normalization swaps values if min > max and enforces a hard floor of 4.0 px on both bounds.

from_nominal_diameter_px(d) sets both diameter_min_px and diameter_max_px to d, producing a fixed-size prior. The same 4.0 px floor applies.

Normalization

Every constructor and accessor normalizes the stored range:

  1. Non-finite values are replaced with the corresponding default (14.0 or 66.0).
  2. If min > max, the two are swapped.
  3. min is clamped to at least 4.0 px.
  4. max is clamped to at least min.

The normalized() method returns a normalized copy without mutating the original.

Methods

MethodReturnDescription
diameter_range_px()[f32; 2]Normalized [min, max] diameter in pixels.
nominal_diameter_px()f32Midpoint of the range: 0.5 * (min + max).
nominal_outer_radius_px()f32Half of nominal diameter: 0.25 * (min + max).

Scale-dependent derivation

When a DetectConfig is constructed (or set_marker_scale_prior() is called), the scale prior drives the following parameter derivations. Let r_min = diameter_min_px / 2, r_max = diameter_max_px / 2, r_nom = (r_min + r_max) / 2, and d_nom = r_min + r_max:

Proposal search radii

Derived fieldFormula
proposal.r_minmax(0.4 * r_min, 2.0)
proposal.r_max1.7 * r_max
proposal.nms_radiusmax(0.8 * r_min, 2.0)

Edge sampling range

Derived fieldFormula
edge_sample.r_max2.0 * r_max
edge_sample.r_min1.5 (fixed)

Outer estimation

Derived fieldFormula
outer_estimation.theta_samplesset to edge_sample.n_rays
outer_estimation.search_halfwidth_pxmax(max((r_max - r_min) * 0.5, 2.0), base_default)

Ellipse validation bounds

Derived fieldFormula
min_semi_axismax(0.3 * r_min, 2.0)
max_semi_axismax(2.5 * r_max, min_semi_axis)

Completion ROI

Derived fieldFormula
completion.roi_radius_pxclamp(0.75 * d_nom, 24.0, 80.0)

Projective center shift gate

Derived fieldFormula
projective_center.max_center_shift_pxSome(2.0 * r_nom)

Usage guidance

  • All markers roughly the same size: use from_nominal_diameter_px(d). This sets both bounds equal, producing tight proposal search and validation windows. Measure d as the outer ring diameter in pixels at the typical working distance.

  • Markers vary in apparent size (perspective, varying distance): use new(min, max) with the smallest and largest expected diameters. This widens search and validation windows to accommodate the range. A wider range makes detection more permissive but may increase false positives.

  • Unsure about scale: start with the default (14–66 px) and inspect detection results. Narrow the range once you know the actual marker sizes in your images.

  • Post-construction update: call config.set_marker_scale_prior(new_scale) or config.set_marker_diameter_hint_px(d) to re-derive all coupled parameters without rebuilding the full config.

  • Very wide scale variation: use adaptive multi-scale methods (Detector::detect_adaptive, Detector::detect_adaptive_with_hint, or Detector::detect_multiscale) instead of forcing one very wide prior.

Source

crates/ringgrid/src/detector/config.rs

Sub-Configurations

DetectConfig aggregates several focused sub-configuration structs. This chapter documents each one with its fields, defaults, and role in the detection pipeline.


SeedProposalParams

Controls seed injection for multi-pass detection. In the two-pass pipeline, pass-1 detections are injected as high-priority seed proposals for pass-2.

FieldTypeDefaultDescription
merge_radius_pxf323.0Radius (px) for merging seed centers with detector proposals. Seeds within this distance of an existing proposal are merged rather than duplicated.
seed_scoref321e12Score assigned to injected seed proposals. The high default ensures seeds survive NMS against weaker gradient-based proposals.
max_seedsOption<usize>Some(512)Maximum number of seeds consumed per run. None removes the cap.

Source: crates/ringgrid/src/detector/config.rs


CompletionParams

Controls the homography-guided completion stage. After the global homography filter identifies inliers, the pipeline projects all board marker positions into the image and attempts local fits at positions where no marker was detected. Completion only runs when a valid homography is available.

FieldTypeDefaultDescription
enablebooltrueMaster switch for the completion stage.
roi_radius_pxf3224.0Radial sampling extent (px) for edge sampling around the projected center. Auto-derived from scale prior as clamp(0.75 * d_nom, 24, 80).
reproj_gate_pxf323.0Maximum reprojection error (px) between the fitted center and the H-projected board center. Fits exceeding this gate are rejected.
min_fit_confidencef320.45Minimum fit confidence score in [0, 1] for accepting a completion fit.
min_arc_coveragef320.35Minimum arc coverage (fraction of rays with both edges found). Low coverage indicates the marker is partially occluded or near the image boundary.
max_attemptsOption<usize>NoneOptional cap on the number of completion fits attempted, in ID order. None means try all missing positions.
image_margin_pxf3210.0Skip completion attempts whose projected center is closer than this to the image boundary.

Source: crates/ringgrid/src/detector/config.rs


IdCorrectionConfig

Controls structural ID verification/recovery that runs in finalize after projective center correction and before optional global filtering.

FieldTypeDefaultDescription
enablebooltrueMaster switch for ID correction.
auto_search_radius_outer_mulsVec<f64>[2.4, 2.9, 3.5, 4.2, 5.0]Local-scale staged neighborhood multipliers (derived from pairwise outer ellipse radii).
consistency_outer_mulf643.2Neighborhood multiplier for consistency checks.
consistency_min_neighborsusize1Minimum neighbors required to evaluate consistency evidence.
consistency_min_support_edgesusize1Minimum board-neighbor support edges required for non-soft-locked IDs.
consistency_max_contradiction_fracf320.5Maximum contradiction fraction allowed by consistency checks.
soft_lock_exact_decodebooltrueSoft-lock exact decodes: do not normally override them.
min_votesusize2Minimum votes for candidate acceptance when marker already has an ID.
min_votes_recoverusize1Minimum votes for recovering id=None markers.
min_vote_weight_fracf320.55Winner weighted-vote fraction gate.
h_reproj_gate_pxf6430.0Reprojection gate for rough-homography fallback assignments.
homography_fallback_enablebooltrueEnable rough-homography fallback seeding for unresolved markers.
homography_min_trustedusize24Minimum trusted markers before fallback can run.
homography_min_inliersusize12Minimum inliers for fallback homography acceptance.
max_itersusize5Max local iterative passes per local-stage multiplier.
remove_unverifiedboolfalseRemove unresolved markers instead of clearing their IDs.
seed_min_decode_confidencef320.7Confidence threshold for weak anchor bootstrap.

Source: crates/ringgrid/src/detector/config.rs, crates/ringgrid/src/detector/id_correction/


ProjectiveCenterParams

Controls projective center recovery from the inner/outer conic pencil. When enabled, the detector computes an unbiased center estimate from the intersection geometry of the inner and outer fitted ellipses, correcting for perspective bias in the naive ellipse-center estimate.

Center correction is applied once per marker during the pipeline: before the global filter for fit-decode markers, and after completion for newly added markers.

FieldTypeDefaultDescription
enablebooltrueEnable projective center estimation.
use_expected_ratiobooltrueUse marker_spec.r_inner_expected as an eigenvalue prior when selecting among candidate centers.
ratio_penalty_weightf641.0Weight of the eigenvalue-vs-expected-ratio penalty term in candidate selection. Higher values prefer candidates whose conic-pencil eigenvalue ratio matches the expected inner/outer ratio.
max_center_shift_pxOption<f64>NoneMaximum allowed shift (px) from the pre-correction center. Large jumps are rejected and the original center is kept. Auto-derived from scale prior as 2 * r_nom.
max_selected_residualOption<f64>Some(0.25)Maximum accepted projective-selection residual. Higher values are less strict. None disables this gate.
min_eig_separationOption<f64>Some(1e-6)Minimum eigenvalue separation for a stable conic-pencil eigenpair. Low separation indicates numerical instability. None disables this gate.

Source: crates/ringgrid/src/detector/config.rs


InnerFitConfig

Controls robust inner ellipse fitting. After the outer ellipse is fitted and the code is decoded, the detector fits an inner ellipse to edge points sampled at the expected inner ring radius. The inner ellipse is required for projective center recovery.

FieldTypeDefaultDescription
min_pointsusize20Minimum number of sampled edge points required to attempt a fit.
min_inlier_ratiof320.5Minimum RANSAC inlier ratio for accepting the inner fit.
max_rms_residualf641.0Maximum accepted RMS Sampson residual (px) of the fitted inner ellipse.
max_center_shift_pxf6412.0Maximum allowed center shift (px) from the outer ellipse center to the inner ellipse center.
max_ratio_abs_errorf640.15Maximum absolute error between the recovered inner/outer scale ratio and the radial hint.
local_peak_halfwidth_idxusize3Half-width (in radius-sample indices) of the local search window around the radial hint peak.

Inner fit RANSAC sub-config

The ransac field is a RansacConfig struct embedded within InnerFitConfig:

FieldTypeDefaultDescription
ransac.max_itersusize200Maximum RANSAC iterations for inner ellipse fitting.
ransac.inlier_thresholdf641.5Inlier threshold (Sampson distance in px).
ransac.min_inliersusize8Minimum inlier count for a valid inner ellipse model.
ransac.seedu6443Random seed for reproducibility.

Source: crates/ringgrid/src/detector/config.rs


CircleRefinementMethod

Enum selector for the center correction strategy applied after local ellipse fits.

VariantDescription
NoneDisable center correction. The naive ellipse center is used as-is.
ProjectiveCenter(default) Run projective center recovery from the inner/outer conic pencil. Requires both inner and outer ellipses to be successfully fitted.
#![allow(unused)]
fn main() {
use ringgrid::CircleRefinementMethod;

// Check if projective center is active
let method = CircleRefinementMethod::ProjectiveCenter;
assert!(method.uses_projective_center());
}

Source: crates/ringgrid/src/detector/config.rs


RansacHomographyConfig

Controls the RANSAC homography estimation used for global filtering and completion. The homography maps board-space marker positions (mm) to image-space pixel coordinates.

FieldTypeDefaultDescription
max_itersusize2000Maximum RANSAC iterations.
inlier_thresholdf645.0Inlier threshold: maximum reprojection error (px) for a correspondence to be counted as an inlier.
min_inliersusize6Minimum number of inliers for the homography to be accepted. The pipeline requires at least 4 decoded markers to attempt RANSAC.
seedu640Random seed for reproducibility.

Source: crates/ringgrid/src/homography/core.rs


SelfUndistortConfig

Controls intrinsics-free distortion estimation from ring marker conic consistency. When enabled, the detector estimates a one-parameter division model that maps distorted image coordinates to undistorted working coordinates. The optimization minimizes the RMS Sampson residual of inner/outer ellipse fits across all detected markers using golden-section search over the lambda parameter.

FieldTypeDefaultDescription
enableboolfalseMaster switch. When false, no self-undistort estimation runs.
lambda_range[f64; 2][-8e-7, 8e-7]Search range for the division model parameter lambda.
max_evalsusize40Maximum function evaluations for the golden-section 1D optimizer.
min_markersusize6Minimum number of markers with both inner and outer edge points required to attempt estimation.
improvement_thresholdf640.01Relative improvement threshold: the model is applied only if (baseline - optimum) / baseline exceeds this value.
min_abs_improvementf641e-4Minimum absolute objective improvement required. Prevents applying corrections when the objective is near the numerical noise floor.
trim_fractionf640.1Trim fraction for robust aggregation: drop this fraction of scores from both tails before averaging per-marker objectives.
min_lambda_absf645e-9Minimum absolute value of lambda required. Very small lambda values are treated as “no correction”.
reject_range_edgebooltrueReject solutions that land near the lambda search range boundaries, which may indicate the true optimum lies outside the range.
range_edge_margin_fracf640.02Fraction of the lambda range treated as an unstable boundary zone.
validation_min_markersusize24Minimum decoded-ID correspondences needed for homography-based validation of the estimated model.
validation_abs_improvement_pxf640.05Minimum absolute homography self-error improvement (px) required during validation.
validation_rel_improvementf640.03Minimum relative homography self-error improvement required during validation.

When the number of decoded markers exceeds validation_min_markers, the estimator uses a homography-based objective (reprojection error) instead of the conic-consistency fallback. The final model must pass both the conic-consistency improvement check and the homography validation check (if enough markers are available) to be applied.

Source: crates/ringgrid/src/pixelmap/self_undistort.rs

Detection Output Format

ringgrid exposes two closely related JSON shapes:

  • the library value DetectionResult, serialized directly with serde_json
  • the CLI output file written by ringgrid detect --out ..., which flattens the same DetectionResult fields at the top level and may add a few CLI-only fields

The primary payload is always detected_markers. Everything else describes the image, coordinate frames, optional board homography, and optional diagnostics.

Optional fields are omitted when absent. They are not serialized as null.

Library vs CLI

If you serialize the Rust result directly:

#![allow(unused)]
fn main() {
let json = serde_json::to_string_pretty(&result)?;
}

you get the fields of DetectionResult only.

If you run:

ringgrid detect --image photo.png --out result.json

the written JSON contains those same top-level fields, and may additionally include:

  • camera when detection used --calibration or inline --cam-* parameters
  • proposal_frame, proposal_count, and proposals when --include-proposals is enabled

Top-Level Fields

FieldPresent whenMeaning
detected_markersalwaysFinal emitted markers. Each entry is a DetectedMarker.
center_framealwaysCoordinate frame of each marker center. Current contract: always image.
homography_framealwaysCoordinate frame of the homography matrix: image or working.
image_sizealwaysInput image dimensions as [width, height].
homographywhen fitted3x3 row-major homography mapping board millimeters into homography_frame.
ransacwhen homography existsQuality statistics for the fitted homography. See RansacStats.
self_undistortwhen self-undistort ranEstimated division-model correction and whether it was applied.
cameraCLI only, when camera input was providedThe CameraModel used by the two-pass mapper path.
proposal_frameCLI only, with --include-proposalsCoordinate frame of proposals. Currently always image.
proposal_countCLI only, with --include-proposalsNumber of serialized proposals.
proposalsCLI only, with --include-proposalsPass-1 center proposals, each with x, y, and score.

What Each Marker Contains

Each entry in detected_markers describes one final marker hypothesis after the full pipeline and post-processing:

FieldMeaning
idDecoded codebook index. Omitted when decoding was rejected or cleared.
board_xy_mmBoard-space marker location in millimeters for valid decoded IDs.
confidenceCombined fit/decode confidence in [0, 1].
centerMarker center in raw image pixels. Always safe to overlay on the original image.
center_mappedWorking-frame center when a mapper was active.
ellipse_outer, ellipse_innerFitted ellipse parameters. With a mapper, ellipse coordinates are in the working frame.
edge_points_outer, edge_points_innerRaw subpixel edge points retained for diagnostics and downstream analysis.
fitFit-quality metrics such as arc coverage, residuals, angular gaps, and reprojection error.
decodeDecode-quality metrics such as observed word, best distance, margin, and rotation.
sourceWhich pipeline path produced the final marker.

source uses these enum values:

  • fit_decoded: normal proposal -> fit -> decode path
  • completion: homography-guided completion stage
  • seeded_pass: pass-2 seeded re-fit in mapper-based detection

Markers without id can still be useful geometrically: they keep center, ellipse fits, and fit-quality metrics, but they do not contribute to homography estimation.

Frames and Homography

Two frame fields tell you how to interpret the geometry:

  • center_frame describes DetectedMarker.center
  • homography_frame describes homography

Important contract:

  • center is always in the original image frame
  • center_mapped is the undistorted working-frame center when a mapper was active
  • homography maps board millimeters into the frame named by homography_frame

This means:

  • use center for overlays on the source image
  • use center_mapped and homography together when working in the mapper’s undistorted frame

See Coordinate Frames for the exact conventions.

Typical CLI Output

{
  "detected_markers": [
    {
      "id": 42,
      "board_xy_mm": [24.0, 16.0],
      "confidence": 0.95,
      "center": [512.3, 384.7],
      "ellipse_outer": {
        "cx": 512.3,
        "cy": 384.7,
        "a": 16.1,
        "b": 15.8,
        "angle": 0.12
      },
      "fit": {
        "n_angles_total": 64,
        "n_angles_with_both_edges": 58,
        "n_points_outer": 58,
        "n_points_inner": 52,
        "ransac_inlier_ratio_outer": 0.93,
        "rms_residual_outer": 0.31
      },
      "decode": {
        "observed_word": 45231,
        "best_id": 42,
        "best_rotation": 3,
        "best_dist": 0,
        "margin": 5,
        "decode_confidence": 0.95
      },
      "source": "fit_decoded"
    }
  ],
  "center_frame": "image",
  "homography_frame": "image",
  "image_size": [1920, 1080],
  "homography": [
    [3.52, 0.08, 640.1],
    [-0.05, 3.48, 480.3],
    [0.00012, -0.00003, 1.0]
  ],
  "ransac": {
    "n_candidates": 35,
    "n_inliers": 33,
    "threshold_px": 5.0,
    "mean_err_px": 0.72,
    "p95_err_px": 1.45
  }
}

When the CLI is run with a camera model or proposal diagnostics, extra top-level fields are added:

{
  "camera": {
    "intrinsics": { "fx": 900.0, "fy": 900.0, "cx": 640.0, "cy": 480.0 },
    "distortion": { "k1": -0.15, "k2": 0.05, "p1": 0.001, "p2": -0.001, "k3": 0.0 }
  },
  "proposal_frame": "image",
  "proposal_count": 128,
  "proposals": [
    { "x": 510.2, "y": 381.7, "score": 94.8 }
  ]
}

Detailed Field References

DetectionResult

DetectionResult is the top-level output returned by Detector::detect() and Detector::detect_with_mapper(). It contains all detected markers, the fitted board-to-image homography (when available), and metadata describing the coordinate frames used.

For the full CLI JSON file shape written by ringgrid detect, including optional top-level camera and proposal-diagnostics fields, see Detection Output Format.

Source: crates/ringgrid/src/pipeline/result.rs

Fields

FieldTypeDescription
detected_markersVec<DetectedMarker>All detected markers in the image. See DetectedMarker.
center_frameDetectionFrameCoordinate frame of each marker’s center field. Current contract: always Image.
homography_frameDetectionFrameCoordinate frame of the homography matrix (Image or Working).
image_size[u32; 2]Image dimensions as [width, height].
homographyOption<[[f64; 3]; 3]>3x3 row-major board-to-output-frame homography. Present when 4 or more markers were decoded.
ransacOption<RansacStats>RANSAC quality statistics for the homography fit. See RansacStats.
self_undistortOption<SelfUndistortResult>Estimated division-model distortion correction, present when self-undistort mode was used.

DetectionFrame

DetectionFrame is an enum with two variants:

  • Image – raw image pixel coordinates.
  • Working – working-frame coordinates (undistorted pixel space when a PixelMapper is active).

Frame conventions

The values of center_frame and homography_frame depend on how detection was invoked:

Detection modecenter_framehomography_frame
Detector::detect() (no mapper)ImageImage
Detector::detect_with_mapper()ImageWorking
Self-undistort (correction not applied)ImageImage
Self-undistort (correction applied)ImageWorking

Marker centers (DetectedMarker::center) are always in image-space pixel coordinates, regardless of mapper usage. When a mapper is active, the working-frame center is available in DetectedMarker::center_mapped. The homography maps board coordinates to whichever frame homography_frame indicates.

Homography

The homography field contains a 3x3 row-major matrix that maps board coordinates (in mm) to the output frame (image or working, as indicated by homography_frame). It is computed via RANSAC when at least 4 decoded markers are available.

To project a board point (bx, by) through the homography:

[u']     [h[0][0]  h[0][1]  h[0][2]]   [bx]
[v'] =   [h[1][0]  h[1][1]  h[1][2]] * [by]
[w ]     [h[2][0]  h[2][1]  h[2][2]]   [1 ]

pixel_x = u' / w
pixel_y = v' / w

Serialization

DetectionResult derives serde::Serialize and serde::Deserialize. Optional fields (homography, ransac, self_undistort) use #[serde(skip_serializing_if = "Option::is_none")], so they are omitted from the JSON output when absent.

Example JSON

A typical serialized result with a fitted homography:

{
  "detected_markers": [
    {
      "id": 42,
      "confidence": 0.95,
      "center": [512.3, 384.7],
      "ellipse_outer": {
        "cx": 512.3, "cy": 384.7, "a": 16.1, "b": 15.8, "angle": 0.12
      },
      "fit": {
        "n_angles_total": 64,
        "n_angles_with_both_edges": 58,
        "n_points_outer": 58,
        "n_points_inner": 52,
        "ransac_inlier_ratio_outer": 0.93,
        "rms_residual_outer": 0.31
      },
      "decode": {
        "observed_word": 45231,
        "best_id": 42,
        "best_rotation": 3,
        "best_dist": 0,
        "margin": 5,
        "decode_confidence": 0.95
      }
    }
  ],
  "center_frame": "image",
  "homography_frame": "image",
  "image_size": [1920, 1080],
  "homography": [
    [3.52, 0.08, 640.1],
    [-0.05, 3.48, 480.3],
    [0.00012, -0.00003, 1.0]
  ],
  "ransac": {
    "n_candidates": 35,
    "n_inliers": 33,
    "threshold_px": 5.0,
    "mean_err_px": 0.72,
    "p95_err_px": 1.45
  }
}

When no homography could be fitted (fewer than 4 decoded markers), the homography and ransac fields are omitted entirely from the JSON output.

DetectedMarker

DetectedMarker represents a single detected ring marker in the image. Each marker carries its decoded ID (when available), pixel-space center, fitted ellipse parameters, and quality metrics.

Source: crates/ringgrid/src/detector/marker_build.rs

Fields

FieldTypeDescription
idOption<usize>Codebook index in the active profile. None if decoding was rejected due to insufficient confidence or Hamming distance.
board_xy_mmOption<[f64; 2]>Board-space marker location in millimeters (BoardLayout::xy_mm semantics). Present only when id is valid for the active board layout.
confidencef32Combined detection and decode confidence in [0, 1].
center[f64; 2]Marker center in raw image pixel coordinates [x, y].
center_mappedOption<[f64; 2]>Marker center in working-frame coordinates. Present only when a PixelMapper is active.
ellipse_outerOption<Ellipse>Fitted outer ring ellipse parameters.
ellipse_innerOption<Ellipse>Fitted inner ring ellipse parameters. Present when inner fitting succeeded.
edge_points_outerOption<Vec<[f64; 2]>>Raw sub-pixel outer edge inlier points used for ellipse fitting.
edge_points_innerOption<Vec<[f64; 2]>>Raw sub-pixel inner edge inlier points used for ellipse fitting.
fitFitMetricsFit quality metrics. See FitMetrics.
decodeOption<DecodeMetrics>Decode quality metrics. Present when decoding was attempted. See FitMetrics & DecodeMetrics.
sourceDetectionSourcePipeline path that produced the final marker: fit_decoded, completion, or seeded_pass.

DetectionSource

DetectedMarker.source tells you how the marker entered the final result:

  • fit_decoded – the normal proposal -> fit -> decode path
  • completion – the homography-guided completion stage filled a missing board marker
  • seeded_pass – the marker was re-fitted during mapper-based pass-2 detection

Center coordinate frames

The center field is always in raw image pixel coordinates, regardless of whether a PixelMapper is active. This ensures that downstream consumers can always overlay detections on the original image without coordinate conversion.

When a mapper is active (e.g., radial distortion correction), center_mapped provides the corresponding position in the working frame (undistorted pixel space). The working-frame center is used internally for homography fitting and completion, but the image-space center remains the canonical output.

Ellipse coordinate frame

The ellipse_outer and ellipse_inner fields use the Ellipse type with five parameters:

ParameterDescription
cx, cyEllipse center
aSemi-major axis length
bSemi-minor axis length
angleRotation angle in radians

When no mapper is active, the ellipse coordinates are in image space. When a mapper is active, ellipses are in the working frame (undistorted pixel space), because edge sampling and fitting operate in that frame. This means that ellipse_outer.cx may differ from center[0] when a mapper is active.

Markers without decoded IDs

Markers with id: None were detected (ellipse fitted successfully) but failed the codebook matching or structural verification stage. Possible reasons include:

  • Hamming distance to the nearest codeword exceeded the threshold.
  • Decode confidence fell below the minimum.
  • ID contradicted board-local structural consistency in id_correction.
  • Insufficient contrast in the code band.

These markers still have valid center, ellipse_outer, and fit fields. They can be useful for distortion estimation or as candidate positions, but they do not contribute to the homography fit.

ID/board consistency contract

Final emitted markers enforce strict ID/layout consistency:

  • if id is Some(i), then board_xy_mm is present and equals the active board layout coordinate of i
  • if id is None, then board_xy_mm is omitted
  • if a decoded ID is not found in the active board layout, it is cleared before output (id=None, board_xy_mm=None)

Serialization

DetectedMarker derives serde::Serialize and serde::Deserialize. All Option fields use #[serde(skip_serializing_if = "Option::is_none")], so absent fields are omitted from JSON output.

Example JSON

A fully decoded marker:

{
  "id": 127,
  "board_xy_mm": [40.0, 24.0],
  "confidence": 0.92,
  "center": [800.5, 600.2],
  "ellipse_outer": {
    "cx": 800.5, "cy": 600.2, "a": 16.3, "b": 15.9, "angle": 0.05
  },
  "ellipse_inner": {
    "cx": 800.4, "cy": 600.1, "a": 10.7, "b": 10.4, "angle": 0.06
  },
  "fit": {
    "n_angles_total": 64,
    "n_angles_with_both_edges": 60,
    "n_points_outer": 60,
    "n_points_inner": 55,
    "ransac_inlier_ratio_outer": 0.95,
    "ransac_inlier_ratio_inner": 0.91,
    "rms_residual_outer": 0.28,
    "rms_residual_inner": 0.35
  },
  "decode": {
    "observed_word": 52419,
    "best_id": 127,
    "best_rotation": 7,
    "best_dist": 0,
    "margin": 4,
    "decode_confidence": 0.92
  },
  "source": "fit_decoded"
}

A marker that was detected but not decoded:

{
  "confidence": 0.3,
  "center": [200.1, 150.8],
  "ellipse_outer": {
    "cx": 200.1, "cy": 150.8, "a": 14.2, "b": 12.1, "angle": 0.78
  },
  "fit": {
    "n_angles_total": 64,
    "n_angles_with_both_edges": 31,
    "n_points_outer": 42,
    "n_points_inner": 0,
    "ransac_inlier_ratio_outer": 0.72,
    "rms_residual_outer": 0.89
  },
  "source": "fit_decoded"
}

FitMetrics & DecodeMetrics

These two structs provide detailed quality metrics for each detected marker. FitMetrics describes how well the ellipse(s) fit the observed edge points. DecodeMetrics describes how confidently the 16-sector code was matched to a codebook entry.

Source: crates/ringgrid/src/detector/marker_build.rs (FitMetrics), crates/ringgrid/src/marker/decode.rs (DecodeMetrics)

FitMetrics

FitMetrics is always present on every DetectedMarker. It reports edge sampling coverage and ellipse fit quality.

Fields

FieldTypeDescription
n_angles_totalusizeTotal number of radial rays cast from the candidate center.
n_angles_with_both_edgesusizeNumber of rays where both inner and outer ring edges were found.
n_points_outerusizeNumber of outer edge points used for the ellipse fit.
n_points_innerusizeNumber of inner edge points used for the inner ellipse fit. 0 if no inner fit was performed.
ransac_inlier_ratio_outerOption<f32>Fraction of outer edge points classified as RANSAC inliers.
ransac_inlier_ratio_innerOption<f32>Fraction of inner edge points classified as RANSAC inliers.
rms_residual_outerOption<f64>RMS Sampson distance of outer edge points to the fitted ellipse (in pixels).
rms_residual_innerOption<f64>RMS Sampson distance of inner edge points to the fitted ellipse (in pixels).
max_angular_gap_outerOption<f64>Largest angular gap between consecutive outer-edge samples (radians). Large gaps indicate missing ring coverage or occlusion.
max_angular_gap_innerOption<f64>Largest angular gap between consecutive inner-edge samples (radians).
inner_fit_statusOption<InnerFitStatus>Outcome of the inner-fit stage: ok, rejected, or failed.
inner_fit_reasonOption<InnerFitReason>Stable reason code explaining why inner fitting was rejected or failed.
neighbor_radius_ratioOption<f32>Ratio of this marker’s outer radius to nearby decoded neighbors. Low values can indicate inner-as-outer contamination.
inner_theta_consistencyOption<f32>Fraction of angular samples that agree on the inner-edge location.
radii_std_outer_pxOption<f32>Standard deviation of per-ray outer radii. High spread suggests unstable outer-edge sampling.
h_reproj_err_pxOption<f32>Final homography reprojection error for this marker in pixels. Present when a global homography is available.

Interpreting FitMetrics

RANSAC inlier ratio measures how consistently the edge points agree with the fitted ellipse:

ransac_inlier_ratio_outerInterpretation
> 0.90Excellent – clean edges with minimal outliers
0.80 – 0.90Good – some edge noise or partial occlusion
< 0.70Poor – significant outliers, possible false detection

RMS Sampson residual measures the geometric precision of the fit:

rms_residual_outerInterpretation
< 0.3 pxExcellent sub-pixel precision
0.3 – 0.5 pxGood precision
0.5 – 1.0 pxAcceptable but noisy
> 1.0 pxPoor fit, possibly wrong feature

Arc coverage is the ratio n_angles_with_both_edges / n_angles_total. It indicates how much of the ring perimeter was successfully sampled:

Coverage ratioInterpretation
> 0.85Full ring visible, high confidence
0.5 – 0.85Partial occlusion or edge-of-frame
< 0.5Severely occluded, likely unreliable

Angular gaps (max_angular_gap_outer, max_angular_gap_inner) help detect partial arcs. Even with a decent point count, a single large missing sector can make a fit less trustworthy than the residual alone suggests.

Neighbor radius ratio (neighbor_radius_ratio) is a structural sanity check added late in the pipeline. Values well below 1.0 compared with nearby decoded markers often indicate that some rays latched onto the inner ring instead of the outer one.

Homography reprojection error (h_reproj_err_px) is the most directly useful per-marker global consistency metric once a homography exists. Higher values mean the marker’s center disagrees with the board layout even if the local ellipse fit looked good.

DecodeMetrics

DecodeMetrics is present on a DetectedMarker when code decoding was attempted. It reports the raw sampled word and the quality of the codebook match.

Fields

FieldTypeDescription
observed_wordu16Raw 16-bit word sampled from the code band. Each bit corresponds to one sector (bright = 1, dark = 0).
best_idusizeIndex of the best-matching codebook entry in the active profile.
best_rotationu8Cyclic rotation (0–15) that produced the best match. Each unit is 22.5 degrees.
best_distu8Hamming distance between the observed word (at best rotation) and the codebook entry.
marginu8Gap between the best and second-best Hamming distances: second_best_dist - best_dist.
decode_confidencef32Heuristic confidence score in [0, 1], combining Hamming distance and margin.

Interpreting DecodeMetrics

Hamming distance (best_dist) tells how many of the 16 sectors disagree with the matched codeword:

best_distInterpretation
0Exact match – no bit errors
1 – 2Minor noise, still reliable
3At the default acceptance threshold
> 3Rejected by default (configurable via DecodeConfig::max_decode_dist)

Margin (margin) measures how unambiguous the match is. It is the difference in Hamming distance between the best and second-best codebook matches:

marginInterpretation
>= 4Highly unambiguous
3Reliable
2Acceptable but less certain
1Risky – two codewords are nearly tied
0Ambiguous – the match could be wrong

Decode confidence (decode_confidence) is a composite heuristic in [0, 1] that accounts for both Hamming distance and margin. Higher values indicate more reliable decodes. The default minimum threshold is 0.30 (configurable via DecodeConfig::min_decode_confidence).

Polarity handling

The decoder tries both normal and inverted polarity of the sampled word (bitwise NOT) and picks whichever produces the better codebook match. The observed_word in DecodeMetrics reflects the polarity that was actually used for matching.

Serialization

Both structs derive serde::Serialize and serde::Deserialize. Optional fields use #[serde(skip_serializing_if = "Option::is_none")] and are omitted from JSON output when absent.

RansacStats

RansacStats reports the quality of the RANSAC-fitted board-to-image homography. It is present in DetectionResult::ransac whenever a homography was successfully estimated (requires at least 4 decoded markers).

Source: crates/ringgrid/src/homography/core.rs

Fields

FieldTypeDescription
n_candidatesusizeTotal number of decoded marker correspondences fed to RANSAC.
n_inliersusizeNumber of correspondences classified as inliers after the final refit.
threshold_pxf64Reprojection error threshold (in working-frame pixels) used to classify inliers.
mean_err_pxf64Mean reprojection error across all inliers (in working-frame pixels).
p95_err_pxf6495th percentile reprojection error across inliers (in working-frame pixels).

Interpretation

Mean reprojection error

The mean_err_px field is the single most informative quality indicator for the homography fit:

mean_err_pxAssessment
< 0.5 pxExcellent – very precise calibration-grade fit
0.5 – 1.0 pxGood – suitable for most applications
1.0 – 3.0 pxAcceptable – some noise or mild distortion present
3.0 – 5.0 pxMarginal – consider checking for distortion, wrong scale, or occlusion
> 5.0 pxPoor – likely issues with marker scale, large lens distortion, or significant occlusion

Inlier ratio

The ratio n_inliers / n_candidates indicates how clean the set of decoded markers is:

Inlier ratioAssessment
> 0.90Excellent – nearly all detections are consistent
0.80 – 0.90Good – a few outliers filtered
0.60 – 0.80Some markers have incorrect IDs or poor localization
< 0.60Problematic – many false decodes or systematic error

Tail behavior

When p95_err_px is significantly larger than mean_err_px (e.g., more than 3x), a small number of inlier markers have notably worse localization than the rest. This can indicate:

  • A few markers near the image edge with higher distortion.
  • Partially occluded markers that passed the inlier threshold but are not well-localized.
  • A mild systematic error (e.g., wrong marker diameter) that affects markers far from the image center more than those near it.

Threshold

The threshold_px field records the reprojection error threshold used during RANSAC. Any correspondence with error below this threshold is classified as an inlier. The default is 5.0 pixels (configurable via RansacHomographyConfig::inlier_threshold). After RANSAC selects the best model, the homography is refit using all inliers, and the final n_inliers, mean_err_px, and p95_err_px are recomputed against the refit model.

Example JSON

{
  "n_candidates": 48,
  "n_inliers": 45,
  "threshold_px": 5.0,
  "mean_err_px": 0.63,
  "p95_err_px": 1.22
}

Absence

When DetectionResult::ransac is None, no homography was fitted. This happens when fewer than 4 markers were decoded or when RANSAC failed to find enough inliers (controlled by RansacHomographyConfig::min_inliers, default 6).

Simple Detection

Simple detection is the most straightforward mode: no camera model, no distortion correction. The detector runs a single pass directly in image pixel coordinates.

Pipeline

When you call detector.detect(&image) with self-undistort disabled (the default), the pipeline runs once in raw image coordinates:

  1. Gradient voting and NMS produce candidate centers.
  2. Outer and inner ellipses are fitted via RANSAC.
  3. 16-sector codes are sampled and matched against the active embedded codebook profile (base by default).
  4. Spatial and ID-based deduplication removes redundant detections.
  5. If enough decoded markers exist, a RANSAC homography is fitted.
  6. Completion fills in missing markers at H-projected positions.

All geometry stays in image space throughout.

Coordinate Frames

FieldFrame
centerImage (distorted pixel coordinates)
center_mappedNone
homographyImage -> Board (maps board mm to image pixels)
center_frameDetectionFrame::Image
homography_frameDetectionFrame::Image

When to Use

  • The camera has negligible lens distortion.
  • You want the fastest possible results without extra configuration.
  • You are working with synthetic or pre-rectified images.
  • You plan to handle distortion correction externally.

Basic Usage

The minimal workflow loads a board layout, opens a grayscale image, and runs detection:

#![allow(unused)]
fn main() {
use ringgrid::{BoardLayout, Detector};
use std::path::Path;

let board = BoardLayout::from_json_file(Path::new("target.json"))?;
let image = image::open("photo.png")?.to_luma8();

let detector = Detector::new(board);
let result = detector.detect(&image);

for marker in &result.detected_markers {
    if let Some(id) = marker.id {
        println!("Marker {} at ({:.1}, {:.1})", id, marker.center[0], marker.center[1]);
    }
}
}

Providing a Marker Diameter Hint

If you know the approximate marker diameter in pixels, passing it as a hint narrows the radius search and speeds up detection:

#![allow(unused)]
fn main() {
let detector = Detector::with_marker_diameter_hint(board, 32.0);
}

Providing a Scale Range

When marker sizes vary across the image (e.g. perspective foreshortening), you can specify a min/max diameter range with MarkerScalePrior:

#![allow(unused)]
fn main() {
use ringgrid::MarkerScalePrior;

let detector = Detector::with_marker_scale(board, MarkerScalePrior::new(14.0, 66.0));
}

For very wide variation (for example very small and very large markers in one image), switch to Adaptive Scale Detection.

One-Step Construction from JSON

Detector::from_target_json_file loads the board layout and creates the detector in a single call:

#![allow(unused)]
fn main() {
let detector = Detector::from_target_json_file(Path::new("target.json"))?;
}

Post-Construction Tuning

After creating the detector you can adjust individual configuration fields through config_mut(). For example, to disable the completion stage:

#![allow(unused)]
fn main() {
let mut detector = Detector::new(board);
detector.config_mut().completion.enable = false;
}

Serializing Results

DetectionResult implements serde::Serialize, so you can write it to JSON directly:

#![allow(unused)]
fn main() {
let json = serde_json::to_string_pretty(&result)?;
std::fs::write("output.json", json)?;
}

The output JSON contains detected markers with their IDs, centers, ellipse parameters, fit metrics, detection source, and (when available) the board-to-image homography. See Detection Output Format for the full serialized schema.

Source Files

  • crates/ringgrid/src/api.rsDetector struct and all constructor variants.
  • crates/ringgrid/examples/basic_detect.rs – complete runnable example.

Proposal Diagnostics

The proposal stage can be run standalone to inspect candidate centers and the vote accumulator heatmap without running the full detection pipeline.

Python API

Raw mode (no scale prior)

import ringgrid
from ringgrid import viz

# Default ProposalConfig
proposals = ringgrid.propose("photo.png")
result = ringgrid.propose_with_heatmap("photo.png")

print(len(result.proposals))
print(result.heatmap.shape)  # (H, W), float32

viz.plot_proposal_diagnostics(
    image="photo.png",
    diagnostics=result,
    out="proposal_overlay.png",
)

Detector-aware mode (scale-tuned)

When a board layout and marker diameter are available, the proposal config is derived from MarkerScalePrior for tighter search windows:

board = ringgrid.BoardLayout.default()
cfg = ringgrid.DetectConfig(board)
detector = ringgrid.Detector(cfg)

result = detector.propose_with_heatmap("photo.png")

Or via the module-level convenience function:

result = ringgrid.propose_with_heatmap(
    "photo.png",
    target=board,
    marker_diameter=32.0,
)

Custom ProposalConfig

config = ringgrid.ProposalConfig(
    r_min=5.0,
    r_max=40.0,
    min_distance=15.0,
    edge_thinning=True,
)
result = ringgrid.propose_with_heatmap("photo.png", config=config)

Rust API

The standalone proposal module provides entry points that work on any grayscale image, independent of the ringgrid detection pipeline:

#![allow(unused)]
fn main() {
use ringgrid::proposal::{find_ellipse_centers, find_ellipse_centers_with_heatmap, ProposalConfig};

let config = ProposalConfig {
    r_min: 5.0,
    r_max: 30.0,
    min_distance: 15.0,
    ..Default::default()
};

// Proposals only
let proposals = find_ellipse_centers(&gray, &config);

// Proposals + heatmap
let result = find_ellipse_centers_with_heatmap(&gray, &config);
println!("heatmap size: {:?}", result.image_size);
}

ProposalResult Fields

FieldTypeDescription
proposalslist[Proposal]Detected center candidates with (x, y, score)
heatmapnp.ndarray (H, W), float32Post-smoothed vote accumulator used for NMS
image_size[int, int][width, height] of the input image

The heatmap is the Gaussian-smoothed vote accumulator that the proposal stage uses for thresholding and NMS. It is useful for understanding where the detector sees radial symmetry evidence.

ProposalConfig Parameters

ParameterDefaultDescription
r_min3.0Minimum voting radius (pixels)
r_max12.0Maximum voting radius (pixels)
min_distance10.0Minimum distance between output proposals (pixels)
grad_threshold0.05Gradient magnitude threshold (fraction of max)
min_vote_frac0.1Minimum accumulator peak (fraction of max)
accum_sigma2.0Gaussian smoothing sigma
edge_thinningtrueApply Canny-style gradient NMS before voting
max_candidatesNoneOptional hard cap on proposals

Visualization Tool

The repo includes a CLI tool for proposal visualization with optional ground-truth recall overlay:

python tools/plot_proposal.py \
    --image tools/out/synth_001/img_0000.png \
    --gt tools/out/synth_001/gt_0000.json \
    --out tools/out/synth_001/proposals_0000.png

Detector-aware mode (with marker scale prior):

python tools/plot_proposal.py \
    --image testdata/target_3_split_00.png \
    --target tools/out/target_faststart/board_spec.json \
    --marker-diameter 32.0 \
    --out proposals_overlay.png

Backward Compatibility

The Python class ProposalDiagnostics is a deprecated alias for ProposalResult. Existing code using ProposalDiagnostics will continue to work.

Adaptive Scale Detection

Adaptive scale detection handles images where marker diameters vary widely (for example near/far perspective or mixed focal lengths).

Why Use It

A single marker-size prior can under-detect:

  • very small markers (proposal/search window too large or weak)
  • very large markers (proposal/search window too small)

Adaptive mode runs multiple scale tiers and merges results with size-aware dedup.

API Entry Points

#![allow(unused)]
fn main() {
use ringgrid::{BoardLayout, Detector, ScaleTiers};
use std::path::Path;

let board = BoardLayout::from_json_file(Path::new("target.json"))?;
let detector = Detector::new(board);
let image = image::open("photo.png")?.to_luma8();

// 1) Automatic probe + auto-tier selection
let r1 = detector.detect_adaptive(&image);

// 2) Optional nominal size hint (px) -> 2-tier bracket around hint
let r2 = detector.detect_adaptive_with_hint(&image, Some(32.0));

// 3) Explicit manual tiers
let tiers = ScaleTiers::four_tier_wide();
let r3 = detector.detect_multiscale(&image, &tiers);
let _ = (r1, r2, r3);
Ok::<(), Box<dyn std::error::Error>>(())
}

Tier Presets

PresetTiersDiameter rangeTypical use
ScaleTiers::four_tier_wide()48–220 pxUnknown or extreme scale variation
ScaleTiers::two_tier_standard()214–100 pxModerate variation, lower runtime
ScaleTiers::single(prior)1customSingle-pass equivalent

How detect_adaptive Chooses Tiers

  1. Runs a lightweight scale probe to estimate dominant code-band radii.
  2. Builds one or more tiers from probe clusters (ScaleTiers::from_detected_radii).
  3. Falls back to ScaleTiers::four_tier_wide() if probe signal is unavailable.
  4. Runs one full detect pass per tier.
  5. Merges all markers with size-consistency-aware dedup.
  6. Runs global filter + completion + final homography refit once on merged results.

When To Prefer Each Method

  • detect():
    • fastest and simplest
    • best when marker size range is relatively tight
  • detect_adaptive():
    • good default when scale is unknown
    • robust across mixed near/far markers
  • detect_adaptive_with_hint(..., Some(d)):
    • use when you have an approximate diameter
    • skips probe and narrows search to a focused two-tier bracket
  • detect_multiscale(...):
    • use when you need explicit control over tiers
    • useful for reproducible experiments and benchmarks

CLI Status

Adaptive entry points are currently Rust API methods. The CLI ringgrid detect uses the regular config-driven detection flow (detect / detect_with_mapper).

Source

  • crates/ringgrid/src/api.rs
  • crates/ringgrid/src/pipeline/run.rs
  • crates/ringgrid/src/detector/config.rs

External PixelMapper

When you have calibrated camera intrinsics and distortion coefficients, you can supply them to the detector as a CameraModel. This enables a two-pass pipeline that accounts for lens distortion during fitting and decoding, producing more accurate marker centers and a cleaner homography.

Two-Pass Pipeline

Calling detector.detect_with_mapper(&image, &camera) runs:

  1. Pass 1 (no mapper) – standard single-pass detection in image coordinates. This produces seed proposals (approximate marker locations).
  2. Pass 2 (with mapper) – re-runs fitting and decoding around the pass-1 seeds, mapping edge samples through the distortion model so that ellipse fitting operates in undistorted (working-frame) coordinates.

The two-pass approach avoids the cost of undistorting the entire image while still giving the fitting stages clean, distortion-free geometry.

Camera Model

CameraModel pairs pinhole intrinsics with Brown-Conrady radial-tangential distortion:

#![allow(unused)]
fn main() {
use ringgrid::{CameraIntrinsics, CameraModel, RadialTangentialDistortion};

let camera = CameraModel {
    intrinsics: CameraIntrinsics {
        fx: 900.0,       // focal length x (pixels)
        fy: 900.0,       // focal length y (pixels)
        cx: 640.0,       // principal point x (pixels)
        cy: 480.0,       // principal point y (pixels)
    },
    distortion: RadialTangentialDistortion {
        k1: -0.15,       // radial
        k2: 0.05,        // radial
        p1: 0.001,       // tangential
        p2: -0.001,      // tangential
        k3: 0.0,         // radial (6th order)
    },
};
}

The distortion model follows the standard Brown-Conrady convention used by OpenCV and most calibration toolboxes:

  • k1, k2, k3 – radial distortion coefficients.
  • p1, p2 – tangential (decentering) distortion coefficients.

The CLI accepts the same camera model via ringgrid detect --calibration camera_model.json. The JSON can be either the direct serde shape:

{
  "intrinsics": { "fx": 900.0, "fy": 900.0, "cx": 640.0, "cy": 480.0 },
  "distortion": { "k1": -0.15, "k2": 0.05, "p1": 0.001, "p2": -0.001, "k3": 0.0 }
}

or a detector-output wrapper with a top-level camera field as documented in Detection Output Format.

Undistortion is performed iteratively (fixed-point iteration in normalized coordinates, up to 15 iterations by default with 1e-12 convergence threshold).

Coordinate Frames

FieldFrame
centerImage (distorted pixel coordinates, always)
center_mappedWorking (undistorted pixel coordinates)
homographyWorking -> Board (maps board mm to undistorted pixels)
center_frameDetectionFrame::Image
homography_frameDetectionFrame::Working

Marker centers are always reported in image space so they can be overlaid on the original photo. The center_mapped field provides the corresponding undistorted position in the working frame, which is the coordinate system the homography operates in.

Full Example

#![allow(unused)]
fn main() {
use ringgrid::{BoardLayout, CameraIntrinsics, CameraModel, Detector, RadialTangentialDistortion};
use std::path::Path;

let board = BoardLayout::from_json_file(Path::new("target.json"))?;
let image = image::open("photo.png")?.to_luma8();
let (w, h) = image.dimensions();

let camera = CameraModel {
    intrinsics: CameraIntrinsics {
        fx: 900.0, fy: 900.0,
        cx: w as f64 * 0.5,
        cy: h as f64 * 0.5,
    },
    distortion: RadialTangentialDistortion {
        k1: -0.15, k2: 0.05,
        p1: 0.001, p2: -0.001,
        k3: 0.0,
    },
};

let detector = Detector::new(board);
let result = detector.detect_with_mapper(&image, &camera);

for marker in &result.detected_markers {
    // center is always in image (distorted) space
    println!("Image: ({:.1}, {:.1})", marker.center[0], marker.center[1]);
    // center_mapped is in working (undistorted) space
    if let Some(mapped) = marker.center_mapped {
        println!("Working: ({:.1}, {:.1})", mapped[0], mapped[1]);
    }
}
}

Important Notes

  • Self-undistort is skipped. When you call detect_with_mapper, the self-undistort estimation is not run, regardless of the config.self_undistort.enable setting. The provided mapper takes precedence.
  • result.self_undistort is None. Since self-undistort does not run, this field will always be None when using detect_with_mapper.
  • Homography maps board to working frame. The 3x3 homography in result.homography transforms board coordinates (mm) into undistorted pixel coordinates, not raw image pixels. To project into the original image, apply the camera distortion model to the working-frame points.

Source Files

  • crates/ringgrid/src/api.rsDetector::detect_with_mapper method.
  • crates/ringgrid/src/pixelmap/cameramodel.rsCameraModel, CameraIntrinsics, PixelMapper implementation.
  • crates/ringgrid/examples/detect_with_camera.rs – complete runnable example.

Self-Undistort Mode

Self-undistort estimates lens distortion directly from the detected markers, without requiring camera calibration data. It uses a 1-parameter division model and decides automatically whether the estimated correction is beneficial.

When to Use

  • You do not have calibrated camera intrinsics or distortion coefficients.
  • You suspect the lens has significant barrel or pincushion distortion.
  • You want the detector to self-correct without external calibration.

If you have a full camera model, use External PixelMapper instead – it is more accurate for cameras with tangential distortion or higher-order radial terms.

Division Model

The self-undistort stage fits a single-parameter division model:

x_u = cx + (x_d - cx) / (1 + lambda * r^2)
y_u = cy + (y_d - cy) / (1 + lambda * r^2)

where r^2 = (x_d - cx)^2 + (y_d - cy)^2 and (cx, cy) is the image center.

  • Negative lambda corresponds to barrel distortion (most common in wide-angle lenses).
  • Positive lambda corresponds to pincushion distortion.
  • lambda = 0 is the identity (no correction).

Pipeline Flow

  1. Baseline detection – a standard single-pass detect runs in image coordinates, producing markers with inner and outer edge points.
  2. Lambda estimation – a golden-section search minimizes a robust objective over the configured lambda range. The objective measures how well inner and outer ring edges fit ellipses after undistortion (lower residual = better conic consistency). When enough decoded markers with board IDs are available, a homography-based objective is used instead for higher accuracy.
  3. Accept/reject – the estimated lambda is accepted only if:
    • The objective improvement exceeds improvement_threshold (relative) and min_abs_improvement (absolute).
    • The estimated |lambda| exceeds min_lambda_abs.
    • The solution is not at the edge of the search range (when reject_range_edge is enabled).
    • Homography validation passes (when enough decoded markers are available).
  4. Second pass – if accepted, detection re-runs with the estimated division model as a pixel mapper, using pass-1 markers as seeds.

Enabling Self-Undistort

Self-undistort is disabled by default. Enable it through the config before creating the detector:

#![allow(unused)]
fn main() {
use ringgrid::{BoardLayout, DetectConfig, Detector, MarkerScalePrior};
use std::path::Path;

let board = BoardLayout::from_json_file(Path::new("target.json"))?;
let image = image::open("photo.png")?.to_luma8();

let mut cfg = DetectConfig::from_target(board);
cfg.self_undistort.enable = true;
cfg.self_undistort.min_markers = 12;

let detector = Detector::with_config(cfg);
let result = detector.detect(&image);

if let Some(su) = &result.self_undistort {
    println!("Lambda: {:.3e}, applied: {}", su.model.lambda, su.applied);
    println!("Objective: {:.4} -> {:.4}", su.objective_at_zero, su.objective_at_lambda);
}
}

SelfUndistortResult

When self_undistort.enable is true, result.self_undistort is always Some, even if the correction was not applied. The struct contains:

FieldTypeDescription
modelDivisionModelEstimated division model (lambda, cx, cy)
appliedboolWhether the model was actually used for re-detection
objective_at_zerof64Baseline objective value (lambda = 0)
objective_at_lambdaf64Objective value at the estimated lambda
n_markers_usedusizeNumber of markers used in the estimation

If applied is false, the estimated lambda did not meet the acceptance criteria and detection results are from the baseline (image-coordinate) pass only.

Configuration Parameters

The SelfUndistortConfig struct controls the estimation behavior:

ParameterDefaultDescription
enablefalseMaster switch for self-undistort
lambda_range[-8e-7, 8e-7]Search range for the lambda parameter
max_evals40Maximum objective evaluations for golden-section search
min_markers6Minimum markers with both inner+outer edge points
improvement_threshold0.01Minimum relative objective improvement
min_abs_improvement1e-4Minimum absolute objective improvement
trim_fraction0.1Trim fraction for robust aggregation (drop 10% tails)
min_lambda_abs5e-9Minimum |lambda| to consider non-trivial
reject_range_edgetrueReject solutions near lambda-range boundaries
range_edge_margin_frac0.02Relative margin treated as boundary zone
validation_min_markers24Minimum decoded-ID matches for H-validation
validation_abs_improvement_px0.05Minimum absolute H-error improvement (px)
validation_rel_improvement0.03Minimum relative H-error improvement

Coordinate Frames

When the model is applied (applied == true):

FieldFrame
centerImage (distorted pixel coordinates)
center_mappedWorking (undistorted via division model)
homographyWorking -> Board
homography_frameDetectionFrame::Working

When the model is not applied (applied == false), the result is identical to simple detection – all coordinates are in image space and center_mapped is None.

Important Notes

  • Self-undistort and external mapper are mutually exclusive. Calling detect_with_mapper never runs self-undistort, regardless of the config setting.
  • The division model has only one free parameter (lambda). It cannot model tangential distortion or higher-order radial terms. For complex distortion, use a calibrated CameraModel instead.
  • The estimation requires enough detected markers with usable edge points. If the baseline pass finds fewer than min_markers qualifying markers, result.self_undistort will be None.

Source Files

  • crates/ringgrid/src/api.rsDetector::detect branches on self_undistort.enable.
  • crates/ringgrid/src/pixelmap/self_undistort.rs – estimation logic, SelfUndistortConfig, SelfUndistortResult.
  • crates/ringgrid/src/pixelmap/distortion.rsDivisionModel type and PixelMapper implementation.
  • crates/ringgrid/examples/detect_with_self_undistort.rs – complete runnable example.

Custom PixelMapper

The PixelMapper trait abstracts distortion/undistortion so you can plug in any lens model. The built-in CameraModel and DivisionModel both implement it, but you can provide your own implementation for exotic distortion models, look-up-table corrections, or domain-specific coordinate transforms.

The PixelMapper Trait

#![allow(unused)]
fn main() {
pub trait PixelMapper {
    /// Map from image (distorted) pixel coordinates to working coordinates.
    fn image_to_working_pixel(&self, image_xy: [f64; 2]) -> Option<[f64; 2]>;

    /// Map from working coordinates back to image (distorted) pixel coordinates.
    fn working_to_image_pixel(&self, working_xy: [f64; 2]) -> Option<[f64; 2]>;
}
}
  • image_to_working_pixel transforms a distorted image-space point into the undistorted working frame. Return None if the point cannot be mapped (e.g. it falls outside the valid domain of the distortion model).
  • working_to_image_pixel transforms an undistorted working-frame point back into distorted image space. Return None if the inverse mapping fails.
  • The two methods must be approximate inverses of each other. Perfect numerical round-tripping is not required, but the error should be small relative to a pixel.

Both methods are called during the two-pass pipeline: working_to_image_pixel maps working-frame sample coordinates into the image for pixel lookups, and image_to_working_pixel maps detected edge points into the working frame for ellipse fitting.

Implementation Example

A simple radial-only distortion model with a single coefficient:

#![allow(unused)]
fn main() {
use ringgrid::PixelMapper;

struct SimpleRadialMapper {
    cx: f64,
    cy: f64,
    k1: f64,
}

impl PixelMapper for SimpleRadialMapper {
    fn image_to_working_pixel(&self, p: [f64; 2]) -> Option<[f64; 2]> {
        let dx = p[0] - self.cx;
        let dy = p[1] - self.cy;
        let r2 = dx * dx + dy * dy;
        let scale = 1.0 + self.k1 * r2;
        if scale.abs() < 1e-12 {
            return None;
        }
        Some([self.cx + dx / scale, self.cy + dy / scale])
    }

    fn working_to_image_pixel(&self, p: [f64; 2]) -> Option<[f64; 2]> {
        let dx = p[0] - self.cx;
        let dy = p[1] - self.cy;
        let r2 = dx * dx + dy * dy;
        let scale = 1.0 + self.k1 * r2;
        Some([self.cx + dx * scale, self.cy + dy * scale])
    }
}
}

Using a Custom Mapper

Pass your mapper to detect_with_mapper just like you would a CameraModel:

#![allow(unused)]
fn main() {
use ringgrid::{BoardLayout, Detector};
use std::path::Path;

let board = BoardLayout::from_json_file(Path::new("target.json"))?;
let image = image::open("photo.png")?.to_luma8();
let (w, h) = image.dimensions();

let mapper = SimpleRadialMapper {
    cx: w as f64 * 0.5,
    cy: h as f64 * 0.5,
    k1: -1e-7,
};

let detector = Detector::new(board);
let result = detector.detect_with_mapper(&image, &mapper);

for marker in &result.detected_markers {
    println!("Image: ({:.1}, {:.1})", marker.center[0], marker.center[1]);
    if let Some(mapped) = marker.center_mapped {
        println!("Working: ({:.1}, {:.1})", mapped[0], mapped[1]);
    }
}
}

The coordinate frames are the same as for the external mapper mode: center is image-space, center_mapped is working-frame, and the homography maps board coordinates to the working frame.

Built-In Implementations

Two types in ringgrid already implement PixelMapper:

TypeDescription
CameraModelFull Brown-Conrady model (k1, k2, k3 radial + p1, p2 tangential) with pinhole intrinsics. Undistortion is iterative.
DivisionModel1-parameter division model (lambda). Used internally by self-undistort. Undistortion is closed-form; distortion (inverse) is iterative.

Both are in the pixelmap module and can serve as reference implementations when writing your own mapper.

Design Guidelines

When implementing PixelMapper:

  • Return None for invalid inputs. If a point is outside the image or the distortion formula diverges, return None rather than a garbage coordinate. The detector will skip that sample gracefully.
  • Keep the methods fast. They are called per edge-sample point, potentially thousands of times per image. Avoid allocations or heavy computation in the hot path.
  • Test round-trip accuracy. Verify that working_to_image_pixel(image_to_working_pixel(p)) returns a value close to p for points across the image. Sub-pixel accuracy (< 0.01 px error) is recommended.

Important Notes

  • Self-undistort is not run when detect_with_mapper is called. The provided mapper fully replaces any automatic distortion estimation.
  • The mapper is used only during the second pass. The first pass always runs in raw image coordinates to generate seed proposals.

Source Files

  • crates/ringgrid/src/pixelmap/mod.rsPixelMapper trait definition.
  • crates/ringgrid/src/pixelmap/cameramodel.rsCameraModel implements PixelMapper.
  • crates/ringgrid/src/pixelmap/distortion.rsDivisionModel implements PixelMapper.
  • crates/ringgrid/src/api.rsDetector::detect_with_mapper.

Coordinate Frames

ringgrid operates in two coordinate frames. Understanding which frame applies to each output field is essential for correct downstream use.

Image Frame

The image frame uses pixel coordinates directly from the input GrayImage:

  • Origin at the top-left corner of the image
  • X increases rightward, Y increases downward
  • Units are pixels
  • This is the native frame of the detector when no pixel mapper is active

DetectedMarker.center is always in the image frame, regardless of detection mode. This ensures that center coordinates can always be overlaid directly on the original image.

Working Frame

The working frame is the coordinate system used internally during detection when a pixel mapper is active. It is the undistorted (or otherwise transformed) coordinate space defined by the mapper’s image_to_working_pixel method.

When a mapper is used (either an external PixelMapper or the self-undistort division model):

  • Edge sampling and ellipse fitting operate in the working frame
  • The homography maps from board coordinates (mm) to the working frame
  • DetectedMarker.center_mapped contains the marker center in the working frame
  • DetectedMarker.center remains in the image frame (mapped back via working_to_image_pixel)

Frame Metadata in DetectionResult

Every DetectionResult includes explicit frame metadata so downstream code never has to guess:

#![allow(unused)]
fn main() {
pub struct DetectionResult {
    /// Always `DetectionFrame::Image` — centers are always image-space.
    pub center_frame: DetectionFrame,
    /// `Image` when no mapper is active; `Working` when a mapper was used.
    pub homography_frame: DetectionFrame,
    // ...
}
}
Detection modecenter_framehomography_framecenter_mapped present?
Simple (detect())ImageImageNo
External mapper (detect_with_mapper())ImageWorkingYes
Self-undistort (applied)ImageWorkingYes
Self-undistort (not applied)ImageImageNo

Homography Frame

The homography field in DetectionResult maps from board coordinates (millimeters, as defined in BoardLayout) to whichever frame homography_frame indicates:

  • When homography_frame == Image: the homography maps board mm to distorted image pixels.
  • When homography_frame == Working: the homography maps board mm to undistorted working-frame pixels.

To project a board point to image pixels when a mapper was used, compose the homography with working_to_image_pixel:

#![allow(unused)]
fn main() {
// H maps board_mm -> working_frame
let working_xy = project_homography(h, board_xy_mm);
// Map back to image pixels
let image_xy = mapper.working_to_image_pixel(working_xy);
}

Practical Guidelines

  1. For visualization (overlaying detections on the original image): use center directly — it is always in image coordinates.

  2. For calibration (computing camera parameters): use center_mapped when available, since it is in the undistorted frame where the homography is valid.

  3. For reprojection error: match the frame of your ground truth to homography_frame metadata, or map both to image space for consistent comparison.

  4. When implementing a custom PixelMapper: ensure that image_to_working_pixel and working_to_image_pixel are consistent inverses. Return None for out-of-bounds coordinates.

Target Generation

This chapter documents the complete workflow for generating:

  • board configuration JSON (board_spec.json)
  • printable vector target (.svg)
  • printable raster target (.png)

for physical calibration boards.

Overview

There are three equivalent canonical target-generation paths:

  1. Rust CLI: ringgrid gen-target
    • emits board_spec.json, .svg, and .png in one run
    • best when you want a pure-Rust command-line workflow
  2. Python script: tools/gen_target.py
    • emits the same board_spec.json, .svg, and .png set
    • best when you are already using the repo’s Python tooling
  3. Rust API: BoardLayout + write_json_file / write_target_svg / write_target_png
    • emits the same canonical artifacts from application code
    • best when target generation is embedded in a Rust program

All three paths use the same Rust target-generation engine. For the same geometry and print options, they generate the same canonical board JSON, SVG, and PNG.

Additional specialized repo helpers remain available:

  • tools/gen_synth.py for synth images + ground truth + optional print files
  • tools/gen_board_spec.py for JSON-only board-spec generation

Prerequisites

From repository root:

For the Rust CLI path:

cargo build -p ringgrid-cli

For the dedicated Python script path:

python3 -m venv .venv
./.venv/bin/python -m pip install -U pip maturin
./.venv/bin/python -m maturin develop -m crates/ringgrid-py/Cargo.toml --release

For gen_synth.py synthetic generation:

./.venv/bin/python -m pip install numpy

For the Rust API path, add ringgrid to your Rust project dependencies.

Equivalent One-Command Workflows

Rust CLI:

cargo run -p ringgrid-cli -- gen-target \
  --out_dir tools/out/target_print_200mm \
  --pitch_mm 8 \
  --rows 15 \
  --long_row_cols 14 \
  --marker_outer_radius_mm 4.8 \
  --marker_inner_radius_mm 3.2 \
  --marker_ring_width_mm 1.152 \
  --name ringgrid_200mm_hex \
  --dpi 600 \
  --margin_mm 5

Python script:

./.venv/bin/python tools/gen_target.py \
  --out_dir tools/out/target_print_200mm \
  --pitch_mm 8 \
  --rows 15 \
  --long_row_cols 14 \
  --marker_outer_radius_mm 4.8 \
  --marker_inner_radius_mm 3.2 \
  --marker_ring_width_mm 1.152 \
  --name ringgrid_200mm_hex \
  --dpi 600 \
  --margin_mm 5

Outputs:

  • tools/out/target_print_200mm/board_spec.json
  • tools/out/target_print_200mm/target_print.svg
  • tools/out/target_print_200mm/target_print.png

Rust API:

#![allow(unused)]
fn main() {
use ringgrid::{BoardLayout, PngTargetOptions, SvgTargetOptions};
use std::path::Path;

let board = BoardLayout::with_name("ringgrid_200mm_hex", 8.0, 15, 14, 4.8, 3.2, 1.152).unwrap();

board
    .write_json_file(Path::new("tools/out/target_print_200mm/board_spec.json"))
    .unwrap();
board
    .write_target_svg(
        Path::new("tools/out/target_print_200mm/target_print.svg"),
        &SvgTargetOptions {
            margin_mm: 5.0,
            include_scale_bar: true,
        },
    )
    .unwrap();
board
    .write_target_png(
        Path::new("tools/out/target_print_200mm/target_print.png"),
        &PngTargetOptions {
            dpi: 600.0,
            margin_mm: 5.0,
            include_scale_bar: true,
        },
    )
    .unwrap();
}

Combined Synth + Print Workflow

Use tools/gen_synth.py when you want print outputs and synthetic images from one command:

./.venv/bin/python tools/gen_synth.py \
  --out_dir tools/out/target_print_with_synth \
  --n_images 3 \
  --board_mm 200 \
  --pitch_mm 8 \
  --print \
  --print_dpi 600 \
  --print_margin_mm 5 \
  --print_basename target_print

Detection From The Generated Board

All three canonical generation paths above emit the same board_spec.json schema. Use that generated JSON directly in detection:

cargo run -- detect \
  --target tools/out/target_print_custom/board_spec.json \
  --image path/to/photo.png \
  --out tools/out/target_print_custom/detect.json

Configuration Reference

Shared ringgrid gen-target / tools/gen_target.py options

The Rust CLI intentionally mirrors the dedicated Python script’s geometry and print-output arguments. The Rust CLI accepts the underscore names shown below; hyphenated aliases such as --pitch-mm are also accepted.

ArgumentDefaultDescription
--pitch_mmrequiredMarker spacing in mm.
--rowsrequiredNumber of hex lattice rows.
--long_row_colsrequiredNumber of markers in long rows.
--marker_outer_radius_mmrequiredOuter ring radius in mm.
--marker_inner_radius_mmrequiredInner ring radius in mm.
--marker_ring_width_mmrequiredFull printed ring width in mm for both inner and outer dark rings.
--nameautoOptional board name. Omitted uses deterministic geometry-derived naming.
--out_dirtools/out/targetOutput directory for board_spec.json, SVG, and PNG.
--basenametarget_printBase filename for SVG/PNG outputs.
--dpi300.0Raster DPI for PNG export (also embedded in PNG metadata).
--margin_mm0.0Extra white margin around the board in print outputs.
--no-scale-barfalseOmit the default scale bar from SVG/PNG outputs.

Equivalent Rust API mapping

Rust API surfaceEquivalent shared option
BoardLayout::new(...) / BoardLayout::with_name(...)geometry (pitch_mm, rows, long_row_cols, radii, ring width, optional name)
write_json_file(path)board_spec.json output
SvgTargetOptions { margin_mm, include_scale_bar }--margin_mm, --no-scale-bar
PngTargetOptions { dpi, margin_mm, include_scale_bar }--dpi, --margin_mm, --no-scale-bar

tools/gen_synth.py geometry options

ArgumentDefaultDescription
--out_dirtools/out/synth_002Output directory for all artifacts.
--n_images3Number of synthetic rendered images (0 is valid for print-only runs).
--board_mm200.0Board side size used for lattice generation and print canvas.
--pitch_mm8.0Marker center-to-center spacing in mm.
--n_markersNoneOptional marker count cap for generated lattice.
--codebooktools/codebook.jsonCodebook JSON used for marker coding.

tools/gen_synth.py print-output options

ArgumentDefaultDescription
--printfalseEmit both printable SVG and PNG.
--print_svgfalseEmit printable SVG file.
--print_pngfalseEmit printable PNG file.
--print_dxffalseEmit DXF target output.
--print_dpi600.0Raster DPI for PNG export (also embedded in PNG metadata).
--print_margin_mm0.0Extra white margin around the board in print outputs.
--print_basenametarget_printBase filename for print outputs (without extension).

Notes:

  • gen_target.py always writes board_spec.json, <basename>.svg, and <basename>.png together.
  • SVG is resolution-independent and preferred for professional printing.
  • PNG size is derived from the board geometry, requested margin, and DPI.
  • gen_synth.py always writes a matching board_spec.json to --out_dir.

tools/gen_board_spec.py options

ArgumentDefaultDescription
--pitch_mm8.0Marker spacing in mm.
--rows15Number of hex lattice rows.
--long_row_cols14Number of markers in long rows.
--board_mm200.0Used for default board name when --name is omitted.
--nameautoBoard name. Default: ringgrid_{board_mm}mm_hex.
--marker_outer_radius_mmpitch_mm * 0.6Outer ring radius.
--marker_inner_radius_mmpitch_mm * 0.4Inner ring radius.
--marker_ring_width_mmmarker_outer_radius_mm * 0.24Full dark-ring width.
--json_outtools/board/board_spec.jsonOutput JSON path.

Board JSON Schema (ringgrid.target.v4)

Minimal example:

{
  "schema": "ringgrid.target.v4",
  "name": "ringgrid_200mm_hex",
  "pitch_mm": 8.0,
  "rows": 15,
  "long_row_cols": 14,
  "marker_outer_radius_mm": 4.8,
  "marker_inner_radius_mm": 3.2,
  "marker_ring_width_mm": 1.152
}

Field reference:

FieldTypeMeaning
schemastringMust be ringgrid.target.v4.
namestringHuman-readable board name.
pitch_mmfloatMarker center spacing in mm.
rowsintNumber of lattice rows.
long_row_colsintMarker count for long rows.
marker_outer_radius_mmfloatOuter ring radius in mm.
marker_inner_radius_mmfloatInner ring radius in mm (0 < inner < outer).
marker_ring_width_mmfloatFull printed width of the dark inner and outer rings in mm.

Validation Rules

The Rust loader validates:

  • schema == "ringgrid.target.v4"
  • finite positive pitch_mm
  • rows >= 1
  • long_row_cols >= 1 (>= 2 when rows > 1)
  • finite positive radii and ring width with marker_inner_radius_mm < marker_outer_radius_mm
  • a positive code-band gap between the inner and outer ring strokes
  • printed marker diameter (including ring stroke width) smaller than minimum center spacing

Quick Validation in Rust

#![allow(unused)]
fn main() {
use ringgrid::BoardLayout;
use std::path::Path;

let board = BoardLayout::from_json_file(Path::new("board_spec.json"))?;
println!("{} markers on '{}'", board.n_markers(), board.name);
Ok::<(), Box<dyn std::error::Error>>(())
}

Practical Print Guidance

  • Prefer SVG for final print jobs.
  • Keep printer scaling at 100% (no fit-to-page).
  • Use print_margin_mm if your printer clips near page edges.
  • Archive the exact board_spec.json that was printed and use that same JSON during detection.

CLI Guide

The ringgrid command-line tool provides access to the ring marker detection pipeline from the terminal. It is built from the ringgrid-cli crate.

Installation

Install from the workspace:

cargo install ringgrid-cli

Or build from source:

cargo build --release -p ringgrid-cli

The binary is named ringgrid.

Commands

ringgrid gen-target – Generate canonical target JSON + printable SVG/PNG

This command generates:

  • board_spec.json
  • <basename>.svg
  • <basename>.png

from direct board geometry arguments.

It is the Rust CLI equivalent of tools/gen_target.py and uses the same practical geometry/print options. For the same geometry, DPI, margin, and scale-bar setting, the generated artifacts are equivalent to the Python script and the Rust API writers.

Geometry and output flags:

FlagDefaultDescription
--pitch_mm <mm>requiredMarker center spacing in mm.
--rows <n>requiredNumber of hex lattice rows.
--long_row_cols <n>requiredNumber of markers in long rows.
--marker_outer_radius_mm <mm>requiredOuter ring radius in mm.
--marker_inner_radius_mm <mm>requiredInner ring radius in mm.
--name <string>autoOptional board name. Omitted uses deterministic geometry-derived naming.
--out_dir <path>tools/out/targetOutput directory for board_spec.json, SVG, and PNG.
--basename <string>target_printBase filename for SVG and PNG outputs.
--dpi <f>300.0PNG raster DPI (also embedded in PNG metadata).
--margin_mm <mm>0.0Extra white border around the board in SVG/PNG outputs.
--no-scale-barfalseOmit the default scale bar from SVG/PNG outputs.

The Rust CLI accepts the underscore flag names above for parity with tools/gen_target.py. Hyphenated aliases such as --pitch-mm, --long-row-cols, and --margin-mm are also accepted.

Example:

ringgrid gen-target \
    --out_dir tools/out/target_faststart \
    --pitch_mm 8 \
    --rows 15 \
    --long_row_cols 14 \
    --marker_outer_radius_mm 4.8 \
    --marker_inner_radius_mm 3.2 \
    --name ringgrid_200mm_hex \
    --dpi 600 \
    --margin_mm 5

Generated files:

  • tools/out/target_faststart/board_spec.json
  • tools/out/target_faststart/target_print.svg
  • tools/out/target_faststart/target_print.png

ringgrid detect – Detect markers in an image

This is the primary command. It loads an image, runs the detection pipeline, and writes results as JSON.

Required arguments:

FlagDescription
--image <path>Path to the input image file.
--out <path>Path to write detection results (JSON).

Output and diagnostics:

FlagDefaultDescription
--include-proposalsfalseAdd pass-1 proposal diagnostics to the output JSON as top-level proposal_frame, proposal_count, and proposals fields.

Board target:

FlagDefaultDescription
--target <path>built-in boardPath to a board layout JSON file. When omitted, uses the built-in default 203-marker hex board.

Marker scale:

FlagDefaultDescription
--marker-diameter <px>Fixed marker outer diameter in pixels (legacy mode). Overrides min/max range.
--marker-diameter-min <px>unsetMinimum marker outer diameter for scale search.
--marker-diameter-max <px>unsetMaximum marker outer diameter for scale search.

When --marker-diameter is set, it locks the detector to a single scale instead of searching a range. This is a legacy compatibility path; prefer the min/max range for new workflows.

When both min/max are omitted, the detector uses the library default prior (14-66 px). If only one bound is provided, the missing side uses the legacy compatibility fallback (20 for min, 56 for max).

RANSAC homography:

FlagDefaultDescription
--ransac-thresh-px <px>5.0Inlier threshold in pixels.
--ransac-iters <n>2000Maximum iterations.
--no-global-filterfalseDisable the global homography filter entirely.

Homography-guided completion:

FlagDefaultDescription
--no-completefalseDisable completion (fitting at H-projected missing IDs).
--complete-reproj-gate <px>3.0Reprojection error gate for accepting completed markers.
--complete-min-conf <f>0.45Minimum fit confidence in [0, 1] for completed markers.
--complete-roi-radius <px>autoROI radius for edge sampling during completion. Defaults to a value derived from the nominal marker diameter.

Center refinement (projective center):

FlagDefaultDescription
--circle-refine-method <m>projective-centerCenter correction method: none or projective-center.
--proj-center-max-shift-px <px>autoMaximum allowed correction shift. Defaults to a value derived from nominal marker diameter.
--proj-center-max-residual <f>0.25Reject corrections with residual above this.
--proj-center-min-eig-sep <f>1e-6Reject corrections with eigenvalue separation below this.

Self-undistort:

FlagDefaultDescription
--self-undistortfalseEstimate a 1-parameter division-model distortion from detected markers, then re-run detection.
--self-undistort-lambda-min <f>-8e-7Lambda search lower bound.
--self-undistort-lambda-max <f>8e-7Lambda search upper bound.
--self-undistort-min-markers <n>6Minimum number of markers with inner+outer edge points required for estimation.

Camera intrinsics:

You can provide an external camera model either inline via --cam-* or from a JSON file via --calibration <file.json>.

FlagDefaultDescription
--calibration <file.json>Load a Brown-Conrady CameraModel from JSON. Accepts either direct { "intrinsics": ..., "distortion": ... } or wrapped { "camera": { ... } } shapes.
--cam-fx <f>Focal length x (pixels).
--cam-fy <f>Focal length y (pixels).
--cam-cx <f>Principal point x (pixels).
--cam-cy <f>Principal point y (pixels).
--cam-k1 <f>0.0Radial distortion k1.
--cam-k2 <f>0.0Radial distortion k2.
--cam-p1 <f>0.0Tangential distortion p1.
--cam-p2 <f>0.0Tangential distortion p2.
--cam-k3 <f>0.0Radial distortion k3.

For inline parameters, all four intrinsic parameters (fx, fy, cx, cy) must be provided together. The distortion coefficients are optional and default to zero.

--calibration and inline --cam-* parameters are mutually exclusive. Any external camera model and --self-undistort are also mutually exclusive. Providing both will produce an error.

When a camera model is provided, the detector runs a two-pass pipeline: pass 1 without distortion mapping to find initial markers, then pass 2 with the camera model applied.

Usage Examples

Basic detection with default settings:

ringgrid detect --image photo.png --out result.json

Specifying the expected marker size range:

ringgrid detect \
    --image photo.png \
    --out result.json \
    --marker-diameter-min 20 \
    --marker-diameter-max 56

Using a custom board specification:

ringgrid detect \
    --target board_spec.json \
    --image photo.png \
    --out result.json

With camera intrinsics and distortion:

ringgrid detect \
    --image photo.png \
    --out result.json \
    --cam-fx 900 --cam-fy 900 --cam-cx 640 --cam-cy 480 \
    --cam-k1 -0.15 --cam-k2 0.05

With a calibration JSON file:

ringgrid detect \
    --image photo.png \
    --out result.json \
    --calibration camera_model.json

With self-undistort estimation:

ringgrid detect \
    --image photo.png \
    --out result.json \
    --self-undistort

Disabling completion and global filter (raw fit-decode output only):

ringgrid detect \
    --image photo.png \
    --out result.json \
    --no-global-filter \
    --no-complete

Adaptive Scale Status

Adaptive multi-scale entry points are currently exposed via Rust and Python APIs:

  • Detector::detect_adaptive
  • Detector::detect_adaptive_with_hint
  • Detector::detect_multiscale

Python bindings expose the same concepts on ringgrid.Detector:

  • detect_adaptive(image, nominal_diameter_px=None) (canonical)
  • detect_adaptive_with_hint(image, nominal_diameter_px=...) (compatibility alias)
  • detect_multiscale(image, tiers)

CLI ringgrid detect uses the regular config-driven detect flow.

ringgrid codebook-info – Print codebook statistics

Prints information about the embedded 16-bit codebook profiles. The output shows the shipped default base profile plus the opt-in extended profile.

ringgrid detect continues to use base unless a config file sets decode.codebook_profile to extended.

ringgrid codebook-info

Example output:

ringgrid embedded codebook profiles
  default profile:      base
  base:
    bits per codeword:    16
    number of codewords:  893
    min cyclic Hamming:   2
    generator seed:       1
    first codeword:       0x035D
    last codeword:        0x0E63
  extended:
    bits per codeword:    16
    number of codewords:  2180
    min cyclic Hamming:   1
    generator seed:       1
    first codeword:       0x035D
    last codeword:        0x2CD3

ringgrid board-info – Print default board specification

Prints summary information about the built-in default board layout: marker count, pitch, rows, columns, and spatial extent.

ringgrid board-info

ringgrid decode-test – Decode a 16-bit word

Tests a hex word against the selected embedded codebook profile and prints the best match with confidence metrics. Useful for debugging code sampling issues.

ringgrid decode-test --word 0x035D
# or:
ringgrid decode-test --word 0x0001 --profile extended

Example output:

Input word:   0x035D (binary: 0000001101011101)
Profile:      base
Best match:
  id:         0
  codeword:   0x035D
  rotation:   0 sectors
  distance:   0 bits
  margin:     2 bits
  confidence: 1.000

Logging

ringgrid uses the tracing crate for structured logging. Control verbosity with the RUST_LOG environment variable:

# Default level (info) -- shows summary statistics
ringgrid detect --image photo.png --out result.json

# Debug level -- shows per-stage diagnostics
RUST_LOG=debug ringgrid detect --image photo.png --out result.json

# Trace level -- shows detailed per-marker information
RUST_LOG=trace ringgrid detect --image photo.png --out result.json

At the default info level, the detector logs:

  • Image dimensions
  • Board layout loaded (name, marker count)
  • Number of detected markers (total and with decoded IDs)
  • Homography statistics (inlier count, mean and p95 reprojection error)
  • Self-undistort results when enabled (lambda, objective improvement, marker count)
  • Output file path

Output Format

ringgrid detect writes the serialized DetectionResult fields at the top level:

  • detected_markers
  • center_frame
  • homography_frame
  • image_size
  • optional homography, ransac, and self_undistort

The CLI may add extra top-level fields:

  • camera when a camera model was supplied
  • proposal_frame, proposal_count, and proposals when --include-proposals is enabled

The full file shape, nested marker fields, and frame semantics are documented in Output Format.

Source Files

  • CLI implementation: crates/ringgrid-cli/src/main.rs