Skip to content

0.10.0 — 2026-10-13

← Back to the changelog index

Recall on real and hard imagery, one distortion pipeline at half the latency, and a pose layer on helicoid's SE(3) with the conventions of the libraries it feeds. - Comparison method: each figure is the measurement of the change that produced it, against the main of the time, on the stated data. No single v0.9.0-against-v0.10.0 run was made for this release; the figures compose, but are not one end-to-end measurement. - Hardware: AMD EPYC-Milan KVM, 8 vCPU (4 cores / 8 threads), AVX2, Linux 6.8, rustc 1.92, --release; latency serialised, RAYON_NUM_THREADS=1 unless stated.

Highlights

  • Recall where separations close. The tile thresholder's dark/bright cut moves from the midpoint of the tile extremes to 179/400 of the range, which holds open the bright gaps that blur and obliquity close between neighbouring markers (standard, 1 thread):
  • EuRoC cam_april 86.02 → 92.05 %, precision 99.996 → 100.000 %;
  • Liu4K 37.18 → 44.56 %;
  • render-tag low-key 66 → 96 %, raw-pipeline 96 → 100 %;
  • every other benchmark at or above its previous recall. Corner error is unchanged: the cut decides topology, the grey-scale refinement decides corners.
  • high_accuracy shares standard's geometry. Its corners now come from ContourRdp, corner_subpix and Erf; what makes it the accuracy profile is its pose layer and stricter gates. EuRoC recall 38.43 → 53.75 %, false positives 32 → 0, LOO corner median 0.4138 → 0.2825 px; render-tag corner RMSE −69 to −71 % with rotation p50/p99 unchanged; AprilGrid board p99 rotation 0.1098 → 0.0186°. Cost: EuRoC latency 3.47 → 5.28 ms.
  • Distorted cameras run the same pipeline as pinhole ones. The distortion route was a second copy of the decode loop that never received the sub-pixel corner pass, the marker-inset calibration or the frame-clipping check. It is now one loop behind a Warp (Brown-Conrady hub, standard, pose mode Accurate): corner RMSE 0.957 → 0.216 px, translation p50 16.8 → 0.4 mm, rotation p50 0.305 → 0.059°, recall 89.40 → 91.39 %. Kannala-Brandt: corner RMSE 1.257 → 0.285 px.
  • About half the latency on distorted frames. Brown-Conrady hub 17.96 → 9.73 ms, Kannala-Brandt 16.50 → 8.51 ms per frame: one decode loop, a per-frame tabulated radial inverse, each quad edge fitted once instead of twice, and a segmentation stage cut from 8.52 to 4.53 ms (which lands on every camera).
  • Pose on helicoid. Pose is helicoid 0.0.3's SE(3); covariances and tangents are rotation-first and packed poses are scalar-first, matching helicoid, GTSAM, tf_tree and the hub ground truth. high_accuracy's pose stage is 7.0 % faster (73.6 → 68.5 ms over the 50 frames of locus_v1_tag36h11_1920x1080), the board estimator 2.6–2.8 %.

Breaking changes

  • Pose covariances and tangents are rotation-first, [ωx, ωy, ωz, vx, vy, vz]. Every 6×6 pose covariance (estimate_tag_pose, Detection::pose_covariance, BoardPose::covariance, Python board_cov), the LM's tangent and Jacobian columns, and Pose::adjoint. The frame is unchanged: body frame, right perturbation.
  • Packed poses are [tx, ty, tz, qw, qx, qy, qz], scalar first, with qw ≥ 0. DetectionBatch.poses (Python (N, 7) float32), the batch Pose6D and board_pose: qw moves from index 6 to index 3, and of q and −q the packed form always carries the one with qw ≥ 0. The array shape is unchanged, so old readers do not fail; they read wrong values.
  • Rust: Pose's public fields become accessors. rotation_matrix(), translation(), quaternion(), with_translation() and from_quaternion(); Pose::new keeps its signature and the serde form is unchanged. New Pose::to_packed().
  • Rust: decode_batch_soa_with_camera takes the frame's RadialInverseTable.
  • Config: upscale_factor > 1 with a declared lens model is rejected with ConfigError::UpscaleUnsupportedWithDistortion, instead of being silently mis-projected (straight-space extraction ran on the upscaled grid with un-upscaled intrinsics).
  • Output changes without an API change.
  • The threshold cut admits different foreground, so detections move (see Highlights). Costs, recorded: tag16h5 gains two false positives in 100 frames (precision 100 → 98 %) and loses one marker; ICRA forward checkerboard-grid recall 0.6886 → 0.6736.
  • high_accuracy's emitted board covariance becomes conservative: mean_board_translation_std_m rises ~14× while the actual error falls ~86 %. Consumers that gate on it gate more often.
  • Tooling: dataset-backed suites fail when their data is missing. They used to report success without running. Set LOCUS_ALLOW_MISSING_DATASETS=1 where there is genuinely no data; cargo nextest run --profile datasets --release --features bench-internals runs all 50 in ~25 s. regression_distortion_hub now requires --features non_rectified.

Migration

import numpy as np

# Covariance: old [t, ω] order -> new [ω, v] order (the permutation is its own inverse).
p = [3, 4, 5, 0, 1, 2]
cov_new = cov_old[np.ix_(p, p)]

# Packed pose: old [tx, ty, tz, qx, qy, qz, qw] -> new [tx, ty, tz, qw, qx, qy, qz].
pose_new = pose_old[..., [0, 1, 2, 6, 3, 4, 5]]

# Scalar-last consumers (ROS, Eigen coeffs(), rerun xyzw=):
xyzw = pose[..., [4, 5, 6, 3]]
# SciPy >= 1.14 reads the packed order directly:
# Rotation.from_quat(pose[..., 3:7], scalar_first=True)

The packed pose stays translation-first while the covariance is rotation-first: do not pair pose[:3] with cov[:3, :3] (docs/explanation/coordinates.md §5).

Added

  • CameraModel::undistort_checked and CameraIntrinsics::undistort_pixel_checked, which perform the round trip and return None outside the model's invertible domain; the bound is camera::MAX_UNDISTORT_RESIDUAL, now public.
  • Pose::to_packed(), the one writer of the packed layout.
  • RadialInverseTable and RoiCache::disabled().
  • ConfigError::UpscaleUnsupportedWithDistortion.

Changed

  • locus-core depends on helicoid and helicoid-linalg 0.0.3 (no_std, libm only, default-features = false). The LM's retraction is helicoid's Decoupled chart, the same formula as before.
  • EdLines, AdaptivePpb and edlines_imbalance_gate stay in the tree although no shipped profile uses them any more; profiles/README.md records why.
  • Segmentation is deterministic: union-find attaches by minimum index, so component numbering follows scan order whatever the order of unions.

Fixed

  • Distorted cameras. The distortion-aware decode route ran no corner refinement at all; the +0.5 px lattice expansion was gated off for it; contours were rectified before being simplified; high_accuracy built a full-frame label image on every distorted frame for a consumer that can never run.
  • Brown-Conrady undistort had not converged: 0.42 px of round-trip error at the image corner of the shipped hub's lens, enough for quad.rs to discard every candidate touching ~1.8 % of the frame. Now a radial Newton solve plus a 2-D polish, worst case 1.4e-9 px, and radii past the model's turning point are rejected instead of inverted onto the mirrored branch.
  • Unprojection failures were trusted. The single-tag IPPE seed, the distorted decode's ideal homography and the board seed used points the lens model could not explain; they now get no pose, a FailedDecode candidate and a skipped correspondence respectively.
  • Panics: a frame narrower or shorter than threshold_tile_size, and fit_edge_line_curved with decimation >= 7, which validate() accepted.
  • The debug-telemetry reprojection error measured the wrong points (tag-corner origin against a tag-centre pose): 67.9 px on a clean tag, now under 1 px.

Performance

  • Segmentation 8.52 → 4.53 ms on the Brown-Conrady hub: only the ~940 surviving components of ~63k are described, spatial moments are opt-in, and labelling is striped across workers with no unsafe.
  • Threshold stage 2.510 → 0.289 ms on the 2448×2048 ICRA frame: no telemetry work on production frames and a separable, vectorised 3×3 tile reduction. This does not reach frame latency (1080p 18.69 → 18.67 ms); the saving is absorbed downstream.
  • Each quad edge is line-fitted once (it was 48.8 % of distorted extraction's CPU), and the radial inverse is a per-frame table lookup with no square root or division.
  • Pose normal equations: weighted outer products factored (board estimator −2.6 to −2.8 %); model-edge samples accumulated as one rank-1 update (high_accuracy pose stage −7.0 %).

Tooling and benchmarks

  • The dataset-backed snapshots are blessed and every suite is green, for the first time since 2026-07-19. regression_straight_space runs in CI, the only CI coverage of the distortion routes; regression_distortion_hub had been measuring the pinhole detector on fisheye frames and now asserts its own premise per frame.
  • The EuRoC scorer's undistortion inverse (cv2.undistortPoints) stopped short by up to 0.134 px and overstated Locus's corner margin by ~3 pp. It is now a fixed point that asserts convergence; the archived EuRoC cells are re-derived (euroc_sota_20261004.md) or annotated with the measured bound. No win/loss verdict changes.
  • docs/engineering/benchmarking/baseline_schema_v2.json is regenerated and gated by a test.
  • Dependencies (dev and bench only; the wheel still requires only numpy and pydantic): polars 2.0 (bench floor >=2.0), pydantic 2.14, huggingface-hub 2.2 with datasets 5, insta 1.49.

Documentation

  • docs/engineering/benchmarking/euroc_error_budget.md: the EuRoC corner figure is a leave-one-tag-out self-consistency residual, reported at 1.365× the underlying corner noise, not a corner error; the "7.7× gap" to render-tag compared incommensurable quantities.
  • profiles/README.md records a profile ablation that found nothing to ship, including why connectivity and sharpening must change together.
  • docs/explanation/coordinates.md §5: the packed-pose and covariance orders, and the conversions above.