Migrating to 0.13.0
Workspace 0.13.0 gives the detector structs and the facade free functions a single, symmetric surface. This is a deliberate pre-1.0 breaking release.
Detection geometry, pixel-coordinate conventions and grid-label semantics are unchanged, and the API restructuring itself changes no results — the corner pass moved, it did not change.
Behaviour does change in one place: the PuzzleBoard decoder, in three deliberate ways, each described below.
| change | effect on which frames decode |
|---|---|
| Rotations, not reflections | more — an unreachable mirrored hypothesis can no longer force a rejection |
| Period-3 consensus | more on noisy frames; some very noisy ones are now refused explicitly instead of decoded on thin evidence |
| The window gates agree on their unit | more — fragments at exactly the documented minimum are no longer rejected unread |
No detector becomes more permissive about what it will claim. All three changes leave the uniqueness proof intact, and the second strengthens it.
Every Rust break in this release is a compile error at the call site. Nothing keeps compiling with a changed meaning.
Why
The three composite params structs each carried a chess: DetectorConfig
field that no detector ever read. Only the facade free functions ran the ChESS
corner pass, so a caller who wanted to configure corner detection and hold a
reusable detector had to abandon the detector API, run the corner pass by hand,
and feed the corners back in. Every binding crate hand-rolled that same glue.
Detectors can now run the corner pass themselves, from the field they already owned.
Detectors take an image
detect is now the whole pipeline from an image. The old corner-consuming
method is still there under an explicit name.
// before
let corners = calib_targets::detect::detect_corners(&img, ¶ms.chess);
let detector = PuzzleBoardDetector::new(params)?;
let detection = detector.detect(&gray_view(&img), &corners)?;
// after
let detector = PuzzleBoardDetector::new(params)?;
let detection = detector.detect(&view)?;
The corner-injection path is unchanged in behaviour, only in name — use it when one corner pass feeds several target detectors:
let corners = detector.detect_corners(&view);
let a = charuco_detector.detect_with_corners(&view, &corners)?;
let b = puzzle_detector.detect_with_corners(&view, &corners)?;
detect_corners on a detector runs exactly the pass that detector’s own
params.chess configures, so the corners you inject match the ones detect
would have produced. That identity holds by construction and is covered by a
test:
d.detect(image) == d.detect_with_corners(image, &d.detect_corners(image))
This applies to CharucoDetector, PuzzleBoardDetector and
MarkerBoardDetector.
ChessboardDetector is unchanged. It consumes a corner cloud by design —
ChessboardParams deliberately has no chess field, because that struct is
embedded inside all three composite params types and a nested corner-detector
config there would be dead in exactly the way this release removes. Configure
the corner pass for a chessboard through the explicit argument that
detect_chessboard(img, chess_cfg, params) already takes.
detect_with_diagnostics is now diagnose
// before
let (result, diag) = detector.detect_with_diagnostics(&view, &corners);
// after — from an image
let (result, diag) = detector.diagnose(&view);
// after — from corners you already have
let (result, diag) = detector.diagnose_with_corners(&view, &corners);
The _with_corners suffix now means one thing everywhere: you supply the
corners.
New facade free functions
Each of charuco, puzzleboard and marker_board now has the same five
entry points, and each is definitionally the detector one-liner:
detect_t(img, params)
detect_t_with_corners(img, corners, params)
diagnose_t(img, params) // `diagnostics` feature
diagnose_t_with_corners(img, corners, params) // `diagnostics` feature
detect_t_best(img, configs)
Arguments are data first, config last, matching the existing
detect_corners(img, cfg).
Two notes:
detect_puzzleboard_with_cornersexisted privately with the arguments in the order(img, params, corners). It is now public and takes(img, corners, params).- The
diagnose_*free functions return(Result<TDetection, DetectError>, Option<TDiagnostics>). TheOptionis not cosmetic: the facade has one failure mode the detector method does not, since it constructs the detector for you andTDetector::newcan reject bad parameters before the pipeline runs.Nonemeans exactly that.
Chessboard diagnostics are now reachable through the facade too —
trace_topological and trace_topological_detection are re-exported under the
diagnostics feature. Previously a Rust caller had to depend on
calib-targets-chessboard directly.
MarkerBoard reports why it failed
MarkerBoardDetector returned Option, which collapsed two very different
failures into one None. It now matches ChArUco and PuzzleBoard:
// before
let Some(detection) = detector.detect(&view, &corners) else { /* why? */ };
// after
match detector.detect(&view) {
Ok(detection) => { /* … */ }
Err(MarkerBoardDetectError::ChessboardNotDetected) => { /* no grid */ }
Err(MarkerBoardDetectError::AlignmentFailed { matched, candidates }) => {
// grid found; the three circle markers did not agree with the board
}
Err(_) => { /* the enum is #[non_exhaustive] */ }
}
The set of inputs that succeed is unchanged — only the failure value carries information now.
Two consequences:
diagnose/diagnose_with_cornersreturn diagnostics even when detection fails, as ChArUco and PuzzleBoard already did. On an alignment failure you get the scored circle candidates and the attempted matches, which is when you most want them.- The facade’s
detect_marker_boardnow returnsDetectError::MarkerBoardDetect(..)instead ofDetectError::NoDetection { target: CheckerboardMarker }. detect_marker_board_bestreports the last config’s ownMarkerBoardDetectErrortoo, matching whatdetect_charuco_bestanddetect_puzzleboard_bestalready did. It still yieldsNoDetectionin the one case where there is no typed error to report: no config got as far as attempting a detection, becauseconfigswas empty or every config was rejected byMarkerBoardDetector::new.
DetectError::NoDetection therefore remains, but now only for chessboard and
for that empty-sweep case. If you match on it to mean “the marker board was not
found”, match DetectError::MarkerBoardDetect(..) instead — and keep a
wildcard arm, since DetectError is #[non_exhaustive].
MarkerBoardDetector::detect_all deliberately does not exist. A marker board is
localised by its three circle markers, which realistically fall inside one
connected grid component; ChArUco and PuzzleBoard can be anchored from two
disjoint fragments and therefore consume every component. That difference is
intentional.
MarkerBoardParams::sweep_for_board (additive)
detect_marker_board_best existed with no preset to feed it. There is one now,
mirroring CharucoParams::sweep_for_board and
PuzzleBoardParams::sweep_for_board:
let configs = MarkerBoardParams::sweep_for_board(&spec);
let detection = detect_marker_board_best(&img, &configs)?;
It sweeps the shared grid-build axis only. No circle-scoring or matching constants are varied.
Bindings
Python and the WASM package:
| before | after |
|---|---|
detect_charuco_with_diagnostics | diagnose_charuco |
detect_marker_board_with_diagnostics | diagnose_marker_board |
detect_puzzleboard_with_diagnostics | diagnose_puzzleboard |
C ABI:
| before | after |
|---|---|
ct_charuco_detector_detect_diagnostics_json | ct_charuco_detector_diagnose_json |
ct_marker_board_detector_detect_diagnostics_json | ct_marker_board_detector_diagnose_json |
ct_puzzleboard_detector_detect_diagnostics_json | ct_puzzleboard_detector_diagnose_json |
The C ABI version goes to 4.0.0. Beyond those three symbol renames, two PuzzleBoard structs grow fields, so this is a struct-layout break as well as a link-time one:
ct_puzzleboard_decode_config_tgainssymmetry_mode(CT_PUZZLEBOARD_SYMMETRY_MODE_ROTATIONS/..._ROTATIONS_AND_REFLECTIONS).ct_puzzleboard_result_tgainslogical_bits,logical_bit_error_rateanddot_dissent_rate.
One semantic change: ct_marker_board_detector_diagnose_json now returns
CT_STATUS_OK with a well-formed payload on a failed detection, where it
previously returned CT_STATUS_NOT_FOUND. That matches the ChArUco and
PuzzleBoard diagnostics entry points, and it is the case where the evidence is
most worth having.
Recompile — do not relink — against the shipped
include/calib_targets_ffi.h.
Python: parameter defaults and sweep presets
Two Python-only fixes. Neither changes the Rust pipeline, and neither affects callers who set the fields explicitly.
Defaults now match Rust. The dataclasses in calib_targets.config mirror
the Rust params structs, and five of their literal defaults had drifted:
| field | was | now |
|---|---|---|
PuzzleBoardDecodeConfig.min_window | 4 | 7 |
ChessboardParams.min_corner_strength | 0.0 | 33.0 |
CircleScoreParams.min_contrast | 60.0 | 10.0 |
CharucoParams.min_marker_inliers | 3 | 1 |
ScanDecodeConfig.min_border_score | 0.45 | 0.75 |
The first two are precision defects, not preferences. min_window is the
bounded-distance uniqueness floor: at 4 the decoder accepted fragments far
below the size at which a decode can be proven unique, which is a latent
false-positive path, and a wrong absolute corner ID cannot be recovered
downstream. min_corner_strength is the floor that clears false corners
produced by marker bits — and since ChessboardParams is embedded in
CharucoParams and MarkerBoardParams, that default leaked into three
detectors.
Expect slightly lower recall and materially better precision on the affected paths. If you were relying on the old permissive values, set them explicitly and be aware of what they disable.
Sweep presets are Rust-computed. PuzzleBoardParams.sweep_for_board had
drifted onto a different axis from the Rust preset of the same name — it
varied the ChESS corner-detector threshold where Rust varies the grid-graph
angular tolerances. The config count is unchanged at six; the axis is now
Rust’s, so detect_puzzleboard_best searches the same space from either
language.
ChessboardParams.sweep_default() and CharucoParams.sweep_for_board(board)
had no Python surface at all and are now available:
configs = ct.CharucoParams.sweep_for_board(board)
detection = ct.detect_charuco_best(img, configs)
PuzzleBoard searches rotations, not reflections
This release also changes a default. A camera imaging the printed side of an opaque planar board can see it rotated by a multiple of 90°, but never mirrored — a rigid pose composed with a perspective projection preserves handedness. The decoder previously searched all eight dihedral relabellings, so half of every search was physically unreachable, and an unreachable hypothesis that happened to match the observed bits could still compete in the uniqueness gate and turn a correct decode into a rejection.
The default is now the four rotations. Decoding is substantially faster and clean-window uniqueness begins at a smaller fragment.
// restore the previous eight-transform search
params.decode.symmetry_mode = PuzzleBoardSymmetryMode::RotationsAndReflections;
Set that when the optical path flips handedness — a mirror or beam splitter in the path, or an image mirrored before detection. Under the default a mirrored view declines to decode rather than returning a wrong absolute labelling.
PuzzleBoard corrects errors before it gates
Both PuzzleBoard code maps repeat every three rows or columns. A fragment
sampling ~2w² dots therefore reads only ~6w distinct master bits, each of
them several times over. The decoder now reduces the dots to those bits by
confidence-weighted majority vote before the accept/reject gates run — the
error correction the pattern was designed around, and which the crate had not
been using.
Nothing to change at the call site. It is on by default and the reduction is pure topology: which dots share a bit depends only on the grid, not on where the fragment sits or how it is oriented, so it runs before any pose hypothesis exists.
Three things are visible:
A new error variant. PuzzleBoardDetectError::NotEnoughLogicalBits { determined, needed } fires when a fragment spans enough corners but too many
of its bit-classes split evenly to resolve. An even split is treated as an
erasure, not guessed. This is a new rejection path, and it is deliberately
strict: over a handful of surviving bits, margin > k_winner still proves the
winner is the only codeword within its error radius, but many master positions
are inside that radius, so the proof holds while saying nothing. Match it with
a wildcard arm — PuzzleBoardDetectError is #[non_exhaustive], so this is
additive, not a compile break.
Three new fields on PuzzleBoardDecodeInfo (also #[non_exhaustive], and
mirrored into ct_puzzleboard_result_t and the Python/WASM result shapes):
| field | what it tells you |
|---|---|
logical_bits | the fragment’s true code length. edges_observed / logical_bits is the redundancy the board gave you |
logical_bit_error_rate | error rate after voting — this is what max_bit_error_rate gates on now |
dot_dissent_rate | hypothesis-free read quality: computed before any pose is hypothesised, so it is meaningful even when the decode is wrong |
dot_dissent_rate is the one to reach for when a frame misbehaves. It rises
sharply when the grid labelling is wrong — which mixes dots reading different
master bits into one class — and stays near zero on a board that is merely
noisy. Read it as “clean” versus “not clean”; it is monotone in the dot error
rate but not calibrated to it.
The gates moved domain. max_bit_error_rate and the uniqueness gate are
now evaluated over voted logical bits rather than physical dots. At the minimum
window each logical bit is read twice, so one bad bit used to count as two
mismatches and both gates were over-conservative by construction. The
bit_error_rate field still reports the raw physical rate, so if you had
calibrated a threshold against it, compare against logical_bit_error_rate
instead.
If you need the old behaviour for an A/B, it is one flag:
let mut tuning = PuzzleBoardAdvancedTuning::new();
tuning.edge_consensus = false;
params.decode = params.decode.with_advanced(tuning);
The two window gates agreed on a number, not a unit
min_window is a span in corners, and the span gate read it that way — but
the edge-count pre-filter read it as a count of squares, demanding 84
interior edges where a 7-corner window yields 60. Fragments spanning exactly
the documented minimum were rejected before they were ever decoded.
The floor is now the interior edge count of the span the gate actually requires. This loosens a pre-filter, so slightly smaller fragments reach the decoder — but they still have to clear the uniqueness gate to be returned, and 60 edges is exactly 30 distinct bits, the exhaustively verified floor at which every master position is uniquely determined under the four rotations.
crates/calib-targets-puzzleboard/src/lib.rs documented the unit as “squares”;
that is corrected too.
PuzzleBoardSpec::master(cell_size) (additive)
Declaring the whole master pattern no longer means spelling out its size:
// before
let spec = PuzzleBoardSpec::new(501, 501, 1.0)?;
// after
let spec = PuzzleBoardSpec::master(1.0)?;
This is the right default for detection — a PuzzleBoard identifies itself, so
the detector does not need to know which sub-rectangle was printed. Declare the
actual printed board instead when you use
PuzzleBoardSearchMode::FixedBoard, which restricts the search to that
board’s extent; a declared 501×501 rectangle is the one case where FixedBoard
is slower than Full.
The constructor is mirrored on the Python dataclass as
PuzzleBoardSpec.master(cell_size).
Migrating to 0.12.0
Workspace 0.12.0 and projective-grid 0.13.0 consolidate integer grid
algebra around one affine GridTransform. This is a deliberate pre-1.0
breaking release. Detection geometry and pixel-coordinate conventions are
unchanged.
projective-grid imports
The crate root remains the ordinary detection facade. Reusable detector-
builder primitives live under expert; the removed root imports are not
restored.
use projective_grid::{Coord, LatticeKind};
use projective_grid::expert::lattice::{
predict_grid_position, GridTransform,
};
use projective_grid::expert::geometry::estimate_homography;
In particular, migrate these old imports:
use projective_grid::{Coord, predict_grid_position};
use projective_grid::{Coord, GridTransform};
use projective_grid::geometry::estimate_homography;
to the expert paths above. Coord and LatticeKind remain at the crate root.
One affine grid transform
projective-grid now owns the only implementation. It represents
destination = matrix * source + translation
for one lattice family. The matrix is row-major. Grid coordinates use u
right and v down; the transform contains no image or pixel coordinates.
Before:
use calib_targets_core::{GridAlignment, GridTransform};
let alignment = GridAlignment {
transform: GridTransform { a: 0, b: 1, c: -1, d: 0 },
translation: [10, 20],
};
let board = alignment.map(grid.u, grid.v);
After:
use projective_grid::{Coord, LatticeKind};
use projective_grid::expert::lattice::GridTransform;
let alignment = GridTransform::new(
LatticeKind::Square,
[[0, 1], [-1, 0]],
[10, 20],
);
let board = alignment.apply(Coord::new(grid.u, grid.v));
Use matrix(), translation(), and lattice() instead of public fields.
with_translation() turns a zero-translation D4/D6 symmetry into an affine
alignment. inverse() returns None unless the integer matrix is unimodular.
calib_targets_core::GridTransform is a re-export of this canonical type.
calib_targets_core::GridAlignment remains as a semantic type alias, so
existing result field types retain a descriptive name without another data
structure or implementation.
JSON, Python, and TypeScript
Alignment values no longer nest a four-scalar transform. The canonical shape
is shared with GridTransform:
{
"lattice": "square",
"matrix": [[0, 1], [-1, 0]],
"translation": [10, 20]
}
The old shape was:
{
"transform": { "a": 0, "b": 1, "c": -1, "d": 0 },
"translation": [10, 20]
}
Python’s GridAlignment and TypeScript’s GridAlignment are now aliases of
GridTransform. Update code that reads alignment.transform.a to read
alignment.matrix[0][0], and similarly for the other matrix elements.
C ABI
There is no C ABI migration. ct_grid_alignment_t keeps its existing matrix
and translation layout, and the FFI crate adapts the canonical Rust type at
the boundary. The FFI package version remains 3.0.0.
Compatibility helpers
calib_targets_core::square_predict_grid_position remains available, but is
now a thin square-lattice wrapper over
projective_grid::expert::lattice::predict_grid_position. D4 tables and
modulo-π angular helpers likewise have one implementation; detector-specific
ordering and naming are compatibility views only.