๐ค Contribute
We welcome pull requests! To keep the deployment and integration pipelines clean and stable, please adhere to our shared development lifecycle rules.
Developers: How to contribute to the framework
Almost everything in the bench is a plugin sitting behind a registry, so the common contributions don't touch any engine code โ you add a file and add a row. Pick the one that matches what you're building:
| You want to addโฆ | Where the file goes | What to register it in |
|---|---|---|
| a metric (ยง1) | lidar_metrics/zone_metrics/<geo>_zones/<category>_metrics/ | registry.yaml |
| a metric parameter that follows the scene (ยง1) | lidar_metrics/metric_params_overrides/ | nothing โ found by filename |
| a zone geometry (ยง2) | lidar_zones/zones_api/zone_plugins/ | zones_types_registry.yaml |
| a database or credential store (ยง3) | lidar_eval_backends/database_backends/ | database_registry.yaml / auth_registry.yaml |
Legend
- โ๏ธ Hands-on โ steps where you actually add or change code.
- Unmarked โ background: how things work, so the hands-on parts make sense.
1. Contributing to lidar_metrics_libraryโ
The metrics library is a pure-Python plugin engine โ no ROS involved. To add a metric you drop in a file and register it. You won't touch the engine.
First: spatial vs projectiveโ
Every metric picks a category โ spatial or projective โ and that pick decides which cloud it
gets. Each zone is pre-filtered both ways, so you just choose one:
spatialโ points inside the zone's 3D box.projectiveโ points whose rays fall in the zone's angular window (already padding-trimmed).
That's the whole decision. It also sets which folder your file lives in โ more below.
How the engine worksโ
LidarMetricsEngine (engine.py) does three
things:
- Reads the registry. registry.yaml
groups metrics by geometry โ one key per zone type (
planar_zone_metrics,cylindrical_zone_metrics, โฆ). New geometry = new key, no code change. - Routes each scan. Every scan, the engine sends each metric only the zones matching its geometry
(a planar metric never sees a cylindrical zone) and only the cloud matching its
category. So your metric receives exactly the points it asked for, already bucketed per zone. - Reduces at the end. It collects every metric's result and pivots it into
{ zone โ Metric โ sub โ value }for the report. Your keys are zone-prefixed: emitf'{zone}_foo'and it lands atreport[zone][Metric][foo]. Keys with no zone go under__global__.
One more thing worth knowing: a metric is created once and reused for every scan, so keep your running totals in the instance.
โ๏ธ Writing a new metricโ
Step 1 โ drop the file in the right folder. The path is the convention:
zone_metrics/<geometry>_zones/<category>_metrics/<file>.py
e.g. a projective planar metric โ zone_metrics/planar_zones/projective_metrics/my_metric.py. The
folder is how you pick your cloud (spatial_metrics/ โ box cloud, projective_metrics/ โ frustum
cloud), and it has to match the category you register or the import fails.
Step 2 โ subclass MetricsBase (metrics_base.py)
and fill in four methods. The rule of thumb: all your state goes in __init__ so shutdown() can
wipe it clean for the next run.
from lidar_metrics.metric_interfaces.metrics_base import MetricsBase
import numpy as np
class MyMetric(MetricsBase):
def __init__(self, pointcloud_by_zone, profiles=None, baseline_profiles=None):
self._sums: dict[str, float] = {} # your accumulators live here
super().__init__(pointcloud_by_zone, profiles, baseline_profiles)
def setup(self): # once, before any scans โ read config + profiles
self._tol = self.config['lidar_metrics_parameters']['my_metric']['tolerance_m']
def update(self, pointcloud_by_zone): # once per scan โ accumulate, don't return
for zb in self.profiles.zone_bounds:
pts = pointcloud_by_zone.get(zb.name)
...
def compute(self): # once at the end โ return your zone-prefixed results
return {f'{zone}_error_m': v for zone, v in self._sums.items()}
def shutdown(self): # once after compute โ reset so the instance can be reused
self._sums.clear()
Think of it as a lifecycle: setup (prep) โ update (per scan) โ compute (final answer) โ
shutdown (clean up).
What you get to work with:
self.profilesโ just your geometry's zones. Loopprofiles.zone_bounds; each planar bound hasy_min/y_max,z_min/z_max,x_surface,expected_depth_m,y_padding/z_padding, andprofiles.lidar_positionis the sensor's xyz.self.horizontal_resolution/self.vertical_resolutionโ the sensor's angular resolution in degrees, ready by the timesetup()runs.- The per-scan clouds โ a
dict[zone_name โ (N, 4) xyzi array], already filtered per zone. Don't re-filter projective clouds (padding is applied upstream) โ but do account for padding when scoring a rate, or the empty edge reads as dropout.
Step 3 โ register it in registry.yaml
under the right geometry key. Everything lives beneath the top-level lidar_metrics: key, one key
per zone geometry:
lidar_metrics:
planar_zone_metrics:
- name: MyMetric # the class name
description: What it measures and why.
executable: my_metric # the filename, minus .py
category: projective # spatial | projective โ must match the folder
return_type: dict[str, float]
enabled: true # false = keep it listed but skip it
name is the class, executable is the file โ the engine puts them together to import your metric.
โ๏ธ Tuning parameters โ config.yamlโ
Any knobs your metric needs live in
config.yaml under your metric's name, and you
read them in setup(). Adding some is just a new block:
lidar_metrics_parameters:
my_metric:
tolerance_m: 0.1
n_bins: 5
โ๏ธ When a parameter depends on the sceneโ
Sometimes a knob shouldn't be a fixed number โ it should follow the zones (e.g.
spatial_dropout.cell_size_m scaling with how far the target sits from the sensor). Two ways to do it:
- Override plugin (cleaner). Add a file named
<metric>__<param>__override.pyin metric_params_overrides/, subclassOverrideInterfaceBase, and compute the value fromself.profilesinretrieve_param(). The engine finds it by filename โ no registration โ and writes the result intoconfig.yamlbefore the run. Here's the worked example. Keeps the math out of your metric. - Just compute it in
setup(). Less to set up, but you're baking that logic into the metric. Fine for a one-off; reach for an override plugin when it's reusable or you want to test it on its own.
โ๏ธ Adding metrics for a brand-new zone typeโ
If you add a new zone geometry (ยง2), the metrics side just follows the same pattern โ still no engine changes:
- Make the folders:
zone_metrics/<new_geo>_zones/{spatial,projective}_metrics/. - Add a
<new_geo>_zone_metrics:key inregistry.yamland list your metrics. - Write them against
MetricsBaseโ every metric, whatever its zone type, inherits from metrics_base.py.
Match the naming (<Geo>ZoneBounds, <geo>_zone_metrics, <geo>_zones/) and the engine wires it all
up on its own.
2. Adding a new zone geometryโ
Zones are plugins too. One
ZoneTypePlugin subclass is the
single place that defines a geometry โ how to parse it, resolve its bounds, mask clouds against it,
serialize it, and draw it โ and every consumer (the zones node, the filter, the orchestrator,
polysetup's URDF generation) picks it up through the ZoneEngine with no further edits.
planar.py is the reference to copy; cylindrical.py is the second example.
Step 1 โ write the plugin at lidar_zones/lidar_zones/zones_api/zone_plugins/<executable>.py.
Its two data structs are nested inside the class, so one class holds the geometry's data and its
behavior, and you expose them via zone_type_cls / bounds_cls:
<Geo>ZoneTypeโ the fields a zone declares in the environment config (planar:z_bounds,width,y_padding,z_padding).<Geo>ZoneBoundsโ the resolved bounds after TF is applied; this is what metrics see onself.profiles.zone_bounds.
Then fill in the contract. Grouped by when it's called:
| Stage | Methods |
|---|---|
| Parse + build (classmethods โ no instance yet) | parse_zone_type, build |
Serialize across /get_profiles | to_dict, from_dict, zone_type_to_dict, zone_type_from_dict |
| Filtering, per scan | spatial_mask, projective_mask |
| Visualization | expected_fields, build_markers |
| Generation time (polysetup) | construct_urdf_link, roi_fields |
| Optional | lateral_half_extent โ defaults to 0.0 (a point); override it if your zone has lateral size |
Step 2 โ register it in zones_types_registry.yaml:
zone_plugins:
- ZoneType: my_geometry # the `type:` string a zone declares in its env config
executable: my_geometry # module name under zone_plugins/
Class: MyGeometryZonePlugin # the ZoneTypePlugin subclass in that module
The geometry label lives only in the registry, never on the plugin โ that's what keeps the
ZoneEngine from needing an edit per geometry.
:::warning Keep module-level imports light
Import only numpy at module scope. Anything heavy or ROS-ish โ urdf_parser_py,
visualization_msgs, the shared marker_helpers โ goes inside the one method that needs it, so
pure consumers like the pointcloud filter never drag in the visualization message types.
:::
Step 3 โ add its metrics, following the
"Adding metrics for a brand-new zone type" pattern above: create
zone_metrics/<geo>_zones/{spatial,projective}_metrics/ and a <geo>_zone_metrics: key in the
metrics registry.yaml. Keep the naming aligned (<Geo>ZoneBounds โ <geo>_zone_metrics โ
<geo>_zones/) and the routing happens on its own.
3. Adding a database backend or credential providerโ
Where results get pushed is also pluggable, and credentials and storage are separate plugin points โ so swapping your secret store doesn't mean writing a storage backend:
- Same database, different secret store โ implement
AuthInterfaceunderdatabase_backends/google/auth/and enable it in that backend'sauth_registry.yaml. - A different database entirely โ implement
DatabaseInterfaceunderdatabase_backends/and register it indatabase_registry.yaml.
Both registries load the first enabled row, so enable exactly one. The full walkthrough โ plus how to set your own credentials up in the first place โ is in Developer Guide ยง 2 โ Authenticating your database.
โ๏ธ Testing your changeโ
Every ROS package carries its tests in <package>/test/test_*.py. They're plain pytest โ no ROS
graph, no bags, no hardware. Fakes stand in for the parts that would need them, so the whole suite
runs in a couple of seconds.
Run everything:
colcon test
colcon test-result --verbose # the failure detail; `colcon test` only prints a summary
Run one file while you iterate โ much faster than a full colcon test, and the failure lands
straight in your terminal:
source install/setup.bash # the tests import from the overlay
python3 -m pytest lidar_zones/test/test_zone_engine.py -q
Adding a metric or a zone plugin? Put its test next to the existing ones โ test_metrics_engine.py
and test_zone_engine.py are the patterns to copy. Both fake their plugins, so they test routing and
report shape without depending on any individual metric's math.
The extras_require gotchaโ
If you create a new ament_python package, its setup.py must declare the test extra:
extras_require={
'test': [
'pytest',
],
},
colcon only picks its pytest runner for packages that declare it. Without it colcon silently falls
back to the old setuptools/unittest runner, which collects nothing, prints NO TESTS RAN, and fails
the package with exit code 5 โ even when the package is full of perfectly good pytest files.
What CI enforcesโ
ros-ci.yml
builds the workspace and runs the suite in two steps:
| Step | Blocking? | What it runs |
|---|---|---|
colcon test (unit tests) | yes | everything except the style linters |
ament style linters | no | test_flake8 / test_pep257, reported for visibility |
The linters are deliberately non-blocking: they flag several hundred pre-existing errors in source,
most of them style preferences this codebase doesn't follow โ notably pep257's D213 ("summary should
start at the second line"), which every multi-line docstring here violates by using the conventional
first-line summary. Gating on them would mean permanently red CI. Your unit tests are the gate, so
a red run means something real broke.
That said, don't add new lint errors in the files you touch. Check before you push:
python3 -m ament_flake8.main path/to/your_file.py
python3 -m ament_pep257.main path/to/your_file.py
Opening a Pull Request (PR)โ
- Isolate your work by creating a feature branch off of
main:feat/add-sensor-xorfix/matrix-transform. - Format your commit descriptions using standard Conventional Commits formatting keys (e.g.,
feat(core): add type-safe transform matrix).