Skip to content

0.8.0 — 2026-09-25

← Back to the changelog index

Tests

  • EuRoC regression tests now use the shipped grid profile instead of Detector::new()'s hardcoded Rust Default. That default matches neither shipped profile (notably enable_sharpening: false, where standard ships true) and measurably underperforms on this dataset: 691 total detections for standard and 977 for high_accuracy vs. grid's 2299, at stride 10 across all 145 sampled frames. This isn't a new convention — regression_render_tag.rs's accuracy_baseline module already documents picking high_accuracy for clean, large synthetic renders and leaving regression_render_tag_robustness.rs on standard for small-tag/noisy content; grid extends the same reasoning one step further for real (not synthetic) sensor noise on a literal multi-tag AprilGrid board — its looser decoder.min_contrast/quad.min_edge_score suit the former, and segmentation.connectivity: Four (vs standard's Eight) avoids merging diagonally-adjacent tag components in the latter. Applied to all four EuRoC tests uniformly. Measured together with the select_dominant_vertices quad-extraction fix (see Fixed below — the two changes were validated jointly, not independently): relative recall 63.1% → 69.9%, distorted-pose recall 49.0% → 71.0%, board-consistency frames checked 50 → 78 (still 100% consistent).
  • euroc_detection_baseline now measures relative recall (decoded / present) instead of an absolute "≥15 tags" bar. cam_april is a calibration sweep recording — 62.8% of sampled frames have the board absent, far away, or off-frame by construction — so the old absolute bar conflated "board not in view" with "decoder missed tags that were there" and measured 27.6% recall dragged down almost entirely by the former. "Present" is now measured, not guessed: once ≥4 tags decode in a frame, a 2D affine map is fit (ordinary least squares, closed-form) from every corner of every decoded tag's known board-plane position to its observed pixel position, the full 6×6 grid layout is reprojected through it, and a tag counts as present iff its projected corners land inside the image bounds. (A full 6-DOF board-pose fit via BoardEstimator's LO-RANSAC was tried first and is more rigorous when it converges, but converges on under 20% of real frames here even with its gates loosened 25× — this affine fit can't fail to converge, at the cost of not modeling perspective, which is an acceptable tradeoff for the coarse "in frame or not" question it's answering.) Also added: a duplicate-decoded-ID-within-a-frame invariant, a periphery-vs-interior spatial recall check, and euroc_pinhole_funnel_diagnostic (#[ignore]d, run explicitly) which reports exactly where candidates are lost per pipeline stage (quad extraction → contrast gate → decode). Fixed scripts/fetch_euroc_calibration.sh, whose upstream host (robotics.ethz.ch) is permanently decommissioned; it now pulls the calibration bundle from ETH's Research Collection instead. Also added tools/viz_rerun_euroc.py, a standalone Rerun visualizer for EuRoC frames (the existing tools/cli.py visualize is hard-wired to the ICRA/hub dataset loader's ground-truth format, which EuRoC doesn't have), including a board-coverage overlay (green = decoded, yellow = predicted-present-but- missing, grey = predicted-out-of-frame) for exactly this recall metric.
  • scripts/fetch_euroc_calibration.sh now fetches cam_april from a private Hugging Face Hub mirror instead of ETH's Research Collection directly. The Research Collection host (research-collection.ethz.ch, ETH's replacement for the dead robotics.ethz.ch) works, but is frequently blocked by the outbound-host allowlists sandboxed CI/agent environments use, making the previous fetch unreliable exactly where it matters most. The cam_april sequence (~534 MB, ETH's own "In Copyright — Non-Commercial Use Permitted" license, https://rightsstatements.org/vocab/InC-NC/1.0/; full citation and source links in the dataset card) is now mirrored, private, at NoeFontana/euroc-mav-cam-april-mirror on the Hub and pulled via hf download. Output layout ($DEST/cam_april/mav0/...), idempotency (skip if already present) and the LOCUS_EUROC_DATASET_DIR consumer in crates/locus-core/tests/common/euroc.rs are unchanged; only the fetch path changed. Verified end-to-end: a fresh fetch into a scratch directory reproduces the expected mav0/cam0/data (1450 PNGs) / cam1/data (1449 PNGs) / imu0 layout, and a second run correctly no-ops.

Performance

  • Segmentation (LSL CCL) now scales with the rayon pool instead of running fully serial. simd_ccl_fusion::label_components_lsl was the one pipeline stage with no parallelism at all — 54 ms of a 76 ms 8-thread 4K frame, capping whole-frame 1→8-thread scaling at 1.69×. Three of its four sub-stages are now parallel: RLE extraction (count pass → prefix sum → row-parallel write into an arena slice), per-run root resolution (a read-only walk of the finished Union-Find forest), and the label-buffer fill (recursive row-band split via rayon::join). The row-pair Union-Find merge stays serial — its output depends on union order, and changing that would renumber components. On an AMD EPYC-Milan 8 vCPU box, RAYON_NUM_THREADS=8, --release: a textured 4K frame (1.72 M runs) drops 60.3 → 39.4 ms (−34.7 %) and a 4K ICRA frame 7.52 → 4.18 ms (−44.5 %); stage scaling 1→8 threads goes from 1.00× to 1.49×. The single-worker path is within ±2.5 % (−2.7 % at 4K, +0.7…+2.5 % on smaller frames), and keeps the one-pass extractor because the counting pass has nothing to amortise against there. Output is bit-exact vs the previous implementation — labels, component order and every component statistic — pinned by a differential test against a verbatim copy of the old algorithm over an adversarial frame zoo, run in both a 1-worker and a 4-worker pool.

Breaking

  • segmentation.margin removed from the profile format. The field had no reader anywhere in the workspace — its last consumer, the threshold-model CCL, was deleted in f79d04b — so it was accepted, validated, round-tripped and reported by Detector.config() while doing nothing. It is gone from the Rust DetectorConfig, the serde shim, the Pydantic model, schemas/profile.schema.json and the three shipped profiles. Because the profile shim uses deny_unknown_fields, a custom profile JSON that still carries segmentation.margin will now fail to load with a ValueError naming the key; delete the key. Detection output is unchanged (byte-identical).
  • Detector(threads=n) / DetectorBuilder::with_threads(n) now does what it says. Previously the value was stored and echoed back by config() but never read: the pipeline always ran on Rayon's global pool, so threads=1 silently used every core. It now builds one scoped rayon::ThreadPool of n workers at detector construction and runs detect / detect_concurrent under ThreadPool::install. threads=0 (the default) keeps the global pool and the previous behaviour exactly. Code that passed a small threads value and relied on the accidental full-core execution will now be slower by design — pass 0. Detection results are identical for every thread count.
  • DetectorConfig::default() / bare Detector::new() now actually match the standard profile they're documented to. docs/tutorials/guide.md has long documented Detector() (no args) as equivalent to Detector(profile="standard"), and docs/engineering/core.md states the shipped JSON is authoritative over any Rust/Pydantic constant — but the hand-written impl Default for DetectorConfig had drifted on three fields (threshold.enable_sharpening: false vs standard's true; quad.max_elongation: 0.0 vs 20.0; quad.min_density: 0.0 vs 0.15 — the latter two are gate-disabling sentinels, so the drift silently turned off both the elongation cap and the density floor for anyone constructing a bare config). The independent Python Pydantic model had the same three plus a fourth (quad.min_area: 16, the pre-fix placeholder standard.json moved off of; see the select_dominant_vertices entry below), inert in practice because locus.Detector() always resolves through DetectorConfig.from_profile(...) and never touches the bare Pydantic defaults — but a real trap for anyone constructing locus.DetectorConfig() directly. All four fields are now synced to standard.json on both sides, schemas/profile.schema.json regenerated to match. Two new value-level parity tests close the gap the existing field-set tripwire (schema_parity_tests::serde_shim_matches_referee_schema) didn't cover: config::schema_parity_tests::default_matches_standard_profile (Rust) and test_bare_default_matches_standard_profile (Python). Behavior change: any code path that bare-constructs a config (Detector::new(), Rust DetectorConfig::default(), or Python locus.DetectorConfig()) now detects with sharpening on and both quad geometry gates enabled — every shipped-profile regression suite (render-tag, ICRA, EuRoC, board/distortion hub) already specifies an explicit profile and is byte-identical; only bare-default callers are affected. contract_config_inertness's threshold_min_range case needed a new prerequisite (base_sharpening_off) — sharpening now saturates every tile's contrast on that test's tiny synthetic canvas, making the field coincidentally inert on that one scene regardless of its real (telemetry-only, see above) effect.

Fixed

  • threshold.min_range documented as telemetry-scoped. The tile-validity mask it drives is applied only when writing telemetry.binarized; the per-pixel threshold map that segmentation consumes is written unconditionally, and the propagation pass that would have made the knob affect detection has been commented out since 996e782. The field is kept (it is live for the debug map) and its Rust/Pydantic docs now say so instead of implying a detection effect.
  • quad.upscale_factor > 1 returned corners in the upscaled frame. The detection pipeline bound the upscale factor to an unused _effective_scale, so quad corners (and the covariances derived from them) stayed in upscaled pixels (e.g. ~2x the true coordinates at factor 2) and were then fed, still unscaled, to GWLF refinement, decoding and pose against the original image. Corners and covariances are now mapped back to original-image coordinates right after quad extraction and the funnel gate, using the centre-aware convention shared with decimation (x = (x_up + 0.5)/U - 0.5; covariance scaled by 1/U^2); decode, GWLF and pose sample the original image. ImageView::upscale_to now samples pixel centres ((x + 0.5)/U) so it is the exact inverse of that mapping (it previously shifted content by half an upscaled pixel). upscale_factor == 1 and all decimation paths are unchanged.
  • ContourRdp quad-corner extraction silently discarded correctly segmented, correctly sized tag candidates before they ever reached decode. extract_single_quad's Douglas-Peucker simplification used epsilon = perimeter * 0.02 — dimensionally wrong, since the perpendicular "staircase" deviation a rasterized angled edge needs absorbing is set by pixel-grid quantization and edge angle, not by the object's size on screen. That made epsilon too tight for small/moderate contours, which then failed a simplified.len() in [4, 11] vertex-count gate (root-caused on real EuRoC MAV imagery: one frame's segmentation found 40 tag-sized components, only 10 ever became quad candidates — 13 of the 30 lost ones failed exactly this gate, always with too many vertices). Replaced with select_dominant_vertices: an epsilon-free significance ranking (one full unconditional Douglas-Peucker decomposition, diameter-pair-seeded so the two anchor points are provably real corners rather than an arbitrary boundary-trace artifact) that hands a generous candidate pool to the existing, unchanged reduce_to_quad for final 4-corner selection — a single top-down "take the top 4" pass was tried first and cost ~19% relative recall on real EuRoC frames vs. pooling before reducing, so the final design keeps reduce_to_quad's iterative robustness and only replaces the epsilon it used to be gated by. Real-world: EuRoC relative recall (decoded/present) 63.1% → 69.9% (measured together with a real-camera/AprilGrid profile fix — see the regression_euroc.rs entry above — the two changes were validated together); distorted-pose recall 49.0% → 71.0%. Synthetic (regression_render_tag/_robustness, insta-snapshotted): broad improvement on the ContourRdp-path tests — mean/reprojection RMSE roughly halves and p99 rotation-error tail improves 4×–60× in 6 of 8 affected tests (e.g. high_iso p99 rotation 104.1° → 1.75°, raw_pipeline 119.5° → 26.0°), recall flat or better in 6 of 8. Two tradeoffs reviewed: tag16h5 precision drops 96.3% → 93.75% (one extra false positive out of ~100 images), and low_key_tuned's p99 rotation error worsens 21.6° → 99.3° on scene_0005_cam_0000.png tag 34 — root- caused, not a corner-extraction defect: that tag's corner RMSE is 2.43px (good), so this is a differential (non-uniform across the 4 corners) micro-perturbation landing on an already poorly-conditioned single-tag pose solve, not a wrong-branch or systematically-worse-corners issue. Confirmed against this project's own prior investigation (project_rotation_tail_is_corner_localization_20260714 in memory): IPPE branch selection has "zero headroom" (picks correctly essentially always), so the render-tag rotation tail is driven by differential corner-localization error, not solver branch choice — and the only documented lever for that class of tail is corner-refinement robustness (ERF/GWLF), a separate pipeline stage this PR doesn't touch. One isolated tag out of every tag across 8 affected tests showing this pattern, against broad improvement everywhere else, is consistent with an isolated hard-geometry case, not a systematic regression this fix introduced. .snap files are not updated by this change (pre-existing, unrelated snapshot drift affects 11 of 17 render-tag/robustness tests independent of this fix — see PR discussion). Latency on the ContourRdp path rose modestly (e.g. low_key 36→41ms, high_iso 45→56ms), expected given the unconditional decomposition does more work than the old epsilon-gated pass; not evaluated against a specific budget.
  • reduce_to_quad could discard a real corner for an adjacent staircase-rasterization pixel on an exact triangle-area tie. Caught by CI, not local validation: regression_icra2020::regression_fixtures (real 2448×2048 photo, 190 printed tag36h11 tags, tests/fixtures/icra2020/0037.png) regressed mean corner RMSE 0.1315px → 0.1375px. Root cause, isolated with a per-tag diff against main: only 2 of 154 matched tags moved (tags 1 and 20, both ~0.5–0.75px on one corner; the other 152 were bit-identical), and both share the same contour shape — a clean 4-corner rectangle plus a 1px anti-aliasing staircase notch immediately beside one true corner (7 contour points total, all ≤ POOL_CAP, so the whole contour reaches reduce_to_quad unfiltered). reduce_to_quad's smallest-triangle-area elimination has no concept of which vertex is "real": on this shape, removing the true corner and removing its neighboring 1px notch cost the exact same triangle area (12.0px² in both regressed cases, verified by hand), and the loop's area < min_area scan silently keeps whichever it reaches first — a coin flip that happened to discard the real corner both times. This was latent in reduce_to_quad itself (unchanged since before this PR) but only became reachable once select_dominant_vertices started pooling low-but-nonzero-significance points like the notch instead of an epsilon threshold filtering them out upstream; reduce_to_quad still has exactly one caller, so this is fixed at the source rather than papered over with a new epsilon. select_dominant_vertices already computes exactly the signal needed to break this tie — the notch's significance weight (~1.0) is over an order of magnitude below the real corner's (~17.0) — so reduce_to_quad now takes that weight array alongside the points and, only when the area criterion is at or within float-precision (1e-9 relative) of the current minimum, prefers to discard the lower-significance point instead of leaving it to iteration order. This is a numerical-precision tolerance, not a reintroduced geometric epsilon — it never changes which vertex has the strictly smaller area, only which one wins a real tie. Fixes both regressed tags exactly (RMSE 0.5085px/0.5083px → 0.0422px/0.0446px, matching main to 4 decimal places) and the fixture snapshot improves on main, 0.1315px → 0.1312px (154/154 tags now bit-identical or better). Re-validated against regression_render_tag/_robustness (both suites byte-identical to the pre-tie-break-fix numbers already documented above — this fix only changes behavior on exact/near-exact area ties, which those suites' contours don't happen to hit) and euroc_detection_baseline (unaffected). All 278 default-feature tests and the full --all-features suite pass; cargo fmt/cargo clippy --all-features -- -D warnings clean.

Added

  • Config-inertness contract test (crates/locus-core/tests/contract_config_inertness.rs). Mutates every DetectorConfig field on a deterministic synthetic frame set and requires the observable output — detections, rejected candidates, poses, covariances and the binarized / threshold_map telemetry images — to change. The field list comes from an exhaustive destructuring of the struct, so a new field fails the build until a case exists. Provably-inert fields are allowlisted with a written reason and are asserted to be identical, so the allowlist cannot silently rot in either direction. Also pins nthreads determinism (output invariant across 1/2/4/8 threads) and asserts the scoped pool is the one the pipeline actually executes on.