Migrating to 0.15.0
Breaking, and confined to the marker-board detector, its printable model, and
the bindings that mirror them. If you do not use calib-targets-marker or
MarkerBoardTargetSpec, nothing here applies and the upgrade is a version bump.
This is a minor rather than a patch for one reason: fields were removed from
public structs. #[non_exhaustive] stops a downstream crate constructing or
exhaustively destructuring them, but it never stopped reading a field, and
#[serde(deny_unknown_fields)] means a stored JSON config naming a removed
field now fails to deserialize rather than being ignored.
Why the marker board changed
A board rendered with inner_square_rel above ~0.3 could come back with the
frame 90 or 180 degrees rotated while the detector reported three clean circle
matches and no error (#96). Every corner then carried a wrong (i, j) label.
Two things had to be fixed — the circle scorer had no test that a candidate was
actually round, so the white inset drawn inside every black square scored as a
marker disk; and frame resolution ranked hypotheses by contrast rather than by
agreement, so two bright false candidates could outvote three real ones. The
CHANGELOG has the full account.
The printed disk diameter moved
CircleScoreParams::diameter_frac is removed. It stated the same physical
quantity as the printable spec’s circle_diameter_rel, and the two could
disagree: a board printed at 0.45 was probed at the default 0.5, putting
every radius the scorer uses 11% off. The board now says it once.
// Before
let params = MarkerBoardParams::for_board(board);
params.circle_score.diameter_frac = 0.45;
// After — on the board, which is what it describes.
let board = board.with_circle_diameter_rel(0.45);
let params = MarkerBoardParams::for_board(board);
In JSON (Python, WASM, stored configs), move circle_score.diameter_frac to
board.circle_diameter_rel. Leaving it in place is a deserialization error, not
a silent ignore. In C, ct_circle_score_params_t loses diameter_frac and
ct_marker_board_layout_t gains circle_diameter_rel.
Circle matching is a frame sweep, so its distance knob is gone
CircleMatchParams::max_distance_cells and CircleMatch::distance_cells are
removed, and CircleMatch::with_match drops its distance_cells argument.
Both encoded the same defunct idea: matching a circle by the distance between a
board cell index and a detected cell index, which presumes the translation
the alignment is supposed to establish. Resolution now enumerates every
(rotation, translation) and verifies it, so a match is an exact integer
coincidence and a distance could only ever have reported zero. Delete the field;
there is nothing to replace it with.
min_offset_inliers defaults to 3
Was 1. The three circles exist for exactly one reason — to break the board’s
4-fold rotational symmetry — and fewer than all three cannot do it: any single
circle is consistent with all four rotations, and two leave no redundancy to
check a wrong pairing against.
This costs nothing on a board where all three markers are visible. It costs recall on one where a marker is occluded or blown out, which used to return a frame that was unverified rather than correct. If you need the old behaviour, set it explicitly and understand that you are trading the “never a wrong label” half of the detection contract for recall:
params.match_params.min_offset_inliers = 2;
A new failure, and a new rejection
MarkerBoardDetectError::AlignmentAmbiguous { inliers, runner_up }— two board frames explain the detected circles equally well, so neither is returned. The enum is#[non_exhaustive]; code with a wildcard arm compiles unchanged and now sees a case it previously received as a confidently wrong answer.PrintableTargetError::InvisibleCircle { i, j }— a marker circle whose polarity matches the square underneath. The checkerboard’s colour phase is fixed (square(0, 0)is black), so a white disk is only visible on an eveni + jand a black one only on an odd one; anything else rendered nothing at all. A spec that previously “worked” this way was producing a board with fewer markers than it claimed.
Corrected without action on your part
MarkerBoardTargetSpec::default_circlesreturned three invisible circles on any board whose centre cell has odd parity — a 6x8 board among them, where it emitted the exact polarity inverse of every shipped fixture. It now anchors the L on an even-parity cell.MarkerBoardSpec::default()on the detector side carried the same contradiction and now matches. If you passed either through unchanged, your board starts working.- The inner-square inset is no longer painted under a marker circle. It is
drawn in the same white a disk needs, so it merged with one and — once
inner_square_relreachedcircle_diameter_rel— swallowed it. Regenerate any printed board that carries both. - A fully detected board no longer drops the id and
target_positionof its last row and column (13 of 48 corners on a 6x8). A circle layout names squares, so the resolved frame labelled corners1..=cols— outside the board’s own index range, with a labelled bounding-box minimum of(1, 1)rather than(0, 0). Labels are inner-corner indices now, andtarget_positionmeasures from the board’s outer corner, matching the printable spec’sresolved_points. If you stored marker-board corner ids or target positions from an earlier release, they are shifted by one cell in both axes relative to what 0.15.0 produces.
Diagnostics gained fields
CircleCandidate::squareness (what the new shape gate measured: 0 for a
disk, 0.48..0.62 for a square inset), and MarkerBoardDiagnostics gains
alignment_runner_up_inliers and alignment_ambiguous. Additive on an opt-in
surface with a documented looser stability promise.
C ABI 4.0.0 -> 5.0.0
Three struct-layout-breaking changes: ct_marker_board_layout_t gains
circle_diameter_rel, ct_circle_score_params_t loses diameter_frac, and
ct_circle_match_params_t loses max_distance_cells. Relink, do not just
recompile — a consumer built against 4.0.0 passes structs of the wrong size.
Migrating to 0.14.0
Additive on every published surface. No type is removed or renamed, the C ABI stays at 4.0.0, and detection results are unchanged for every board printed with the default one-cell marker border — which is every board the previous releases could describe.
There is exactly one behaviour change, below.
scan.border_bits is derived from the board
CharucoBoardSpec now carries border_bits, the width in marker cells of the
black ring printed around each marker payload (OpenCV’s borderBits). It is a
physical property of the printed target, so the board spec is where it lives,
and CharucoDetector::new now overwrites params.scan.border_bits with the
board’s value on every construction path.
If you were reaching past the board spec to describe a wide-ring board:
// Before — the only way to say "this board has a two-cell ring".
let mut params = CharucoParams::for_board(spec);
params.scan.border_bits = 2;
// After — say it on the board, which is what it describes.
let spec = spec.with_border_bits(2);
let params = CharucoParams::for_board(spec);
The old form now silently resolves back to the board’s value (1 unless you
set it), so it must be migrated rather than left in place.
Why unconditionally, rather than repairing an “unset” scan value: the scan
default is 1, which is indistinguishable from a caller who meant 1. A
conditional repair would therefore leave CharucoParams::for_board as the only
construction path whose scan config agreed with its own board — reassigning
params.board, or deserialising a config that names the board and omits scan
(the shape the Python bindings and any stored JSON config take), would keep
sampling the ring of whatever board the params were seeded from.
Two fields describing one physical fact is the actual defect; deriving one from the other removes it.
What this does not mean
The previous behaviour was not a decode failure. Measured on a synthetic
render, the board-level matcher aligns a border_bits = 2 — even = 3 — board
correctly while sampling it with a one-cell ring, because the inset sampling
grid lands inside the payload either way. What was genuinely wrong is everything
computed from the ring: the min_border_score gate scored cells that are not
the printed border, and diagnostics reported a ring the board does not have. The
tolerance is a coincidence of one inset value and should not be relied on.
New, no action required
CharucoBoardSpec::border_bits/with_border_bits, mirrored on the printableCharucoTargetSpecand the PythonCharucoBoardSpec. Defaults to 1;border_bits = 0is now rejected withCharucoBoardError::InvalidBorderBits(a marker with no ring cannot be told from the white square under it).render_target_bundle_json(doc)on the WASM surface takes a fullPrintableTargetDocument— page size, orientation, margin, and every spec field the fixed-arityrender_*_bundlehelpers do not name. Those helpers are unchanged; handing the new function the page one of them would have synthesised returns a byte-identical SVG.PrintableMarkerCircleSpecin the WASM TypeScript declarations. The printable model’s circle ({ i, j, polarity }) needs a name distinct from the detector’sMarkerCircleSpec({ cell, polarity }): two Rust crates may share a type name, one.d.tsmodule may not.
TypeScript-only corrections
Three declarations in typescript-extras.d.ts described something the Rust side
does not. Each is a narrowing of what type-checks, and in each case what stops
type-checking never worked at runtime:
MarkerLayoutno longer offers"bottom_left". The Rust enum has exactly one variant, so that value was always rejected at the boundary.CharucoBoardSpecgainsborder_bitsandmarker_layoutbecomes optional, matching the serde shape.- The printable
circlesfield is a 3-tuple rather than an array, matching Rust[MarkerCircleSpec; 3], which already rejected any other length at runtime.
If your code type-checked against the old declarations for a marker board, it
did not compile at all — the duplicate MarkerCircleSpec merged into an
interface requiring the fields of both shapes. Nothing to migrate; it starts
working.