0.9.0 — 2026-10-04
Real-camera recall, sub-pixel corners, and a reproducible comparison against OpenCV and
aruco_nano.
- Comparison method: the figures compare standard against v0.8.0 on the same data with
cargo xtask sota, using full datasets.
- Hardware: AMD EPYC-Milan KVM, 8 vCPU, AVX2, Linux 6.8, rustc 1.92, --release.
- Reports: EuRoC,
scoreboard.
Highlights
- Real cameras. On EuRoC
cam_april(Kalibr AprilGrid),standarddecodes 86.0 % of the in-view tags at 99.996 % precision. - Recall: v0.8.0 20.8 %; best reference OpenCV, 57.1 %.
- Leave-one-tag-out corner error: 0.283 px. v0.8.0: 0.912 px; OpenCV APRILTAG: 0.516 px.
- Corners. A sub-pixel corner stage,
decoder.corner_subpix: - fuses a junction estimate with the marker's whole-edge lines;
- repairs gross seed corners;
- measures and removes, on each decoded marker, the photometric inset that every gradient estimator has on gamma-encoded images.
Debiased corner error on render-tag drops from 0.44–0.58 px to 0.05–0.21 px; references score
0.22–0.24 px.
- Precision. A decoded marker must show its dark border ring, within a per-family error
budget. tag16h5 precision rises from 83 % to 100 %, and every benchmark is at 99.9 % or better.
- Hard light and lenses. Recall:
- low_key: 14 → 66 %;
- raw_pipeline: 60 → 96 %;
- Kannala–Brandt AprilGrid: 83 → 96 %;
- EuRoC: 21 → 86 %.
- Boards. The board pose models each frame's photometric inset. Board translation error on
render-tag boards falls 5–9×.
- New: the ArUcoMip36h12 tag family; cargo xtask data for pinned datasets; and
cargo xtask sota for pinned reference detectors and a win table.
- Faster. 1-thread latency of standard against v0.8.0, measured serially and back to back
on the same machine:
| Benchmark | v0.8.0 (ms) | v0.9.0 (ms) | Change |
|---|---|---|---|
| render-tag 1080p | 39.1 | 21.7 | −45 % |
| render-tag 4K | 151.0 | 85.0 | −44 % |
| ICRA forward | 96.9 | 54.9 | −43 % |
| AprilGrid board | 48.9 | 25.8 | −47 % |
| EuRoC | 7.6 | 7.4 | −2 % |
EuRoC now decodes four times as many tags in the same time. - Cost: - ICRA forward recall: 73.8 → 72.0 %. - ChArUco marker recall: 99.8 → 99.2 %.
Breaking changes
Default output. standard, grid and DetectorConfig::default() produce different
corners, poses and detection sets. high_accuracy changes only through the fixes listed under
Fixed.
- standard and grid decode first and refine only what decodes.
- They require the dark border ring, with a per-family budget.
- They run decoder.corner_subpix.
- standard also segments with 4-connectivity and disables the blob-shape quad gates
(min_fill_ratio, min_density, max_elongation = 0).
- Every profile rejects markers that the image frame cuts.
Profile JSON and Pydantic config:
| Change | Migration |
|---|---|
extends removed (never implemented) |
Delete it. |
threshold.min_radius, max_radius, gradient_threshold, min_range, constant removed |
Delete them. LocalMean subtracts clamp(round(noise_k·σ̂ₙ), 2, 20). |
threshold.noise_k must be > 0, default 4.0 |
Remove "noise_k": 0.0. |
threshold.local_mean_radius default 24 → 7 |
Set it if you relied on 24. |
quad.refine_before_decode removed |
Delete it. Decode-first applies to Static + Erf at native resolution. |
decoder.refinement_mode: "Gwlf" removed, with decoder.gwlf_transversal_alpha |
Use "Erf" or "None". |
pose.pose_consistency_min_decisive_ratio removed (fixed at 5.0) |
Delete it. |
Boolean quad.edlines_imbalance_gate rejected |
Use "Enabled" / "Disabled". |
AdaptivePpb requires decoder.refinement_mode: "None" |
Set it. |
pose.structure_tensor_radius: 0 rejected |
Use 1–8. |
decoder.max_border_error_rate added: None = per-family budget, 1.0 = off |
Set 1.0 to disable the ring check. |
Every key is optional and defaults to standard in both Rust and Python |
An omitted decoder.corner_subpix used to mean false in Rust; set it explicitly if you relied on that. |
Rust API (locus-core):
- DetectorConfig:
- Fields removed: threshold_min_radius, threshold_max_radius, threshold_gradient_threshold, threshold_min_range, adaptive_threshold_constant, pose_consistency_min_decisive_ratio, gwlf_transversal_alpha, quad_refine_before_decode.
- Fields added: threshold_mode, threshold_local_mean_radius, threshold_noise_k, decoder_max_border_error_rate, decoder_corner_subpix. Struct literals need them, or ..DetectorConfig::default().
- DetectorConfigBuilder and DetectorConfig::builder() removed. Use struct update syntax or from_profile(..).
- DetectorBuilder keeps only new, with_config, with_family, with_decimation, with_threads, with_max_concurrent_frames, build, validated_build and build_engine. Set config fields and pass them with with_config.
- DetectOptions / DetectOptionsBuilder removed. Families go to with_family / set_families; intrinsics and tag size go to detect.
- CornerRefinementMode::Gwlf removed; TagFamily::ArUcoMip36h12 added (the enum is exhaustive).
- ConfigError:
- InvalidPoseConsistencyMinDecisiveRatio removed.
- AdaptivePolicyStaticRefinement added.
- InvalidNoiseK and InvalidStructureTensorRadius now cover 0.
- DetectionBatch::corner_refined added; TelemetryPayload::{gwlf_fallback_count, gwlf_avg_delta} removed.
Python API (locus):
- DetectorBuilder is a pure-Python builder that validates through DetectorConfig.
- It keeps with_family, with_decimation, with_threads, with_max_concurrent_frames and build, and adds with_profile and with_config.
- Every per-knob setter is removed.
- build() returns locus.Detector.
- Removed: CornerRefinementMode.Gwlf, locus.DetectOptions, and PipelineTelemetry GWLF counters.
- Added: TagFamily.ArUcoMip36h12, locus.ThresholdMode, Detector(..., max_concurrent_frames=n).
Tooling:
- scripts/fetch_euroc_calibration.sh is removed; use cargo xtask data fetch euroc.
- sync_hub.py --repo-id is removed.
- Hub datasets are pinned to a revision.
- bench real uses the selected profile unless an option is given explicitly: --constant / --min-range are now --noise-k, and Gwlf is gone.
Migration
To reproduce v0.8.0's standard, start from standard and set:
{
"quad": { "min_fill_ratio": 0.10, "min_density": 0.15, "max_elongation": 20.0 },
"decoder": { "max_border_error_rate": 1.0, "corner_subpix": false },
"segmentation": { "connectivity": "Eight" }
}
You cannot switch decode-first ordering off for the Erf route.
The new corner stage and the frame-clipping rule apply to undistorted cameras. With a declared distortion model, the distortion-aware path still refines before decoding; its redesign is planned.
Added
decoder.corner_subpix: a sub-pixel corner stage for decoded markers. It is on instandardandgrid, and off inhigh_accuracy.- Junction estimate: the gradient-orthogonality (cornerSubPix) model. The window comes from the marker cell; among windows of 0.3, 0.5 and 0.75 cell (2–4 px), it keeps the largest one that is not significantly less certain. A junction check rejects captures by outside structure.
- Fusion: the junction is fused, by inverse covariance, with the intersection of the marker's two whole-edge lines. This is done only when they agree (χ²₂ 99 %) and all four corners qualify. X-junctions, such as AprilGrid connectors, keep the junction.
- Gross-corner repair: a corner the junction model rejects while both neighbours pass is re-placed where its two edges cross. The edges are fitted from the good neighbours, and the junction model must confirm the result.
- Photometric calibration (
marker_inset.rs):- Why: cameras blur in linear light and then apply a tone curve, so every gradient estimator finds edges shifted toward the dark side. Corners sit 0.4–0.8 px inside the printed ones, OpenCV and aruco_nano included. On a single tag that becomes depth and rotation error.
- Method: a robust per-marker fit to the decoded bit boundaries separates the inset from the layout scale, the per-side shrink and a lens bow (kept only when an F-test finds it). It needs no camera or tone-curve model.
- Skipped: markers with cells under about 3.3 px, and declared lens distortion.
- Dark border-ring evidence (
decoder.max_border_error_rate). A decoded candidate must show the one-cell black ring around its payload. - Budget: by default it is the codeword's own error density,
max_hamming / bit_count: 1 cell of 28 for tag36h11, 0 for tag16h5. - Decode-first ordering. Candidates decode from their contour corners. Only decoded candidates and near misses are refined, and a match stands only if the refined quad decodes the same id within budget.
- Near-miss recovery budgeted by probability. Recovery runs within each family's recovery window, the Hamming distance random bits reach with probability ≤ 0.1. It also requires the seed to show a dark ring.
- Board inset.
BoardEstimatorestimates a per-frame photometric edge offset for each corner estimator, kept when significant.DetectionBatch::corner_refinedrecords which estimator placed each corner. - Opt-in
threshold.mode = "LocalMean". A per-pixel local mean minus a noise-calibrated offset: radius 7,noise_k4, using an allocation-free Immerkær noise estimate. No shipped profile uses it yet. ArUcoMip36h12(cv2.aruco.DICT_ARUCO_MIP_36h12) tag family.
Changed
standardsegments dark regions with 4-connectivity and judges candidates by marker evidence, not blob shape.- Connectivity: 8-connectivity links squares that touch at a corner (AprilGrid connectors) and fuses the whole board into one component.
- Blob gates: they reject hollow-ring and merged markers that decode.
- Cost: markers of about 2 px per cell, whose one-pixel ring connects only diagonally.
- Codebook search is one branch-free pass, dispatched at runtime to popcnt/AVX2/NEON. Results are identical.
Fixed
- Gamma inset: corners on gamma-encoded images sat 0.4–0.8 px inside the printed outline (see Added).
- Frame-clipped markers: markers cut by the frame were reported with a clipped corner. A marker is now rejected when a corner lies outside the image, or a side's middle lies within 3 px of the border. A corner may still touch the border.
- Multi-family decoding:
- Decoding kept the last matching family instead of the one at the lowest Hamming distance.
- The corner stage now reads the matched family's layout.
- Resampling maps: they were off by 1 px at
decimation = 2and by 0.25 px atupscale_factor = 2.decimate_tonow area-averages, andupscale_torounds. - Smaller fixes:
- The gradient sampler's border fallback was 0.5 px off.
- The AVX2 branch of the ERF gradient projection missed the pixel-centre offset.
- Unchecked sampler: the marker calibration's unchecked sampler needed the homography depth to keep one sign. Strongly foreshortened quads now take the checked sampler.
- Stale corner-class bits: the distortion-aware decoder left corner-class bits from earlier frames.
- More than eight families registered through
set_familiespanicked. - Custom profile defaults: a Rust custom profile that omitted
decoder.corner_subpixgotfalse; Python gottrue.
Performance
Net 1-thread latency against v0.8.0 is under Highlights (−2 to −47 %). The output-identical
speed-ups behind it:
- the cached and band-restricted ERF scans;
- one edge fit per quad side;
- a shared exp(−s²);
- one decode-first seed refinement per candidate;
- tracing without the full-frame label image (built only for EdLines);
- contour pre-rejects (Pick's-theorem decodability floor, compactness);
- runtime-dispatched codebook search.
Tooling and benchmarks
cargo xtask dataprovisions every dataset (Hub suites, ICRA 2020, EuRoC, Liu4K) from one pinned manifest:list,fetchandverify.cargo xtask sotabuilds pinned, unpatched references: OpenCV 4.10 with the NONE, SUBPIX and APRILTAG refiners, aruco_nano961b18b, and AprilTag 3.- It runs all detectors under one timing protocol, scores 17 benchmarks, and writes a win table against the best reference operating point.
- Corner cells are judged on debiased error, with each detector's photometric offset reported separately.
- EuRoC scoring:
- It now judges only within the published lens model's valid radius.
- It measures error in image pixels.
- It pools its reference only from self-consistent detectors.
- Distortion benchmarks:
hub-aprilgrid-bcandhub-aprilgrid-kb.
Documentation
- Updated for the current pipeline: pipeline, algorithm, architecture, memory-model, guide, FFI-contract and profile docs.
- New benchmark reports and lessons under
docs/engineering/, linked from benchmarking.