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:
-
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.
-
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.
-
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
DetectedMarkerstructs, 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:
- Simple detection — single-pass detection in image coordinates. No distortion correction.
- Adaptive scale detection — multi-tier detection that auto-selects scale bands (or uses explicit tiers) for scenes with large marker size variation.
- 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.
- 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_namewithwrite_json_file,write_target_svg, andwrite_target_pngwhen 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.jsontools/out/target_faststart/target_print.svgtools/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::detector CLIdetect). - For scenes with very small and very large markers in the same image, use adaptive multi-scale APIs:
Detector::detect_adaptiveDetector::detect_adaptive_with_hintDetector::detect_multiscale
Next Reads
- Full configuration and flag reference: Target Generation
- CLI usage and detection flags: CLI Guide
- Detection JSON schema: Detection Output Format
- Adaptive scale details: Adaptive Scale Detection
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:
| Parameter | Default value | Description |
|---|---|---|
| Outer radius | 4.8 mm | Radius of the outer ring centerline |
| Inner radius | 3.2 mm | Radius of the inner ring centerline |
| Ring width | 0.576 mm (0.12 * outer radius) | Width of each dark ring band |
| Pitch | 8.0 mm | Center-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:
| Field | Default | Notes |
|---|---|---|
r_inner_expected | 0.488 | 0.328 / 0.672 |
inner_search_halfwidth | 0.08 | Search window: [0.408, 0.568] |
inner_grad_polarity | LightToDark | Light center to dark inner ring |
radial_samples | 64 | Resolution along radial profiles |
theta_samples | 96 | Angular samples around the ring |
aggregator | Median | Robust to code-band sector outliers |
min_theta_coverage | 0.6 | At least 60% of angles must be valid |
min_theta_consistency | 0.35 | At 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:
-
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. -
Sufficient code band width: The gap between inner and outer rings must be wide enough to sample 16 angular sectors with adequate spatial resolution.
-
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:
| Profile | Size | Minimum cyclic Hamming distance | Intended use |
|---|---|---|---|
base | 893 codewords | 2 | Default shipped profile with stable IDs 0..892 |
extended | 2180 codewords | 1 | Explicit opt-in profile when ID capacity matters more than the baseline ambiguity guarantee |
Key codebook properties:
| Property | Value |
|---|---|
| Codeword length | 16 bits |
| Baseline size | 893 codewords |
| Extended size | 2180 codewords |
| Baseline minimum cyclic Hamming distance | 2 |
| Extended minimum cyclic Hamming distance | 1 |
| Generator seed | 1 |
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:
- Initialize the threshold at the midpoint of the intensity range.
- Split sectors into two groups (above and below threshold).
- Recompute the threshold as the midpoint of the two group means.
- 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:
- Match
observed_wordagainst the codebook (normal polarity). - Match
!observed_word(bitwise complement) against the codebook (inverted polarity). - 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:
| Parameter | Default | Description |
|---|---|---|
rows | 15 | Number of marker rows |
long_row_cols | 14 | Number of markers in a long row |
pitch_mm | 8.0 mm | Center-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,
rranges from -7 to +7. - q is the column index within each row, also centered around zero. The
range of
qdepends 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:
| Method | Returns | Description |
|---|---|---|
default() | BoardLayout | Default 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() | usize | Total 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:
| Field | Type | Description |
|---|---|---|
schema | string | Must be "ringgrid.target.v4" |
name | string | Human-readable target name |
pitch_mm | float | Center-to-center marker spacing |
rows | int | Number of rows in the lattice |
long_row_cols | int | Markers per long row |
marker_outer_radius_mm | float | Outer ring radius |
marker_inner_radius_mm | float | Inner ring radius |
marker_ring_width_mm | float | Full 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:
- Positive dimensions:
pitch_mm,marker_outer_radius_mm,marker_inner_radius_mm, andmarker_ring_width_mmmust all be finite and positive. - Inner < outer: The inner radius must be strictly less than the outer radius.
- 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.
- Non-overlapping printed markers: The full printed marker diameter,
including ring stroke width, must be smaller than the nearest-neighbor
distance (
pitch * sqrt(3)). - Sufficient columns: When
rows > 1,long_row_colsmust be at least 2 (to allow short rows withlong_row_cols - 1 >= 1markers).
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:
- The fit uses many points (overdetermined system), averaging out per-point noise
- Points are distributed around the full ellipse, constraining all five parameters
- 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:
| Property | ringgrid | ArUco 4x4 | ArUco 6x6 | Checkerboard |
|---|---|---|---|---|
| Unique IDs | 893 | 50 | 250 | 0 |
| Rotation invariant | Yes | No (4 orientations) | No | N/A |
| Error tolerance | Hamming distance | Hamming distance | Hamming distance | N/A |
| Encoding mechanism | Angular sectors | Binary grid | Binary grid | None |
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:
| Stage | Name | Description |
|---|---|---|
| 1 | Proposal | Scharr gradient voting + NMS produces candidate centers |
| 2 | Outer Estimate | Radial profile peak detection yields radius hypotheses |
| 3 | Outer Fit | RANSAC ellipse fitting on sampled edge points |
| 4 | Decode | 16-sector code sampling and codebook matching |
| 5 | Inner Estimate | Inner ring ellipse fitting from outer prior |
| 6 | Dedup | Spatial 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:
| Order | Name | Description |
|---|---|---|
| 1 | Projective Center | Correct fit-decode marker centers (once per marker) |
| 2 | ID Correction | Structural consistency scrub/recovery of decoded IDs |
| 3 | Global Filter | Optional RANSAC homography from decoded markers with known board positions |
| 4 | Completion | Optional conservative fits at missing H-projected IDs (+ projective center for new markers) |
| 5 | Final H Refit | Optional 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:
- Before global filter: Corrects all fit-decode markers so that downstream geometric stages operate on unbiased centers.
- 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):
- Pass 1: Run
detect_single_passwithout the mapper to get initial detections. - 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:
- Baseline pass: Run
detect_single_pass. - Self-undistort estimation: If enabled and enough markers with edge points are available, estimate a
DivisionModelmapper from the ellipse edge points. - 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:
- Build tiers (automatic probe, hint-derived, or explicit).
- Run per-tier fit/decode + projective center + ID correction.
- Merge markers across tiers with size-aware dedup.
- Run global filter + completion + final homography refit once.
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
PixelMapperis 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 ofDetectedMarkerstructs.homography: Optional 3x3 board-to-image homography matrix.ransac: OptionalRansacStatsfor the homography fit.image_size: Dimensions of the input image.center_frame: AlwaysDetectionFrame::Image.homography_frame:ImageorWorkingdepending 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:
- Quantize the gradient direction to one of 4 directions (0, 45, 90, 135 degrees) using integer ratio tests — no
atan2needed. - Compare the pixel’s gradient magnitude squared against its two neighbors along the quantized direction.
- 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:
- Compute the unit gradient direction:
(dx, dy) = (gx/mag, gy/mag) - For each sign in
{-1, +1}:- Walk along the direction
sign * (dx, dy)at integer radius steps fromr_mintor_max - At each voted position, deposit
maginto the accumulator using bilinear interpolation
- Walk along the direction
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:
- Use an internal NMS radius of
min(min_distance, 10.0)pixels, capped for efficiency (offset count scales as pi * r^2). - Scan all pixels outside a border margin. Skip pixels below
min_vote_frac * max_accumulator_value(default: 10% of max). - 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:
- Sort NMS survivors by score (descending).
- Greedily accept proposals, rejecting any that fall within
min_distancepixels of an already-accepted proposal. - Accepted peaks become
Proposalstructs 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:
| Variant | Behavior |
|---|---|
Auto | Factor 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:
- 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.
- 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:
| Parameter | Default | Description |
|---|---|---|
r_min | 3.0 | Minimum voting radius in pixels |
r_max | 12.0 | Maximum voting radius in pixels |
min_distance | 10.0 | Minimum distance between output proposals (pixels) |
grad_threshold | 0.05 | Gradient magnitude threshold (fraction of max) |
min_vote_frac | 0.1 | Minimum accumulator value (fraction of max) |
accum_sigma | 2.0 | Gaussian sigma for accumulator smoothing |
edge_thinning | true | Apply Canny-style gradient NMS before voting |
max_candidates | None | Optional 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_pxmin_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 twoOuterHypothesisstructs, sorted best-first, each withr_outer_px,peak_strength, andtheta_consistency.status:OkorFailedwith a diagnostic reason.
Configuration
The OuterEstimationConfig struct controls this stage:
| Parameter | Default | Description |
|---|---|---|
search_halfwidth_px | 4.0 | Search half-width around expected radius |
radial_samples | 64 | Number of radial samples per ray |
theta_samples | 48 | Number of angular rays |
aggregator | Median | Angular aggregation method |
grad_polarity | DarkToLight | Expected edge polarity |
min_theta_coverage | 0.6 | Minimum fraction of valid rays |
min_theta_consistency | 0.35 | Minimum fraction of rays agreeing with peak |
allow_two_hypotheses | true | Emit runner-up hypothesis if strong enough |
second_peak_min_rel | 0.85 | Runner-up must be this fraction of best peak |
refine_halfwidth_px | 1.0 | Per-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:
| Parameter | Description |
|---|---|
n_rays | Number of uniformly spaced radial rays (typically 64–128) |
r_min | Minimum search radius in pixels |
r_max | Maximum 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:
- Sample: randomly select 6 edge points (the minimum for Fitzgibbon)
- Fit: compute the direct least-squares ellipse
- Score: count inliers using Sampson distance as the error metric
- Iterate: repeat for
max_itersiterations, keeping the best model - 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:
| Gate | Default | Purpose |
|---|---|---|
| Semi-axis bounds | min_semi_axis = 3.0, max_semi_axis = 15.0 (derived from scale prior) | Reject fits that are too small or too large |
| Aspect ratio | max_aspect_ratio = 3.0 | Reject highly elongated fits (likely not a ring) |
| Inlier ratio | Minimum fraction of edge points that are inliers | Reject 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:
- Computes the sector center angle:
θ_k = k × 2π/16fork = 0..15 - Samples pixel intensities at multiple points within the sector (oversampled in both angular and radial directions)
- 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:
- Initialize threshold at the mean of all sector intensities
- Split sectors into two groups (above/below threshold)
- Recompute threshold as the mean of the group means
- 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 shippedbaseprofile, the minimum cyclic Hamming distance is2; for the opt-inextendedprofile it is1.
DecodeMetrics
The decoding stage produces a DecodeMetrics struct:
| Field | Type | Meaning |
|---|---|---|
observed_word | u16 | The raw 16-bit word before matching |
best_id | usize | Matched codebook entry ID |
best_rotation | u8 | Rotation offset (0–15 sectors) |
best_dist | u8 | Hamming distance to best match |
margin | u8 | Gap to second-best match |
decode_confidence | f32 | Combined 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:
| Parameter | Default | Purpose |
|---|---|---|
min_points | 20 | Minimum edge points to attempt fit |
min_inlier_ratio | 0.5 | Minimum RANSAC inlier fraction |
max_rms_residual | 1.0 px | Maximum RMS Sampson residual |
max_center_shift_px | 12.0 px | Maximum center offset from outer fit |
max_ratio_abs_error | 0.15 | Maximum deviation of recovered scale ratio from radial hint |
ransac.max_iters | 200 | RANSAC iterations |
ransac.inlier_threshold | 1.5 px | Sampson distance inlier threshold |
ransac.min_inliers | 8 | Minimum inlier count |
Validation
After fitting, the inner ellipse is validated against the outer ellipse:
- Center consistency: the inner ellipse center must be within
max_center_shift_pxof the outer ellipse center - Scale ratio: the ratio of inner to outer semi-axes must be close to
r_inner_expected(withinmax_ratio_abs_error) - 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:
| When | Which markers |
|---|---|
| Before global filter | All markers from fit-decode stage |
| After completion | Completion-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:
| Parameter | Default | Purpose |
|---|---|---|
enable | true | Master switch |
use_expected_ratio | true | Use r_inner_expected as eigenvalue prior |
ratio_penalty_weight | 1.0 | Weight for ratio-prior penalty |
max_center_shift_px | Derived from scale prior | Reject corrections that shift the center too far |
max_selected_residual | Some(0.25) | Reject candidates with high geometric residual |
min_eig_separation | Some(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:
- Outlier rejection: markers that are inconsistent with the dominant planar mapping are discarded
- 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 = trueinDetectConfig
When fewer than 4 decoded markers are available, the global filter is skipped and homography-dependent finalize stages do not run.
Algorithm
- Build correspondences: for each decoded marker, pair its board position
(xy_mm)with its detected center - 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
- Discard outlier markers
Configuration
RansacHomographyConfig:
| Parameter | Default | Purpose |
|---|---|---|
max_iters | 2000 | Maximum RANSAC iterations |
inlier_threshold | 5.0 px | Reprojection error threshold |
min_inliers | 6 | Minimum inliers for a valid model |
seed | 0 | Random seed for reproducibility |
Output
The global filter produces:
- A fitted homography matrix H (3x3, stored in
DetectionResult.homography) RansacStatswith 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=Noneor 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
- Bootstrap trusted anchors from decoded IDs.
- Pre-consistency scrub clears IDs that contradict local hex-neighbor structure.
- Local recovery iteratively votes unresolved markers from trusted neighbors using local-scale gates derived from marker ellipse radii.
- Homography fallback (optional) seeds unresolved markers with a rough, gated board-to-image model built from trusted anchors.
- Post-consistency sweep + refill repeats scrub/recovery to remove late contradictions and fill safe holes.
- 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:
- Project: use the homography to map the board position to image coordinates
- Boundary check: skip if the projected position is too close to the image edge (within
image_margin_px) - Local fit: run edge sampling and RANSAC ellipse fitting within a limited ROI (
roi_radius_px) around the projected center - Decode: attempt code decoding at the fitted position
- 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:
| Parameter | Default | Purpose |
|---|---|---|
reproj_gate_px | 3.0 px | Max distance between fitted center and H-projected position |
min_fit_confidence | 0.45 | Minimum fit quality score |
min_arc_coverage | 0.35 | Minimum fraction of rays with valid edge detections |
roi_radius_px | 24.0 px (derived from scale prior) | Edge sampling extent |
image_margin_px | 10.0 px | Skip attempts near image boundary |
max_attempts | None (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:
- Uses all available markers for maximum accuracy
- Accepts the refit only if the mean reprojection error improves
- Updates
DetectionResult.homographyandDetectionResult.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-productsS₁₂(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 x² terms around 10⁵ to the constant 1). This makes the scatter matrix S ill-conditioned.
Ringgrid applies Hartley-style normalization before fitting:
- Compute the centroid
(m_x, m_y)of the input points - Compute the mean distance
dof points from the centroid - Set scale factor
s = √2 / d - 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
- Normalize the input points (Hartley-style: center and scale)
- Build the
n x 6design matrixDin normalized coordinates - Compute the scatter matrix
S = Dᵀ Dand partition into 3x3 blocks - Compute the reduced matrix
M = S₁₁ - S₁₂ S₂₂⁻¹ S₂₁ - Solve the eigenvalue problem
C₁⁻¹ M a₁ = λ a₁ - Select the eigenvector with positive ellipse constraint:
a₁ᵀ C₁ a₁ > 0 - Recover linear coefficients:
a₂ = -S₂₂⁻¹ S₂₁ a₁ - Denormalize the conic coefficients to original coordinates
- Validate the result (check it is a proper ellipse with finite positive semi-axes)
- 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 w | m = 4 (homography) | m = 6 (ellipse) |
|---|---|---|
| 0.9 | 5 | 8 |
| 0.7 | 16 | 47 |
| 0.5 | 71 | 292 |
| 0.3 | 493 | 5,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):
| Parameter | Typical value | Purpose |
|---|---|---|
max_iters | 200–500 | Iteration budget |
inlier_threshold | 1.0–2.0 px | Sampson distance threshold |
min_inliers | 8 | Minimum inlier count for acceptance |
seed | Fixed | Reproducible 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:
- Sample 4 distinct random correspondences
- Fit H via DLT
- Count inliers (reprojection error <
inlier_threshold) - Track best model
- Early exit when >90% of points are inliers
- After all iterations, refit from all inliers of the best model
- Recompute inlier mask with the refit H
Configuration (RansacHomographyConfig):
| Parameter | Default | Purpose |
|---|---|---|
max_iters | 2000 | Iteration budget |
inlier_threshold | 5.0 px | Reprojection error threshold |
min_inliers | 6 | Minimum inlier count |
seed | 0 | Reproducible random seed |
Output (RansacStats):
| Field | Meaning |
|---|---|
n_candidates | Total correspondences fed to RANSAC |
n_inliers | Inliers after final refit |
threshold_px | Threshold used |
mean_err_px | Mean inlier reprojection error |
p95_err_px | 95th 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):
- Compute the centroid
(cx, cy)of the point set - Compute the mean distance from the centroid
- 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:
| Gate | Purpose |
|---|---|
max_center_shift_px | Reject if correction moves center too far from ellipse-fit center |
max_selected_residual | Reject if geometric residual is too high (unreliable solution) |
min_eig_separation | Reject 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
- Baseline detection: run the standard pipeline (no distortion correction) to detect initial markers
- Check prerequisites: need at least
min_markers(default 6) markers with both inner and outer edge points - Optimize λ: search for the λ that minimizes an objective function over a bounded range
[lambda_min, lambda_max](default[-8e-7, 8e-7]) - Accept/reject: apply gates to decide if the estimated correction is meaningful
- Pass-2 detection: if accepted, re-run detection with the estimated
DivisionModelas 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:
| Field | Meaning |
|---|---|
model | The estimated DivisionModel (λ, cx, cy) |
applied | Whether the correction was accepted and applied |
objective_at_zero | Objective value with no correction |
objective_at_lambda | Objective value at the estimated λ |
n_markers_used | Number of markers contributing to the estimation |
Comparison with Brown-Conrady
| Property | Division Model | Brown-Conrady |
|---|---|---|
| Parameters | 1 (λ) | 5 (k1, k2, p1, p2, k3) |
| Requires intrinsics | No (center at image center) | Yes (fx, fy, cx, cy) |
| Used by | Self-undistort mode | detect_with_mapper with CameraModel |
| Forward mapping | Closed-form | Closed-form |
| Inverse mapping | Iterative | Iterative |
| Accuracy | Captures dominant radial distortion | Full 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:
| Parameter | Default | Purpose |
|---|---|---|
enable | false | Enable self-undistort estimation |
lambda_range | [-8e-7, 8e-7] | Search bounds for λ |
min_markers | 6 | Minimum 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– derivesmarker_spec.r_inner_expectedanddecode.code_band_ratiofrom 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
| Field | Type | Default | Purpose |
|---|---|---|---|
marker_scale | MarkerScalePrior | 14.0–66.0 px | Expected marker diameter range in pixels. Drives derivation of many downstream parameters. |
outer_estimation | OuterEstimationConfig | (see sub-configs) | Outer-edge radius hypothesis generation from radial profile peaks. |
proposal | ProposalConfig | (derived from scale) | Scharr gradient voting and NMS proposal generation. r_min, r_max, nms_radius are auto-derived. |
seed_proposals | SeedProposalParams | merge=3.0, score=1e12, max=512 | Controls seed injection for multi-pass detection. |
edge_sample | EdgeSampleConfig | (derived from scale) | Radial edge sampling range and ray count. r_min, r_max are auto-derived. |
decode | DecodeConfig | (derived from board) | 16-sector code sampling. code_band_ratio is auto-derived from board geometry; codebook_profile defaults to base. |
marker_spec | MarkerSpec | (derived from board) | Marker geometry specification. r_inner_expected is auto-derived from board inner/outer radius ratio. |
inner_fit | InnerFitConfig | (see sub-configs) | Robust inner ellipse fitting: RANSAC params, validation gates. |
circle_refinement | CircleRefinementMethod | ProjectiveCenter | Center correction strategy selector: None or ProjectiveCenter. |
projective_center | ProjectiveCenterParams | (see sub-configs) | Projective center recovery gates and tuning. max_center_shift_px is auto-derived from scale. |
completion | CompletionParams | (see sub-configs) | Completion at missing H-projected board positions. roi_radius_px is auto-derived from scale. |
min_semi_axis | f64 | 3.0 | Minimum semi-axis length (px) for a valid outer ellipse. Auto-derived from scale. |
max_semi_axis | f64 | 15.0 | Maximum semi-axis length (px) for a valid outer ellipse. Auto-derived from scale. |
max_aspect_ratio | f64 | 3.0 | Maximum aspect ratio (a/b) for a valid ellipse. |
dedup_radius | f64 | 6.0 | NMS deduplication radius (px) for final markers. |
use_global_filter | bool | true | Enable RANSAC homography global filter (requires board layout with marker positions). |
ransac_homography | RansacHomographyConfig | iters=2000, thresh=5.0 | RANSAC parameters for homography estimation. |
board | BoardLayout | empty | Board layout defining marker positions and geometry. |
id_correction | IdCorrectionConfig | enabled | Structural consistency verification/recovery of decoded IDs before global filter. |
self_undistort | SelfUndistortConfig | disabled | Self-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
| Field | Type | Default | Description |
|---|---|---|---|
diameter_min_px | f32 | 14.0 | Minimum expected marker outer diameter in pixels. |
diameter_max_px | f32 | 66.0 | Maximum 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:
- Non-finite values are replaced with the corresponding default (14.0 or 66.0).
- If
min > max, the two are swapped. minis clamped to at least 4.0 px.maxis clamped to at leastmin.
The normalized() method returns a normalized copy without mutating the original.
Methods
| Method | Return | Description |
|---|---|---|
diameter_range_px() | [f32; 2] | Normalized [min, max] diameter in pixels. |
nominal_diameter_px() | f32 | Midpoint of the range: 0.5 * (min + max). |
nominal_outer_radius_px() | f32 | Half 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 field | Formula |
|---|---|
proposal.r_min | max(0.4 * r_min, 2.0) |
proposal.r_max | 1.7 * r_max |
proposal.nms_radius | max(0.8 * r_min, 2.0) |
Edge sampling range
| Derived field | Formula |
|---|---|
edge_sample.r_max | 2.0 * r_max |
edge_sample.r_min | 1.5 (fixed) |
Outer estimation
| Derived field | Formula |
|---|---|
outer_estimation.theta_samples | set to edge_sample.n_rays |
outer_estimation.search_halfwidth_px | max(max((r_max - r_min) * 0.5, 2.0), base_default) |
Ellipse validation bounds
| Derived field | Formula |
|---|---|
min_semi_axis | max(0.3 * r_min, 2.0) |
max_semi_axis | max(2.5 * r_max, min_semi_axis) |
Completion ROI
| Derived field | Formula |
|---|---|
completion.roi_radius_px | clamp(0.75 * d_nom, 24.0, 80.0) |
Projective center shift gate
| Derived field | Formula |
|---|---|
projective_center.max_center_shift_px | Some(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. Measuredas 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)orconfig.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, orDetector::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.
| Field | Type | Default | Description |
|---|---|---|---|
merge_radius_px | f32 | 3.0 | Radius (px) for merging seed centers with detector proposals. Seeds within this distance of an existing proposal are merged rather than duplicated. |
seed_score | f32 | 1e12 | Score assigned to injected seed proposals. The high default ensures seeds survive NMS against weaker gradient-based proposals. |
max_seeds | Option<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.
| Field | Type | Default | Description |
|---|---|---|---|
enable | bool | true | Master switch for the completion stage. |
roi_radius_px | f32 | 24.0 | Radial 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_px | f32 | 3.0 | Maximum reprojection error (px) between the fitted center and the H-projected board center. Fits exceeding this gate are rejected. |
min_fit_confidence | f32 | 0.45 | Minimum fit confidence score in [0, 1] for accepting a completion fit. |
min_arc_coverage | f32 | 0.35 | Minimum arc coverage (fraction of rays with both edges found). Low coverage indicates the marker is partially occluded or near the image boundary. |
max_attempts | Option<usize> | None | Optional cap on the number of completion fits attempted, in ID order. None means try all missing positions. |
image_margin_px | f32 | 10.0 | Skip 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.
| Field | Type | Default | Description |
|---|---|---|---|
enable | bool | true | Master switch for ID correction. |
auto_search_radius_outer_muls | Vec<f64> | [2.4, 2.9, 3.5, 4.2, 5.0] | Local-scale staged neighborhood multipliers (derived from pairwise outer ellipse radii). |
consistency_outer_mul | f64 | 3.2 | Neighborhood multiplier for consistency checks. |
consistency_min_neighbors | usize | 1 | Minimum neighbors required to evaluate consistency evidence. |
consistency_min_support_edges | usize | 1 | Minimum board-neighbor support edges required for non-soft-locked IDs. |
consistency_max_contradiction_frac | f32 | 0.5 | Maximum contradiction fraction allowed by consistency checks. |
soft_lock_exact_decode | bool | true | Soft-lock exact decodes: do not normally override them. |
min_votes | usize | 2 | Minimum votes for candidate acceptance when marker already has an ID. |
min_votes_recover | usize | 1 | Minimum votes for recovering id=None markers. |
min_vote_weight_frac | f32 | 0.55 | Winner weighted-vote fraction gate. |
h_reproj_gate_px | f64 | 30.0 | Reprojection gate for rough-homography fallback assignments. |
homography_fallback_enable | bool | true | Enable rough-homography fallback seeding for unresolved markers. |
homography_min_trusted | usize | 24 | Minimum trusted markers before fallback can run. |
homography_min_inliers | usize | 12 | Minimum inliers for fallback homography acceptance. |
max_iters | usize | 5 | Max local iterative passes per local-stage multiplier. |
remove_unverified | bool | false | Remove unresolved markers instead of clearing their IDs. |
seed_min_decode_confidence | f32 | 0.7 | Confidence 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.
| Field | Type | Default | Description |
|---|---|---|---|
enable | bool | true | Enable projective center estimation. |
use_expected_ratio | bool | true | Use marker_spec.r_inner_expected as an eigenvalue prior when selecting among candidate centers. |
ratio_penalty_weight | f64 | 1.0 | Weight 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_px | Option<f64> | None | Maximum 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_residual | Option<f64> | Some(0.25) | Maximum accepted projective-selection residual. Higher values are less strict. None disables this gate. |
min_eig_separation | Option<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.
| Field | Type | Default | Description |
|---|---|---|---|
min_points | usize | 20 | Minimum number of sampled edge points required to attempt a fit. |
min_inlier_ratio | f32 | 0.5 | Minimum RANSAC inlier ratio for accepting the inner fit. |
max_rms_residual | f64 | 1.0 | Maximum accepted RMS Sampson residual (px) of the fitted inner ellipse. |
max_center_shift_px | f64 | 12.0 | Maximum allowed center shift (px) from the outer ellipse center to the inner ellipse center. |
max_ratio_abs_error | f64 | 0.15 | Maximum absolute error between the recovered inner/outer scale ratio and the radial hint. |
local_peak_halfwidth_idx | usize | 3 | Half-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:
| Field | Type | Default | Description |
|---|---|---|---|
ransac.max_iters | usize | 200 | Maximum RANSAC iterations for inner ellipse fitting. |
ransac.inlier_threshold | f64 | 1.5 | Inlier threshold (Sampson distance in px). |
ransac.min_inliers | usize | 8 | Minimum inlier count for a valid inner ellipse model. |
ransac.seed | u64 | 43 | Random seed for reproducibility. |
Source: crates/ringgrid/src/detector/config.rs
CircleRefinementMethod
Enum selector for the center correction strategy applied after local ellipse fits.
| Variant | Description |
|---|---|
None | Disable 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.
| Field | Type | Default | Description |
|---|---|---|---|
max_iters | usize | 2000 | Maximum RANSAC iterations. |
inlier_threshold | f64 | 5.0 | Inlier threshold: maximum reprojection error (px) for a correspondence to be counted as an inlier. |
min_inliers | usize | 6 | Minimum number of inliers for the homography to be accepted. The pipeline requires at least 4 decoded markers to attempt RANSAC. |
seed | u64 | 0 | Random 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.
| Field | Type | Default | Description |
|---|---|---|---|
enable | bool | false | Master 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_evals | usize | 40 | Maximum function evaluations for the golden-section 1D optimizer. |
min_markers | usize | 6 | Minimum number of markers with both inner and outer edge points required to attempt estimation. |
improvement_threshold | f64 | 0.01 | Relative improvement threshold: the model is applied only if (baseline - optimum) / baseline exceeds this value. |
min_abs_improvement | f64 | 1e-4 | Minimum absolute objective improvement required. Prevents applying corrections when the objective is near the numerical noise floor. |
trim_fraction | f64 | 0.1 | Trim fraction for robust aggregation: drop this fraction of scores from both tails before averaging per-marker objectives. |
min_lambda_abs | f64 | 5e-9 | Minimum absolute value of lambda required. Very small lambda values are treated as “no correction”. |
reject_range_edge | bool | true | Reject solutions that land near the lambda search range boundaries, which may indicate the true optimum lies outside the range. |
range_edge_margin_frac | f64 | 0.02 | Fraction of the lambda range treated as an unstable boundary zone. |
validation_min_markers | usize | 24 | Minimum decoded-ID correspondences needed for homography-based validation of the estimated model. |
validation_abs_improvement_px | f64 | 0.05 | Minimum absolute homography self-error improvement (px) required during validation. |
validation_rel_improvement | f64 | 0.03 | Minimum 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 withserde_json - the CLI output file written by
ringgrid detect --out ..., which flattens the sameDetectionResultfields 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:
camerawhen detection used--calibrationor inline--cam-*parametersproposal_frame,proposal_count, andproposalswhen--include-proposalsis enabled
Top-Level Fields
| Field | Present when | Meaning |
|---|---|---|
detected_markers | always | Final emitted markers. Each entry is a DetectedMarker. |
center_frame | always | Coordinate frame of each marker center. Current contract: always image. |
homography_frame | always | Coordinate frame of the homography matrix: image or working. |
image_size | always | Input image dimensions as [width, height]. |
homography | when fitted | 3x3 row-major homography mapping board millimeters into homography_frame. |
ransac | when homography exists | Quality statistics for the fitted homography. See RansacStats. |
self_undistort | when self-undistort ran | Estimated division-model correction and whether it was applied. |
camera | CLI only, when camera input was provided | The CameraModel used by the two-pass mapper path. |
proposal_frame | CLI only, with --include-proposals | Coordinate frame of proposals. Currently always image. |
proposal_count | CLI only, with --include-proposals | Number of serialized proposals. |
proposals | CLI only, with --include-proposals | Pass-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:
| Field | Meaning |
|---|---|
id | Decoded codebook index. Omitted when decoding was rejected or cleared. |
board_xy_mm | Board-space marker location in millimeters for valid decoded IDs. |
confidence | Combined fit/decode confidence in [0, 1]. |
center | Marker center in raw image pixels. Always safe to overlay on the original image. |
center_mapped | Working-frame center when a mapper was active. |
ellipse_outer, ellipse_inner | Fitted ellipse parameters. With a mapper, ellipse coordinates are in the working frame. |
edge_points_outer, edge_points_inner | Raw subpixel edge points retained for diagnostics and downstream analysis. |
fit | Fit-quality metrics such as arc coverage, residuals, angular gaps, and reprojection error. |
decode | Decode-quality metrics such as observed word, best distance, margin, and rotation. |
source | Which pipeline path produced the final marker. |
source uses these enum values:
fit_decoded: normal proposal -> fit -> decode pathcompletion: homography-guided completion stageseeded_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_framedescribesDetectedMarker.centerhomography_framedescribeshomography
Important contract:
centeris always in the original image framecenter_mappedis the undistorted working-frame center when a mapper was activehomographymaps board millimeters into the frame named byhomography_frame
This means:
- use
centerfor overlays on the source image - use
center_mappedandhomographytogether 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
| Field | Type | Description |
|---|---|---|
detected_markers | Vec<DetectedMarker> | All detected markers in the image. See DetectedMarker. |
center_frame | DetectionFrame | Coordinate frame of each marker’s center field. Current contract: always Image. |
homography_frame | DetectionFrame | Coordinate frame of the homography matrix (Image or Working). |
image_size | [u32; 2] | Image dimensions as [width, height]. |
homography | Option<[[f64; 3]; 3]> | 3x3 row-major board-to-output-frame homography. Present when 4 or more markers were decoded. |
ransac | Option<RansacStats> | RANSAC quality statistics for the homography fit. See RansacStats. |
self_undistort | Option<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 aPixelMapperis active).
Frame conventions
The values of center_frame and homography_frame depend on how detection was invoked:
| Detection mode | center_frame | homography_frame |
|---|---|---|
Detector::detect() (no mapper) | Image | Image |
Detector::detect_with_mapper() | Image | Working |
| Self-undistort (correction not applied) | Image | Image |
| Self-undistort (correction applied) | Image | Working |
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
| Field | Type | Description |
|---|---|---|
id | Option<usize> | Codebook index in the active profile. None if decoding was rejected due to insufficient confidence or Hamming distance. |
board_xy_mm | Option<[f64; 2]> | Board-space marker location in millimeters (BoardLayout::xy_mm semantics). Present only when id is valid for the active board layout. |
confidence | f32 | Combined detection and decode confidence in [0, 1]. |
center | [f64; 2] | Marker center in raw image pixel coordinates [x, y]. |
center_mapped | Option<[f64; 2]> | Marker center in working-frame coordinates. Present only when a PixelMapper is active. |
ellipse_outer | Option<Ellipse> | Fitted outer ring ellipse parameters. |
ellipse_inner | Option<Ellipse> | Fitted inner ring ellipse parameters. Present when inner fitting succeeded. |
edge_points_outer | Option<Vec<[f64; 2]>> | Raw sub-pixel outer edge inlier points used for ellipse fitting. |
edge_points_inner | Option<Vec<[f64; 2]>> | Raw sub-pixel inner edge inlier points used for ellipse fitting. |
fit | FitMetrics | Fit quality metrics. See FitMetrics. |
decode | Option<DecodeMetrics> | Decode quality metrics. Present when decoding was attempted. See FitMetrics & DecodeMetrics. |
source | DetectionSource | Pipeline 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 pathcompletion– the homography-guided completion stage filled a missing board markerseeded_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:
| Parameter | Description |
|---|---|
cx, cy | Ellipse center |
a | Semi-major axis length |
b | Semi-minor axis length |
angle | Rotation 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
idisSome(i), thenboard_xy_mmis present and equals the active board layout coordinate ofi - if
idisNone, thenboard_xy_mmis 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
| Field | Type | Description |
|---|---|---|
n_angles_total | usize | Total number of radial rays cast from the candidate center. |
n_angles_with_both_edges | usize | Number of rays where both inner and outer ring edges were found. |
n_points_outer | usize | Number of outer edge points used for the ellipse fit. |
n_points_inner | usize | Number of inner edge points used for the inner ellipse fit. 0 if no inner fit was performed. |
ransac_inlier_ratio_outer | Option<f32> | Fraction of outer edge points classified as RANSAC inliers. |
ransac_inlier_ratio_inner | Option<f32> | Fraction of inner edge points classified as RANSAC inliers. |
rms_residual_outer | Option<f64> | RMS Sampson distance of outer edge points to the fitted ellipse (in pixels). |
rms_residual_inner | Option<f64> | RMS Sampson distance of inner edge points to the fitted ellipse (in pixels). |
max_angular_gap_outer | Option<f64> | Largest angular gap between consecutive outer-edge samples (radians). Large gaps indicate missing ring coverage or occlusion. |
max_angular_gap_inner | Option<f64> | Largest angular gap between consecutive inner-edge samples (radians). |
inner_fit_status | Option<InnerFitStatus> | Outcome of the inner-fit stage: ok, rejected, or failed. |
inner_fit_reason | Option<InnerFitReason> | Stable reason code explaining why inner fitting was rejected or failed. |
neighbor_radius_ratio | Option<f32> | Ratio of this marker’s outer radius to nearby decoded neighbors. Low values can indicate inner-as-outer contamination. |
inner_theta_consistency | Option<f32> | Fraction of angular samples that agree on the inner-edge location. |
radii_std_outer_px | Option<f32> | Standard deviation of per-ray outer radii. High spread suggests unstable outer-edge sampling. |
h_reproj_err_px | Option<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_outer | Interpretation |
|---|---|
| > 0.90 | Excellent – clean edges with minimal outliers |
| 0.80 – 0.90 | Good – some edge noise or partial occlusion |
| < 0.70 | Poor – significant outliers, possible false detection |
RMS Sampson residual measures the geometric precision of the fit:
rms_residual_outer | Interpretation |
|---|---|
| < 0.3 px | Excellent sub-pixel precision |
| 0.3 – 0.5 px | Good precision |
| 0.5 – 1.0 px | Acceptable but noisy |
| > 1.0 px | Poor 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 ratio | Interpretation |
|---|---|
| > 0.85 | Full ring visible, high confidence |
| 0.5 – 0.85 | Partial occlusion or edge-of-frame |
| < 0.5 | Severely 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
| Field | Type | Description |
|---|---|---|
observed_word | u16 | Raw 16-bit word sampled from the code band. Each bit corresponds to one sector (bright = 1, dark = 0). |
best_id | usize | Index of the best-matching codebook entry in the active profile. |
best_rotation | u8 | Cyclic rotation (0–15) that produced the best match. Each unit is 22.5 degrees. |
best_dist | u8 | Hamming distance between the observed word (at best rotation) and the codebook entry. |
margin | u8 | Gap between the best and second-best Hamming distances: second_best_dist - best_dist. |
decode_confidence | f32 | Heuristic 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_dist | Interpretation |
|---|---|
| 0 | Exact match – no bit errors |
| 1 – 2 | Minor noise, still reliable |
| 3 | At the default acceptance threshold |
| > 3 | Rejected 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:
margin | Interpretation |
|---|---|
| >= 4 | Highly unambiguous |
| 3 | Reliable |
| 2 | Acceptable but less certain |
| 1 | Risky – two codewords are nearly tied |
| 0 | Ambiguous – 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
| Field | Type | Description |
|---|---|---|
n_candidates | usize | Total number of decoded marker correspondences fed to RANSAC. |
n_inliers | usize | Number of correspondences classified as inliers after the final refit. |
threshold_px | f64 | Reprojection error threshold (in working-frame pixels) used to classify inliers. |
mean_err_px | f64 | Mean reprojection error across all inliers (in working-frame pixels). |
p95_err_px | f64 | 95th 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_px | Assessment |
|---|---|
| < 0.5 px | Excellent – very precise calibration-grade fit |
| 0.5 – 1.0 px | Good – suitable for most applications |
| 1.0 – 3.0 px | Acceptable – some noise or mild distortion present |
| 3.0 – 5.0 px | Marginal – consider checking for distortion, wrong scale, or occlusion |
| > 5.0 px | Poor – 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 ratio | Assessment |
|---|---|
| > 0.90 | Excellent – nearly all detections are consistent |
| 0.80 – 0.90 | Good – a few outliers filtered |
| 0.60 – 0.80 | Some markers have incorrect IDs or poor localization |
| < 0.60 | Problematic – 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:
- Gradient voting and NMS produce candidate centers.
- Outer and inner ellipses are fitted via RANSAC.
- 16-sector codes are sampled and matched against the active embedded codebook profile (
baseby default). - Spatial and ID-based deduplication removes redundant detections.
- If enough decoded markers exist, a RANSAC homography is fitted.
- Completion fills in missing markers at H-projected positions.
All geometry stays in image space throughout.
Coordinate Frames
| Field | Frame |
|---|---|
center | Image (distorted pixel coordinates) |
center_mapped | None |
homography | Image -> Board (maps board mm to image pixels) |
center_frame | DetectionFrame::Image |
homography_frame | DetectionFrame::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.rs–Detectorstruct 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
| Field | Type | Description |
|---|---|---|
proposals | list[Proposal] | Detected center candidates with (x, y, score) |
heatmap | np.ndarray (H, W), float32 | Post-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
| Parameter | Default | Description |
|---|---|---|
r_min | 3.0 | Minimum voting radius (pixels) |
r_max | 12.0 | Maximum voting radius (pixels) |
min_distance | 10.0 | Minimum distance between output proposals (pixels) |
grad_threshold | 0.05 | Gradient magnitude threshold (fraction of max) |
min_vote_frac | 0.1 | Minimum accumulator peak (fraction of max) |
accum_sigma | 2.0 | Gaussian smoothing sigma |
edge_thinning | true | Apply Canny-style gradient NMS before voting |
max_candidates | None | Optional 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
| Preset | Tiers | Diameter range | Typical use |
|---|---|---|---|
ScaleTiers::four_tier_wide() | 4 | 8–220 px | Unknown or extreme scale variation |
ScaleTiers::two_tier_standard() | 2 | 14–100 px | Moderate variation, lower runtime |
ScaleTiers::single(prior) | 1 | custom | Single-pass equivalent |
How detect_adaptive Chooses Tiers
- Runs a lightweight scale probe to estimate dominant code-band radii.
- Builds one or more tiers from probe clusters (
ScaleTiers::from_detected_radii). - Falls back to
ScaleTiers::four_tier_wide()if probe signal is unavailable. - Runs one full detect pass per tier.
- Merges all markers with size-consistency-aware dedup.
- 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.rscrates/ringgrid/src/pipeline/run.rscrates/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:
- Pass 1 (no mapper) – standard single-pass detection in image coordinates. This produces seed proposals (approximate marker locations).
- 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
| Field | Frame |
|---|---|
center | Image (distorted pixel coordinates, always) |
center_mapped | Working (undistorted pixel coordinates) |
homography | Working -> Board (maps board mm to undistorted pixels) |
center_frame | DetectionFrame::Image |
homography_frame | DetectionFrame::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 theconfig.self_undistort.enablesetting. The provided mapper takes precedence. - result.self_undistort is None. Since self-undistort does not run, this field
will always be
Nonewhen usingdetect_with_mapper. - Homography maps board to working frame. The 3x3 homography in
result.homographytransforms 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.rs–Detector::detect_with_mappermethod.crates/ringgrid/src/pixelmap/cameramodel.rs–CameraModel,CameraIntrinsics,PixelMapperimplementation.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
- Baseline detection – a standard single-pass detect runs in image coordinates, producing markers with inner and outer edge points.
- 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.
- Accept/reject – the estimated lambda is accepted only if:
- The objective improvement exceeds
improvement_threshold(relative) andmin_abs_improvement(absolute). - The estimated
|lambda|exceedsmin_lambda_abs. - The solution is not at the edge of the search range (when
reject_range_edgeis enabled). - Homography validation passes (when enough decoded markers are available).
- The objective improvement exceeds
- 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:
| Field | Type | Description |
|---|---|---|
model | DivisionModel | Estimated division model (lambda, cx, cy) |
applied | bool | Whether the model was actually used for re-detection |
objective_at_zero | f64 | Baseline objective value (lambda = 0) |
objective_at_lambda | f64 | Objective value at the estimated lambda |
n_markers_used | usize | Number 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:
| Parameter | Default | Description |
|---|---|---|
enable | false | Master switch for self-undistort |
lambda_range | [-8e-7, 8e-7] | Search range for the lambda parameter |
max_evals | 40 | Maximum objective evaluations for golden-section search |
min_markers | 6 | Minimum markers with both inner+outer edge points |
improvement_threshold | 0.01 | Minimum relative objective improvement |
min_abs_improvement | 1e-4 | Minimum absolute objective improvement |
trim_fraction | 0.1 | Trim fraction for robust aggregation (drop 10% tails) |
min_lambda_abs | 5e-9 | Minimum |lambda| to consider non-trivial |
reject_range_edge | true | Reject solutions near lambda-range boundaries |
range_edge_margin_frac | 0.02 | Relative margin treated as boundary zone |
validation_min_markers | 24 | Minimum decoded-ID matches for H-validation |
validation_abs_improvement_px | 0.05 | Minimum absolute H-error improvement (px) |
validation_rel_improvement | 0.03 | Minimum relative H-error improvement |
Coordinate Frames
When the model is applied (applied == true):
| Field | Frame |
|---|---|
center | Image (distorted pixel coordinates) |
center_mapped | Working (undistorted via division model) |
homography | Working -> Board |
homography_frame | DetectionFrame::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_mappernever 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
CameraModelinstead. - The estimation requires enough detected markers with usable edge points.
If the baseline pass finds fewer than
min_markersqualifying markers,result.self_undistortwill beNone.
Source Files
crates/ringgrid/src/api.rs–Detector::detectbranches onself_undistort.enable.crates/ringgrid/src/pixelmap/self_undistort.rs– estimation logic,SelfUndistortConfig,SelfUndistortResult.crates/ringgrid/src/pixelmap/distortion.rs–DivisionModeltype andPixelMapperimplementation.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_pixeltransforms a distorted image-space point into the undistorted working frame. ReturnNoneif the point cannot be mapped (e.g. it falls outside the valid domain of the distortion model).working_to_image_pixeltransforms an undistorted working-frame point back into distorted image space. ReturnNoneif 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:
| Type | Description |
|---|---|
CameraModel | Full Brown-Conrady model (k1, k2, k3 radial + p1, p2 tangential) with pinhole intrinsics. Undistortion is iterative. |
DivisionModel | 1-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
Nonefor invalid inputs. If a point is outside the image or the distortion formula diverges, returnNonerather 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 topfor points across the image. Sub-pixel accuracy (< 0.01 px error) is recommended.
Important Notes
- Self-undistort is not run when
detect_with_mapperis 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.rs–PixelMappertrait definition.crates/ringgrid/src/pixelmap/cameramodel.rs–CameraModelimplementsPixelMapper.crates/ringgrid/src/pixelmap/distortion.rs–DivisionModelimplementsPixelMapper.crates/ringgrid/src/api.rs–Detector::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_mappedcontains the marker center in the working frameDetectedMarker.centerremains in the image frame (mapped back viaworking_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 mode | center_frame | homography_frame | center_mapped present? |
|---|---|---|---|
Simple (detect()) | Image | Image | No |
External mapper (detect_with_mapper()) | Image | Working | Yes |
| Self-undistort (applied) | Image | Working | Yes |
| Self-undistort (not applied) | Image | Image | No |
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
-
For visualization (overlaying detections on the original image): use
centerdirectly — it is always in image coordinates. -
For calibration (computing camera parameters): use
center_mappedwhen available, since it is in the undistorted frame where the homography is valid. -
For reprojection error: match the frame of your ground truth to
homography_framemetadata, or map both to image space for consistent comparison. -
When implementing a custom
PixelMapper: ensure thatimage_to_working_pixelandworking_to_image_pixelare consistent inverses. ReturnNonefor 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:
- Rust CLI:
ringgrid gen-target- emits
board_spec.json,.svg, and.pngin one run - best when you want a pure-Rust command-line workflow
- emits
- Python script:
tools/gen_target.py- emits the same
board_spec.json,.svg, and.pngset - best when you are already using the repo’s Python tooling
- emits the same
- 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.pyfor synth images + ground truth + optional print filestools/gen_board_spec.pyfor 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.jsontools/out/target_print_200mm/target_print.svgtools/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.
| Argument | Default | Description |
|---|---|---|
--pitch_mm | required | Marker spacing in mm. |
--rows | required | Number of hex lattice rows. |
--long_row_cols | required | Number of markers in long rows. |
--marker_outer_radius_mm | required | Outer ring radius in mm. |
--marker_inner_radius_mm | required | Inner ring radius in mm. |
--marker_ring_width_mm | required | Full printed ring width in mm for both inner and outer dark rings. |
--name | auto | Optional board name. Omitted uses deterministic geometry-derived naming. |
--out_dir | tools/out/target | Output directory for board_spec.json, SVG, and PNG. |
--basename | target_print | Base filename for SVG/PNG outputs. |
--dpi | 300.0 | Raster DPI for PNG export (also embedded in PNG metadata). |
--margin_mm | 0.0 | Extra white margin around the board in print outputs. |
--no-scale-bar | false | Omit the default scale bar from SVG/PNG outputs. |
Equivalent Rust API mapping
| Rust API surface | Equivalent 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
| Argument | Default | Description |
|---|---|---|
--out_dir | tools/out/synth_002 | Output directory for all artifacts. |
--n_images | 3 | Number of synthetic rendered images (0 is valid for print-only runs). |
--board_mm | 200.0 | Board side size used for lattice generation and print canvas. |
--pitch_mm | 8.0 | Marker center-to-center spacing in mm. |
--n_markers | None | Optional marker count cap for generated lattice. |
--codebook | tools/codebook.json | Codebook JSON used for marker coding. |
tools/gen_synth.py print-output options
| Argument | Default | Description |
|---|---|---|
--print | false | Emit both printable SVG and PNG. |
--print_svg | false | Emit printable SVG file. |
--print_png | false | Emit printable PNG file. |
--print_dxf | false | Emit DXF target output. |
--print_dpi | 600.0 | Raster DPI for PNG export (also embedded in PNG metadata). |
--print_margin_mm | 0.0 | Extra white margin around the board in print outputs. |
--print_basename | target_print | Base filename for print outputs (without extension). |
Notes:
gen_target.pyalways writesboard_spec.json,<basename>.svg, and<basename>.pngtogether.- SVG is resolution-independent and preferred for professional printing.
- PNG size is derived from the board geometry, requested margin, and DPI.
gen_synth.pyalways writes a matchingboard_spec.jsonto--out_dir.
tools/gen_board_spec.py options
| Argument | Default | Description |
|---|---|---|
--pitch_mm | 8.0 | Marker spacing in mm. |
--rows | 15 | Number of hex lattice rows. |
--long_row_cols | 14 | Number of markers in long rows. |
--board_mm | 200.0 | Used for default board name when --name is omitted. |
--name | auto | Board name. Default: ringgrid_{board_mm}mm_hex. |
--marker_outer_radius_mm | pitch_mm * 0.6 | Outer ring radius. |
--marker_inner_radius_mm | pitch_mm * 0.4 | Inner ring radius. |
--marker_ring_width_mm | marker_outer_radius_mm * 0.24 | Full dark-ring width. |
--json_out | tools/board/board_spec.json | Output 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:
| Field | Type | Meaning |
|---|---|---|
schema | string | Must be ringgrid.target.v4. |
name | string | Human-readable board name. |
pitch_mm | float | Marker center spacing in mm. |
rows | int | Number of lattice rows. |
long_row_cols | int | Marker count for long rows. |
marker_outer_radius_mm | float | Outer ring radius in mm. |
marker_inner_radius_mm | float | Inner ring radius in mm (0 < inner < outer). |
marker_ring_width_mm | float | Full 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 >= 1long_row_cols >= 1(>= 2whenrows > 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_mmif your printer clips near page edges. - Archive the exact
board_spec.jsonthat was printed and use that same JSON during detection.
Related Chapters
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:
| Flag | Default | Description |
|---|---|---|
--pitch_mm <mm> | required | Marker center spacing in mm. |
--rows <n> | required | Number of hex lattice rows. |
--long_row_cols <n> | required | Number of markers in long rows. |
--marker_outer_radius_mm <mm> | required | Outer ring radius in mm. |
--marker_inner_radius_mm <mm> | required | Inner ring radius in mm. |
--name <string> | auto | Optional board name. Omitted uses deterministic geometry-derived naming. |
--out_dir <path> | tools/out/target | Output directory for board_spec.json, SVG, and PNG. |
--basename <string> | target_print | Base filename for SVG and PNG outputs. |
--dpi <f> | 300.0 | PNG raster DPI (also embedded in PNG metadata). |
--margin_mm <mm> | 0.0 | Extra white border around the board in SVG/PNG outputs. |
--no-scale-bar | false | Omit 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.jsontools/out/target_faststart/target_print.svgtools/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:
| Flag | Description |
|---|---|
--image <path> | Path to the input image file. |
--out <path> | Path to write detection results (JSON). |
Output and diagnostics:
| Flag | Default | Description |
|---|---|---|
--include-proposals | false | Add pass-1 proposal diagnostics to the output JSON as top-level proposal_frame, proposal_count, and proposals fields. |
Board target:
| Flag | Default | Description |
|---|---|---|
--target <path> | built-in board | Path to a board layout JSON file. When omitted, uses the built-in default 203-marker hex board. |
Marker scale:
| Flag | Default | Description |
|---|---|---|
--marker-diameter <px> | – | Fixed marker outer diameter in pixels (legacy mode). Overrides min/max range. |
--marker-diameter-min <px> | unset | Minimum marker outer diameter for scale search. |
--marker-diameter-max <px> | unset | Maximum 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:
| Flag | Default | Description |
|---|---|---|
--ransac-thresh-px <px> | 5.0 | Inlier threshold in pixels. |
--ransac-iters <n> | 2000 | Maximum iterations. |
--no-global-filter | false | Disable the global homography filter entirely. |
Homography-guided completion:
| Flag | Default | Description |
|---|---|---|
--no-complete | false | Disable completion (fitting at H-projected missing IDs). |
--complete-reproj-gate <px> | 3.0 | Reprojection error gate for accepting completed markers. |
--complete-min-conf <f> | 0.45 | Minimum fit confidence in [0, 1] for completed markers. |
--complete-roi-radius <px> | auto | ROI radius for edge sampling during completion. Defaults to a value derived from the nominal marker diameter. |
Center refinement (projective center):
| Flag | Default | Description |
|---|---|---|
--circle-refine-method <m> | projective-center | Center correction method: none or projective-center. |
--proj-center-max-shift-px <px> | auto | Maximum allowed correction shift. Defaults to a value derived from nominal marker diameter. |
--proj-center-max-residual <f> | 0.25 | Reject corrections with residual above this. |
--proj-center-min-eig-sep <f> | 1e-6 | Reject corrections with eigenvalue separation below this. |
Self-undistort:
| Flag | Default | Description |
|---|---|---|
--self-undistort | false | Estimate a 1-parameter division-model distortion from detected markers, then re-run detection. |
--self-undistort-lambda-min <f> | -8e-7 | Lambda search lower bound. |
--self-undistort-lambda-max <f> | 8e-7 | Lambda search upper bound. |
--self-undistort-min-markers <n> | 6 | Minimum 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>.
| Flag | Default | Description |
|---|---|---|
--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.0 | Radial distortion k1. |
--cam-k2 <f> | 0.0 | Radial distortion k2. |
--cam-p1 <f> | 0.0 | Tangential distortion p1. |
--cam-p2 <f> | 0.0 | Tangential distortion p2. |
--cam-k3 <f> | 0.0 | Radial 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_adaptiveDetector::detect_adaptive_with_hintDetector::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_markerscenter_framehomography_frameimage_size- optional
homography,ransac, andself_undistort
The CLI may add extra top-level fields:
camerawhen a camera model was suppliedproposal_frame,proposal_count, andproposalswhen--include-proposalsis 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