0.10.0 — 2026-10-13
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_april86.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_accuracysharesstandard's geometry. Its corners now come fromContourRdp,corner_subpixandErf; 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.Poseishelicoid0.0.3's SE(3); covariances and tangents are rotation-first and packed poses are scalar-first, matchinghelicoid, GTSAM,tf_treeand the hub ground truth.high_accuracy's pose stage is 7.0 % faster (73.6 → 68.5 ms over the 50 frames oflocus_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, Pythonboard_cov), the LM's tangent and Jacobian columns, andPose::adjoint. The frame is unchanged: body frame, right perturbation. - Packed poses are
[tx, ty, tz, qw, qx, qy, qz], scalar first, withqw ≥ 0.DetectionBatch.poses(Python(N, 7) float32), the batchPose6Dandboard_pose:qwmoves from index 6 to index 3, and ofqand−qthe packed form always carries the one withqw ≥ 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()andfrom_quaternion();Pose::newkeeps its signature and the serde form is unchanged. NewPose::to_packed(). - Rust:
decode_batch_soa_with_cameratakes the frame'sRadialInverseTable. - Config:
upscale_factor > 1with a declared lens model is rejected withConfigError::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:
tag16h5gains 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_mrises ~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=1where there is genuinely no data;cargo nextest run --profile datasets --release --features bench-internalsruns all 50 in ~25 s.regression_distortion_hubnow 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_checkedandCameraIntrinsics::undistort_pixel_checked, which perform the round trip and returnNoneoutside the model's invertible domain; the bound iscamera::MAX_UNDISTORT_RESIDUAL, now public.Pose::to_packed(), the one writer of the packed layout.RadialInverseTableandRoiCache::disabled().ConfigError::UpscaleUnsupportedWithDistortion.
Changed
locus-coredepends onhelicoidandhelicoid-linalg0.0.3 (no_std,libmonly,default-features = false). The LM's retraction ishelicoid'sDecoupledchart, the same formula as before.EdLines,AdaptivePpbandedlines_imbalance_gatestay in the tree although no shipped profile uses them any more;profiles/README.mdrecords 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_accuracybuilt a full-frame label image on every distorted frame for a consumer that can never run. - Brown-Conrady
undistorthad not converged: 0.42 px of round-trip error at the image corner of the shipped hub's lens, enough forquad.rsto 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
FailedDecodecandidate and a skipped correspondence respectively. - Panics: a frame narrower or shorter than
threshold_tile_size, andfit_edge_line_curvedwithdecimation >= 7, whichvalidate()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_accuracypose 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_spaceruns in CI, the only CI coverage of the distortion routes;regression_distortion_hubhad 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.jsonis regenerated and gated by a test.- Dependencies (dev and bench only; the wheel still requires only
numpyandpydantic): 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.mdrecords a profile ablation that found nothing to ship, including whyconnectivityandsharpeningmust change together.docs/explanation/coordinates.md§5: the packed-pose and covariance orders, and the conversions above.