Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Hex Lattice Layout

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

Lattice parameters

The hex lattice is fully defined by three parameters:

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

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

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

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

Axial coordinate system

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

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

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

Cartesian conversion

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

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

In Rust, this is implemented as:

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

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

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

Nearest-neighbor distance

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

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

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

The BoardLayout type

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

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

Key methods:

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

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

The BoardMarker type

Each marker on the board is represented by:

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

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

JSON schema

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

Example JSON for the default board:

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

Schema fields:

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

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

Validation rules

The BoardLayout loader validates several geometric constraints:

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

Board generation

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

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

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