No GPU, numpy-only, geometry-based. Removes dynamic objects (vehicles, pedestrians, cyclists) from LiDAR scans and accumulated maps — no deep learning, numpy the only dependency.
-
🕹️ Browser playground (no install): https://rsasaki0109.github.io/dynamic-3d-object-removal/demo/playground.html — the real library running client-side via Pyodide. Box / Range / Temporal modes, AV2 64-beam and nuScenes 32-beam presets, shareable URLs, or drop your own PCD.
-
More demos: AV2 sequence · single scan · local sequence
20-frame accumulated Argoverse 2 map (not a single scan): 233k ghost points (11.9% of 2M) removed, static structure preserved.
- Five algorithms, all numpy:
box(per-scan crop, needs 3D boxes),temporal(voxel consistency),range(range-image visibility, Removert-style remove + revert),scan_ratio(ERASOR-style per-column pseudo-occupancy + ground revert),fusion(highest-accuracy map cleaner) — the last four are detector-free - Fast: 1.5 ms for 24k points on CPU; ROS2 realtime node (
box/temporal/range) - Minimal dependencies:
numpyonly (pyarrowjust for Argoverse 2 Feather input)
Every branch is backed by a measurement below:
flowchart TD
Q1{"Do you have 3D boxes<br/>(detector or annotations)?"}
Q2{"Filtering live, per scan<br/>(e.g. ROS2 in a SLAM pipeline)?"}
Q3{"Sensor density?"}
BOX["<b>box</b><br/>geometric crop per scan"]
RT["<b>temporal</b> (fastest, simplest)<br/>or <b>range</b>"]
FUSION["<b>fusion</b> — highest accuracy<br/>(Semantic-KITTI AA 98.6 / 98.0)"]
SPARSE["<b>range</b> sized to beam density,<br/>optionally ∧ <b>scan_ratio</b> mask"]
Q1 -- "yes" --> BOX
Q1 -- "no" --> Q2
Q2 -- "yes" --> RT
Q2 -- "no — offline map cleaning" --> Q3
Q3 -- "dense (64-beam+)" --> FUSION
Q3 -- "sparse (≤ 32-beam)" --> SPARSE
ERASOR (RA-L '21) and Removert (IROS '20) clean a finished, pose-aligned map offline; this project also covers online, per-scan use. Positioning guide (from their papers, not a re-run benchmark):
| This project | ERASOR | Removert | |
|---|---|---|---|
| Primary goal | Per-scan / realtime removal + map cleaning | Offline static-map cleaning | Offline static-map cleaning |
| Needs a detector / 3D boxes | box: yes · others: no |
No | No |
| Needs poses | box/temporal: no · map cleaners: yes |
Yes | Yes |
| Online / realtime | Yes (ROS2 node) | No (batch) | No (batch) |
| Core stack | numpy only |
C++ / ROS / PCL | C++ / ROS / PCL |
Detector-free methods only, reproducible with one command, no signup. Ground truth = points on objects whose track actually moved (parked cars don't count against a motion-based method).
| method (detector-free) | precision | recall | F1 | static points kept |
|---|---|---|---|---|
free-space fusion (fusion, short-window thresholds) |
0.65 | 0.66 | 0.66 | 0.97 |
range-image visibility (range) |
0.68 | 0.54 | 0.60 | 0.98 |
scan-ratio pseudo-occupancy (scan_ratio, --sr-min-votes 2) |
0.66 | 0.56 | 0.61 | 0.98 |
temporal consistency (temporal) |
0.19 | 0.72 | 0.30 | 0.78 |
Scene
0b5142c1…, 1.24 M points, 84 k GT points.fusionneeds relaxed short-window thresholds here (0.7 / 3 / 4, the script's defaults — the library defaults assume 100+ scans and drop F1 to 0.39).rangeis tunable toward precision (--min-see-through 4→ ≈ 0.89).scan_ratioreaches a similar F1 through an independent signal (column occupancy vs line-of-sight); use a small fixed--sr-min-voteson short windows.
pip install awscli pyarrow
python3 scripts/run_av2_benchmark.py --frames 12On a ~5× sparser sensor the one change that matters: match the range-image resolution to beam density (2.5° vs AV2's 1.0°).
| method (detector-free) | precision | recall | F1 | static points kept |
|---|---|---|---|---|
| range ∧ scan-ratio (intersection) | 0.51 | 0.87 | 0.64 | 0.84 |
range-image visibility (range) |
0.48 | 0.92 | 0.63 | 0.81 |
scan-ratio pseudo-occupancy (scan_ratio) |
0.36 | 0.90 | 0.51 | 0.69 |
free-space fusion (fusion, short-window thresholds) |
0.16 | 0.32 | 0.22 | 0.68 |
temporal consistency (temporal) |
0.07 | 0.22 | 0.11 | 0.47 |
scene-0757, 12 keyframes, 303 k points, 49 k GT points. AV2's fine1.0°resolution collapses F1 to ~0.30 here.scan_ratio's column signal is more sparsity-sensitive (high recall, weak precision) — but its false positives are nearly disjoint fromrange's, so intersecting the two dynamic masks gives the best precision-side numbers at no extra cost.fusionis not suited to sparse sensors: beyond ~13 m the beam spacing exceeds the carving voxel and static walls get carved between beams; coarser voxels don't recover it (measured F1 < 0.3).
python3 scripts/run_nuscenes_benchmark.py # downloads nuScenes mini once, ~3.9 GB, no signupKTH-RPL DynamicMap_Benchmark teaser sequences (Zenodo, no signup), the benchmark's SA / DA / AA metrics. Our methods only.
| method | seq 00 SA | seq 00 DA | seq 00 AA | seq 05 SA | seq 05 DA | seq 05 AA |
|---|---|---|---|---|---|---|
free-space fusion (fusion) |
98.9 | 98.3 | 98.6 | 98.0 | 98.1 | 98.0 |
scan-ratio pseudo-occupancy (scan_ratio) |
98.0 | 92.8 | 95.4 | 96.0 | 97.9 | 96.9 |
range-image visibility (range) |
99.6 | 34.5 | 58.6 | 99.8 | 25.9 | 50.9 |
temporal consistency (temporal) |
97.0 | 46.6 | 67.2 | 97.3 | 25.9 | 50.2 |
seq 00: 141 scans / 17.4 M points; seq 05: 321 scans / 39.9 M points.
fusionmatches the leaderboard-topping DUFOMap on seq 00 (AA 98.6) and exceeds every listed method on seq 05 (98.0 vs 96.3); the learning-based, GPU-trained 4dNDF (AA ≈ 99) is outside this numpy-only class. Channel thresholds were tuned on these two sequences, like most leaderboard entries — cross-dataset transfer is what the AV2/nuScenes sections above measure.
python3 scripts/run_dynamicmap_benchmark.py --sequences 00 05 # ~385 MB per sequencepip install dynamic-object-removalPure-Python wheel, numpy the only dependency. Extras: [ros2] (ROS2 node), [benchmarks] (AV2/nuScenes scripts). From source: git clone + pip install -e .
Real Argoverse 2 data in three commands, no signup:
# 1. Download an AV2 sample (1 sweep + annotations, ~1.3 MB)
pip install awscli pyarrow
python3 scripts/download_av2_sample.py
# 2. Remove dynamic objects (18 vehicles, 3 pedestrians, 1 bicycle, 1 wheelchair)
dynamic-object-removal \
--input-cloud data/av2_sample/lidar/315969904359876000.feather \
--input-objects data/av2_sample/annotations.feather \
--timestamp-ns 315969904359876000 \
--output-cloud output/av2_cleaned.pcd
# 3. Inspect before/after in 3D
python3 demo/run_scan_demo.py \
--input-cloud data/av2_sample/lidar/315969904359876000.feather \
--input-objects data/av2_sample/annotations.feather \
--timestamp-ns 315969904359876000 \
--max-render-points 50000 \
--output-html demo/index_3d_av2.htmlRemoves 3,406 of 95,381 points (3.6%); static road and buildings remain. KITTI is also supported:
scripts/download_kitti_sample.py.
# Box-driven (needs detected boxes)
dynamic-object-removal \
--input-cloud /path/to/scan.pcd \
--input-objects /path/to/objects.json \
--output-cloud /path/to/output.xyz
# Detector-free map cleaning (swap range for scan_ratio as needed)
dynamic-object-removal \
--algorithm range \
--input-map accumulated_map.npy \
--input-cloud query_sweep.npy \
--sensor-origin 0 0 0 \
--output-cloud cleaned_map.npySubscribes to PointCloud2, filters, publishes:
# Box-driven with an external detector
dynamic-object-removal-realtime \
--pointcloud-topic /velodyne_points \
--objects-topic /detected_objects \
--output-topic /cleaned_points \
--algorithm box
# Detector-free temporal consistency
dynamic-object-removal-realtime \
--pointcloud-topic /velodyne_points \
--output-topic /cleaned_points \
--algorithm temporal \
--voxel-size 0.10 --temporal-window 5 --temporal-min-hits 3from pathlib import Path
from dynamic_object_removal import load_points, load_boxes, remove_points_in_boxes, save_points
points = load_points(Path("/path/to/scan.pcd"), fmt="auto")
boxes = load_boxes(Path("/path/to/objects.json"), fmt="auto", skip_invalid=True)
kept, keep_mask = remove_points_in_boxes(points, boxes, margin=(0.05, 0.05, 0.05))
save_points(Path("/path/to/output.xyz"), kept, fmt="auto")Main public APIs:
load_points(path, fmt="auto")/load_boxes(path, fmt="auto", skip_invalid=False)/save_points(path, fmt="auto")remove_points_in_boxes(points, boxes, margin=(0.05, 0.05, 0.05))TemporalConsistencyFilter(voxel_size=0.10, window_size=5, min_hits=3)remove_ghost_by_range_image(map_points, query_points, sensor_origin, range_margin=0.5)— single map-vs-scan visibility removalclean_map_by_visibility(map_points, scans, min_see_through=2, max_surface_hits=2, ground_z=None, resolutions=None)— multi-scan map cleaner (remove + revert)remove_dynamic_by_scan_ratio(map_points, query_points, sensor_origin, scan_ratio_threshold=0.2, ground_margin=0.2)— single map-vs-scan scan-ratio removalclean_map_by_scan_ratio(map_points, scans, scan_ratio_threshold=0.2, min_votes=None, votes_fraction=0.5, votes_floor=3)— multi-scan scan-ratio cleaner (min_votes=None= majority of each point's column revisits)clean_map_by_fusion(map_points, scans, workers=1)— highest-accuracy map cleanerRangeImageGhostFilter(window_size=5, range_margin=0.5)— streaming range-image filter for ROS2
# scans: list of (points_in_map_frame, sensor_origin) from the sweeps that built the map.
kept, keep_mask = clean_map_by_visibility(
map_points, scans,
range_margin=0.5, min_see_through=2, max_surface_hits=2, ground_z=-1.4,
)A point is removed only when enough scans see through it and few confirm it as a real surface (the Removert-style revert guard). For higher precision pass resolutions=[2.5, 4.0] (multi-resolution consensus: AV2 precision 0.68 → 0.78). Try it in the playground's Range mode.
kept, keep_mask = clean_map_by_scan_ratio(
map_points, scans,
scan_ratio_threshold=0.2, min_map_height=0.5, ground_margin=0.2,
)ERASOR-style and independent of visibility: a polar column that is tall in the map but flat in a live sweep held a moving object; above-ground points are removed, the ground reverted by a per-column plane fit. Strongest on dense (64-beam+) LiDAR; on sparse sensors prefer range or raise votes_fraction.
kept, keep_mask = clean_map_by_fusion(map_points, scans, workers=6)Three independent dynamic-evidence channels, OR-fused:
flowchart LR
MAP["accumulated map<br/>+ per-scan (points, sensor origin)"]
FS["<b>free-space carving</b><br/>ray-sampled, per-scan hit precedence<br/>dynamic when ≥ 90% of observers freed it"]
EV["<b>eroded voids</b> (DUFOMap-style)<br/>hit inflation + 26-neighborhood erosion<br/>dynamic after ≥ 11 confirmed voids"]
SR["<b>scan-ratio votes</b><br/>polar-column occupancy, fraction 0.7"]
OR(("OR"))
OUT["dynamic mask removed →<br/>cleaned static map"]
MAP --> FS
MAP --> EV
MAP --> SR
FS --> OR
EV --> OR
SR --> OR
OR --> OUT
Fractional free-space voting nails transient traffic; absolute void counts catch slow movers and late leavers — the union scores high on both (KITTI AA 98.6 / 98.0). Carving is the cost: minutes per hundred 64-beam scans with workers=6, vs seconds for range/scan_ratio.
Sizing to your data: defaults assume a long (100+ scan) dense-sensor sequence. For short windows (~12 scans) relax to free_votes_fraction=0.7, free_votes_floor=3, void_min_scans=4. On sparse (32-beam) sensors use range instead (measured on nuScenes above).
# Single scan
python3 demo/run_scan_demo.py \
--input-cloud demo/actual_scan_20240820_cloud.pcd \
--input-objects demo/actual_scan_20240820_objects.json \
--max-render-points 220000 \
--output-scene demo/demo_scene_single_scan.json \
--output-html demo/index_3d_standalone.html
# Sequence (temporal-cleaned; pass --input-objects / --input-poses for box-driven, pose-aligned)
python3 demo/run_scan_sequence_demo.py \
--input-glob "/path/to/graph/*/cloud.pcd" \
--frame-count 12 --stride 1 --max-render-points 9000 --fps 4 \
--voxel-size 0.35 --window-size 5 --min-hits 3 \
--output-html demo/index_3d_sequence_standalone.htmlThe checked-in HTML demos are self-contained (sampled point data embedded).
- Point clouds:
PCD(ASCII / binary),CSV,TXT,XYZ,NPY,BIN(KITTI),Feather(Argoverse 2) - Bounding boxes:
JSON,CSV,KITTI label_2,Feather(Argoverse 2) PCD DATA binary_compressedis not supported
Releases publish to PyPI via Trusted Publishing on tag push. Bump __version__ in dynamic_object_removal.py, commit, then:
git tag v0.6.0
git push origin v0.6.0

