Skip to content

0.5.0 — 2026-05-20

← Back to the changelog index

BREAKING CHANGES

Detector configuration has been unified around JSON profiles. Four overlapping config paths have been collapsed into one; migration is documented in docs/migration/config-refactor.md.

  • Removed DetectorPreset enum; pass profile="standard" | "grid" | "high_accuracy" as a string.
  • Removed flat detector kwargs on Detector.__init__ (threshold_tile_size, quad_min_edge_score, refinement_mode, etc.). Use locus.DetectorConfig.from_profile(...) and edit the nested model.
  • Removed Detector.standard_config(); use locus.DetectorConfig.from_profile("standard").
  • Removed the **kwargs escape hatch on Detector.__init__. Unknown kwargs now raise TypeError.
  • Removed duck-typed integer conversion for typed PyO3 enums at the FFI boundary. Pass the real enum instance, or a string variant name when editing a JSON-shaped dict.
  • Removed Grid + 8-connectivity UserWarning branch. The grid profile pins 4-connectivity by construction.
  • Removed locus._profile module. DetectorConfig lives in locus._config and is re-exported from the package root.
  • Removed Rust DetectorConfig::standard_default() / grid_default() / high_accuracy_default(). Use DetectorConfig::from_profile("standard" | "grid" | "high_accuracy") or DetectorConfig::from_profile_json(&str).
  • Removed Rust ConfigPreset test enum. The regression harness now takes .with_profile(&str) / .with_profile_json(&str).
  • Removed Soft-decision decoding entirely. The DecodeMode enum (Rust locus_core::config::DecodeMode and Python locus.DecodeMode), the decode_mode field on DetectorConfig (Rust + Pydantic), the decode_mode key in shipped JSON profiles, the with_decode_mode() builder/setter, the EdLines + Soft cross-group validation rule, the EdLinesIncompatibleWithSoftDecode Rust error variant, the regression_icra_forward_synthetic test + tests/fixtures/icra_synthetic.json fixture, and the soft_decoding_bench.rs benchmark are all gone. Hard-decision Hamming decoding is now the only path.
  • Removed CornerRefinementMode::Edge (the gradient-peak fit_edge_line-only refiner). Empirically dominated: no shipped profile referenced it, and the variant only surfaced in production via the wasted-work path on Gwlf quads (now fixed). Migration: switch to Erf (the PSF Gauss-Newton fit; Edge's gradient-peak fallback is still its internal failure path) or None (the EdLines-native sub-pixel corners). PyO3 enum discriminants shift to dense {None=0, Erf=1, Gwlf=2} — Rust enum order matches.
  • Removed PoseEstimationMode (the Fast / Accurate toggle). The pose estimator now selects its cost surface automatically from per-corner covariance availability: image view (or external GWLF covariances) routes to weighted Mahalanobis LM with a 6×6 covariance output; no image and no covariances routes to unweighted Huber LM with no covariance. Fast mode was latency-neutral on high_accuracy, ≤ 13 % faster on standard, and 5–6× worse pose RMSE — a default-value artifact, not an architectural feature.
  • Migration: drop the pose_estimation_mode= argument from locus.Detector.detect() / detect_concurrent() calls and the tools/cli.py --pose-mode flag.
  • Removed symbols: PoseEstimationMode enum (Rust + Python), the pose_estimation_mode field on DetectOptions (Rust + Pydantic) and DetectOptionsBuilder, the pose_mode / mode parameter on Detector::detect, Detector::detect_concurrent, LocusEngine::detect_with_context, LocusEngine::detect_concurrent, estimate_tag_pose, estimate_tag_pose_with_config, refine_poses_soa, refine_poses_soa_with_config, and the bench-internals helpers (bench_estimate_tag_pose, bench_estimate_both_branches, bench_refit_pose_drop_corner). The bench_pose_fast divan bench is deleted along with the regression_hub_fast_tag36h11_1080p test and its *_fast.snap snapshot.

Added

  • Shipped JSON profiles (crates/locus-core/profiles/*.json) — single source of truth for Rust and Python. Embedded into Rust via include_str! and re-exposed to the Python wheel through the _shipped_profile_json FFI hook so both readers see byte-identical input.
  • schemas/profile.schema.json — JSON Schema exported from the Pydantic model; CI diffs it against the runtime-generated schema to catch drift.
  • locus._config.DetectorConfig — nested Pydantic model with cross-group invariant checks.
  • DetectorConfig::from_profile_json(&str) on the Rust side for user-supplied profiles.
  • docs/engineering/profiles.md — rationale for the refactor.
  • docs/migration/config-refactor.md — before/after table for every removal.
  • Pose-consistency gate (pose.pose_consistency_fpr, default 0.0 = disabled): final reprojection-consistency check inside estimate_tag_pose_with_config using Mahalanobis distance with χ²-derived critical values. Aggregate gate at χ²(2; fpr); per-corner gate at χ²(1; fpr). Rejected poses surface as Detection.pose = None. Only high_accuracy and render_tag_hub opt in (both at 1.0e-3); standard, grid, and general profiles are byte-identical. Mahalanobis-aware IPPE branch selection swaps to the alternate branch when its aggregate d² is at least 2× better AND statistically defensible — gated on fpr > 0 so disabled profiles preserve the legacy ideal-corner Euclidean comparison.
  • bench-internals SoA telemetry columns: pose_consistency_d2, pose_consistency_d2_max_corner, ippe_branch_d2_ratio. NaN sentinel when the gate did not run.
  • crates/locus-core/tests/regression_pose_consistency_roc.rs — synthetic-isotropic ROC sweep with a hard realized FPR ∈ [1e-4, 1e-2] acceptance gate at the production fpr = 1e-3. Snapshot lives at regression_pose_consistency_roc__pose_consistency_roc__synthetic_isotropic.snap.
  • docs/engineering/track2_precision_threshold.md — calibration rationale, ROC harness usage, and the empirical-fallback procedure if the χ²(2) tail breaks on a future codebase change.
  • docs/engineering/icra_forward_high_accuracy_diagnostic_20260426.md — re-uses the icra_forward_diagnostic test from the PR-#205 truncation work to attribute the high_accuracy recall collapse on ICRA forward frames 0..5 to the EdLines extraction stage (not the funnel), tabulates a knob-flip experiment matrix, and lays out the SOTA path forward (adaptive per-candidate extraction).
  • Adaptive PPB quad-extraction routing (opt-in): new quad.extraction_policy enum on DetectorConfig with two variants — Static (default; preserves byte-identity for every shipped profile) and AdaptivePpb { threshold, low_extraction, high_extraction, low_refinement, high_refinement }. Each candidate quad's pixels-per-bit estimate (bbox_short / min_outer_dim across registered families) is compared against threshold (strict < tie-break for snapshot stability) and routed to the low or high extractor + refinement pair. Validators reject degenerate (low == high) policies, thresholds outside (1.0, 5.0), and the per-route EdLines + Erf incompatibility.
  • max_recall_adaptive shipped profile: opt-in routing of ContourRdp + Erf (PPB < 2.5) versus EdLines + None (PPB ≥ 2.5). Lifts ICRA forward recall from 0.4631 (high_accuracy) to 0.7380 while keeping render-tag p99 rotation under the 1.897° budget at every shipped resolution. EdLines under distortion is rejected by the upstream guard, so the profile is only well-defined for pinhole / rectified inputs (matches general and render_tag_hub).
  • bench-internals SoA telemetry columns: routed_to (u8; 0 = low, 1 = high, 255 = Static) and ppb_estimate (f32; 0.0 under Static).
  • docs/engineering/max_recall_adaptive_calibration_20260426.md — records the empirical calibration sweep that landed on (threshold = 2.5, sharpening = on), the negative-result variants, the intrinsic source of the ~0.6 px render-tag RMSE versus high_accuracy's ~0.2 px, and the deferred Phase B follow-ups (post-decode corner re-refinement, image pyramid, per-route sharpening) that would lift adaptive to a strict improvement.
  • Phase C.5 — post-decode corner re-refinement (decoder.post_decode_refinement, default false): for each Valid candidate, fits the four outer tag edges with the shared ErfEdgeFitter (PSF step model), intersects adjacent edges to recover four sub-pixel corners, and re-solves the homography. Only max_recall_adaptive opts in; every other shipped profile is byte-identical. Drops the profile's ICRA-forward mean RMSE from 0.2871 → 0.2724 (−5.1 %) with recall preserved at 0.7380, and improves render-tag mean RMSE by 0.3–0.7 % across the full 640 / 720 / 1080 / 2160 pyramid with p99 rotation flat (within sub-1 % noise). Phase A's GWLF / structure-tensor covariance is intentionally preserved — the line fit's Cramér-Rao bound is too tight for synthetic-PSF imagery and would over-trust the refit in the weighted pose solver. Two new SoA-contract tests (contract_phase_c5_refit_valid_corners_disabled_is_noop, contract_phase_c5_refit_valid_corners_active_writes) lock the corners + homographies write set.
  • docs/engineering/post_decode_refinement_20260426.md — algorithm rationale (why edge-fit-then-intersect over per-corner saddle), failure-mode constants, the covariance-preservation argument, before/after numbers across all 5 datasets, and the byte-revert path for A/B comparison.
  • EdLines Phase-3 erf-step sub-pixel finder (quad.edlines_phase3_erf, default Disabled): opt-in replacement for the 3-point parabolic vertex inside the EdLines Phase-3 micro-ray refinement. Fits a PSF-blurred step model I(k) = (A+B)/2 + (B-A)/2 · erf((k-μ)/σ) to the 5 intensity samples per probed pixel via separable nonlinear LS (alternating linear (A,B) solve and one Gauss-Newton step on (μ,σ)). Adapts σ per ray and removes the directional bias that the parabolic clamp introduced when the true edge offset exceeded ±1.5 px. Per-ray fallback to the parabolic vertex on low contrast (Δ < 4 intensity units), singular GN, or μ runaway (|μ| > 2) preserves sample density. Under the new Accurate pose-mode default on high_accuracy + EdLines, the trade-off vs the parabolic default measured on the hub corpus is: trans_rmse 0.0047 → 0.0129 m (single-tag, regression), rot_p99 0.77 → 0.37 ° (single-tag, −52%), rot_p99 40.57 → 1.19 ° (aprilgrid_golden multi-tag, −97%), multi-tag pose-flips 32 → 5 (−84%). Latency cost is +3 % at 1 tag/frame and +24 % at ~29 tags/frame, gated by max_iters = 6 per probe. Tail-stability vs accuracy trade-off — no shipped profile flips the default; opt in per-deployment when rot p99 / pose-flip count is the load-bearing metric. JSON / Python accepts boolean true/false with a deprecation warning; canonical form is "Enabled" / "Disabled".
  • 5 new unit tests on fit_erf_step_lm (centred edge, sub-pixel offset sweep, low-contrast rejection, robustness to initial guess, variable σ).

Changed

  • high_accuracy shipped profile tightens decoder.max_hamming_error to 1 (previously inherited the AprilTag36h11 family default of 2). On the 1080p render-tag corpus (locus_v1_tag36h11_1920x1080), the family default admitted one decode at hamming = 2 (scene_0026, decoded id 89 for true tag 543) — the entire 1.96 % precision gap vs AprilTag-C on the SOTA detector matrix. Every true-positive across all four shipped render-tag resolutions (640 / 720 / 1080 / 2160) decodes at hamming = 0, so tightening the budget leaves recall at 100 % on the full hub render-tag corpus and at every regression suite (board, distortion, ICRA, robustness — zero snapshot drift). Locus high_accuracy now ties AprilTag-C and OpenCV on precision (all 100 %) while keeping every translation / latency / rotation-p99 lead documented in docs/engineering/benchmarking/render_tag_sota_20260425.md §1. Same rationale as the prior 16h5 default-tightening (decoder.rs:1562-1566).
  • high_accuracy shipped profile now opts into the AdaptivePpb router with the same routing pair as max_recall_adaptive (ContourRdp + Erf for PPB < 2.5; EdLines + None for PPB ≥ 2.5). All other knobs are unchanged (enable_sharpening: false, refinement_mode: None, pose_consistency_fpr: 1.0e-3). Render-tag tag36h11 snapshots are byte-identical at 640 / 720 / 1080 / 2160 because high-PPB tags continue to route through the original EdLines + None path. ICRA forward mean_recall lifts 0.4631 → 0.6053 (+14.2 pt) and mean_rmse improves 0.7535 → 0.5572 (−0.20 px) by recovering far-field tags that the EdLines extractor was rejecting — they now route through the ContourRdp + Erf branch instead. Resolves path (1) of docs/engineering/icra_forward_high_accuracy_diagnostic_20260426.md.

Fixed

  • Quad-extraction pre-filter truncation: dropped the pre-filter pixel_count truncation block introduced in 5a2f438 that capped component_stats at MAX_CANDIDATES before per-component geometric filtering. On dense small-tag scenes the cap discarded tag-sized candidates in favour of large background blobs that the geometric gates would have rejected anyway. The pixel_count-descending iteration order from 5a2f438 is preserved verbatim — that ordering is load-bearing for the dedup funnel. Survivor truncation now happens caller-side after extract_single_quad has filtered geometrically. Trade-off: distortion suite lifts to its true ceiling — Brown–Conrady recall 0.870 → 0.935 (+6.5 pp), Kannala–Brandt 0.809 → 0.813; ICRA forward variants regress by 2-3 pp (e.g. standard 0.7517 → 0.7236) because the post-filter survivor set on dense crude-render scenes feeds a different dedup state than the pre-filter set did. Render-tag SOTA snapshots are byte-identical to bless commit 4998607. See docs/engineering/benchmarking/quad_truncation_fix_20260426.md (superseded — consolidated into docs/engineering/lessons/ in v0.7.1).
  • ICRA test silent-skip: tests/common::resolve_dataset_root previously fell through to a stub fixture (tests/fixtures/icra2020/) when LOCUS_ICRA_DATASET_DIR was set to a relative path that didn't resolve relative to the test CWD; the macro's if let Some(provider) = IcraProvider::new(...) then short-circuited without ever asserting on the snapshot. PR #205's pre-merge "16 passed" was a false positive that hid the ICRA delta above. The resolver now treats the env var as authoritative — relative paths resolve against the workspace root (matching resolve_hub_root), and a non-directory value panics rather than falling back. IcraProvider::new likewise refuses to silent-skip when the env var is set.
  • Packaging: relocated the shipped JSON profiles from crates/locus-py/locus/profiles/ to crates/locus-core/profiles/ so cargo package -p locus-core no longer fails with "couldn't read src/../../locus-py/locus/profiles/standard.json: No such file or directory". Python now queries the embedded bytes through a new _shipped_profile_json FFI hook instead of importlib.resources, which means the wheel ships zero profile data files of its own — deleting one is impossible by construction.

Internal

  • Telemetry: added a static #[tracing::instrument(skip_all, name = "pipeline::segmentation")] annotation on simd_ccl_fusion::label_components_lsl, closing the Segmentation gap left by the 2026-05-03 2160p latency audit (tools/bench/aggregate_telemetry.py previously reported uninstrumented (folded into Quad Extraction) for that stage). Span name follows the pipeline::* convention used by the other five stages. Quad Extraction p95 at 2160p moved by <0.01 ms between V2's audit and this branch — well inside the ±5 % observability budget. Numbers (50 frames, high_accuracy + Accurate, AMD EPYC-Milan KVM): segmentation p50 = 18.30 ms, p95 = 45.17 ms, p99 = 103.53 ms at 2160p; p50 = 4.72 ms, p95 = 10.00 ms at 1080p. The 2160p ↔ 1080p multiplier is ~3.9–4.5× across percentiles, matching the O(N) expectation for a 4× pixel-count step. Full record: docs/engineering/latency_audit_2160p_segmentation_addendum_2026-05-03.md.
  • Type checker: migrated from mypy to basedpyright (PEP 561 stubs unchanged). [tool.mypy] and the five [[tool.mypy.overrides]] blocks in pyproject.toml are replaced by a single [tool.pyright] block with selective strictness (reportUnnecessaryTypeIgnoreComment, reportUnusedImport). Type-checking is now also enforced in CI via a new type-check-python job; previously the gate was local-only. The 25 # type: ignore[…] comments across the repo are rewritten to # pyright: ignore[…] per the standard mapping. As a side effect, the migration surfaced and fixed several real type bugs: tools/cli.py previously called rr.* even when the --no-rerun flag was passed (latent NameError when rerun-sdk was not installed) — visualize now requires Rerun and exits early with a clear hint when missing; tools/viz_rerun_hub.py had latent None-subscript bugs in the manifest/intrinsics path; tools/bench/sync_hub.py was importing EntryNotFoundError from the deprecated huggingface_hub.utils location (now huggingface_hub.errors); tools/bench/plots/pareto.py was using the private plt.Line2D re-export instead of matplotlib.lines.Line2D.
  • Soft-decode removal: deleted SoftStrategy, SoftCode, and the multiversion-dispatched LLR distance kernels. With only one strategy left, the DecodingStrategy trait collapsed into a single free function strategy::bits_from_intensities; decoder.rs's decode_batch_soa_generic, decode_batch_soa_with_camera_inner, decode_candidate_distorted, sample_grid_generic, and sample_grid_soa_precomputed lost their <S: DecodingStrategy> type parameter and S::Code associated type, calling decoder.decode_full(code, …) directly. The dead TagDecoder::for_each_candidate_within_hamming trait method (its only consumer was Soft) and its five family impls plus the underlying TagDictionary::for_each_candidate_within_hamming helper are deleted; the production-path TagDictionary::decode continues to use the MIH index. Also dropped the with_decode_mode test harness on RegressionHarness and the tests/common::hub helper, and an orphan regression_icra2020__icra_forward_synthetic.snap snapshot. CLI's tools/cli.py collapses the former Locus (Hard) / Locus (Soft) side-by-side wrappers into a single Locus entry. Documentation references in README.md, docs/tutorials/guide.md, docs/reference/api.md, docs/explanation/pipeline.md, docs/explanation/algorithms.md, and docs/engineering/ffi_contracts.md are updated; docs/engineering/benchmarking/lessons.md keeps the historical Soft-vs-Hard analysis as a record-of-decisions.
  • Pre-release code cleanup: deleted orphan crates/locus-core/src/homography.rs (never declared in lib.rs; production Homography lives in decoder.rs), dropped unused img_height parameter from extract_boundary_segments, removed dead sample_grid_generic / sample_grid_soa helpers, removed unused DecodingStrategy::distance trait method, gated test/bench-only helpers behind cfg(any(test, feature = "bench-internals")) (or cfg(feature = "bench-internals") for items with no in-crate test consumers), tightened unsafe SAFETY comments across filter.rs, threshold.rs, quad.rs, and locus-py to cite concrete invariants (rayon disjointness, multiversion runtime checks, pyo3-numpy contracts), removed redundant inner cfg(feature = "bench-internals") blocks from tests already gated by required-features in Cargo.toml, and replaced a stream-of-consciousness comment in threshold.rs with a one-line invariant statement.
  • Pre-release docs hygiene: untracked the leaked .claude/settings.local.json and added it to .gitignore; stripped an absolute developer path from docs/engineering/erf-simd-attempt-postmortem.md; renamed the legacy *_sota regression test functions and their snapshot files to align with the public preset taxonomy (*_highaccuracy for the EdLines + GN + None metrology preset, *_standard for the default ContourRdp + Hard preset, *_grid for the 4-connectivity profile), deleted four stale duplicate hub snapshots at tests/snapshots/regression_render_tag__hub_*_sota.snap (the live copies live under tests/common/snapshots/...), reconciled sota_metrology_20260321.md with the v0.3.1 shipped JSON profiles (Standard and Grid both ship decode_mode: Hard since PR #172 — the historical Soft-decode figures are now flagged as such), updated release_performance_20260322.md's Hub table to use the current preset name (HighAccuracy rather than SOTA), and removed a dangling regression_hub_tag36h11_*_sota_pure_tags TODO sentence that had outlived its context.