iolabs · AI3D point-cloud pipeline · 3dai.iolabs.pointcloud.guardrails
MLS Guardrail Detection — How It Works
The pipeline at a glance
Inputs: what a segment gives us
The road is split into ~143 segments (dataset A4_5). Each segment brings:
| Input | Content | Used for |
|---|---|---|
*_run3_points.npz | MLS point records (XYZ, millions of points), possibly several overlapping windows | All evidence; streamed in ≤4M-point chunks |
run3_planes.npz | Division-plane centres + normals along the road | The road spine — a smoothed 3D spline used as centerline |
topdown_tiles/*.json/.png | Raster frame metadata, RGB + intensity ortho tiles | Georeferencing the overlays and QC images |
1Local ground model (DEM)
Segment points are binned into a 0.75 m XY grid; each cell's ground elevation is the
8th percentile of Z (ground_cell_m, ground_percentile). The low
percentile ignores cars, vegetation and rail returns while staying robust to sparse cells. From
here on, "height" always means height above this DEM, so the detector works identically
on flat, banked or sloped roads.
2Road spine & detection corridor
A cubic spline through the division-plane centres gives the spine; every point is projected onto it (nearest of ~1 m arc-length samples) to get station and signed offset. Then a hard crop keeps only points inside the bands where guardrails can physically stand:
Rail-like clusters found outside the corridor (second carriageway's barrier, fences)
are not discarded silently — they are clustered and written to
corridor_exclusions in the output so every decision is auditable.
3Candidate cells — the W-beam signature
Surviving points are dropped into a 0.10 m occupancy grid (top-down). A cell is kept
only if its height distribution looks like a compact steel rail band
(geometry.select_candidate_cells):
| Gate | Default | Rejects |
|---|---|---|
| Min points per cell | ≥ 3 | isolated noise returns |
| Points in rail band 0.35–0.85 m | ≥ 2 and ≥ 40 % | ground, kerbs, low vegetation |
| Mean height | 0.42 – 0.78 m | markings (~0 m), walls/fences (>1 m) |
| Height spread per cell | ≤ 0.50 m | full-height structures, bushes |
| Tall-point fraction (1.3–4.5 m) | ≤ 12 % | cells under trees / signs / gantries |
4Clustering & occlusion bridging
Selected cell centres are clustered in 2D with DBSCAN (eps 0.20 m,
min_samples 3). Two repair passes then join fragments
(geometry.merge_cluster_labels, union-find over cluster endpoints):
- Collinear merge — endpoint gap ≤ 4.5 m, heading difference ≤ 15°, lateral offset ≤ 0.5 m.
- Occlusion bridge — up to 8 m across a parked-vehicle shadow, but only when the
line stays tightly continuous (heading ≤ 4°, lateral ≤ 0.4 m). The bridged interval is always
recorded in the instance's
gap_spans— never a silent interpolation.
5Centerline fit & acceptance gates
For each cluster (geometry._fit_instance): PCA gives the principal direction; cells are
projected onto it and binned every 1 m (polyline_bin_m); each bin's vertex is the
median lateral position (robust to stray cells); a 5-vertex moving average smooths the result.
The candidate is accepted only if it behaves like a rail:
| Acceptance gate | Default | Intuition |
|---|---|---|
| Length | ≥ 12 m | rails are long; stripes and fragments are not |
| Longitudinal coverage | ≥ 35 % | evidence spread along the run, not one blob |
| Local width (2·p90 residual) | ≤ 0.75 m | a rail is thin; hedges are not |
| Straightness (≤ 25 m runs) | ≤ 0.5 m dev / 10 m window | short runs must be near-straight |
| Cluster height p90−p10 / p95 | ≤ 0.80 m / ≤ 1.15 m | uniform rail band, nothing tall inside |
If a cluster fails because it is too wide, the fitter splits it at the median lateral position and retries each half — this recovers the two separate rails of a close double barrier.
6Face merge — two faces, one rail
The scanner sees both sides of one W-beam (~0.3 m apart in top-down), so raw fits often
produce two parallel fragments. dedupe.merge_parallel_faces collapses them into one
physical instance when, on the same side of the road:
- lateral spacing < 1.3 m (
merge_face_max_spacing_m), - headings within 5°,
- station ranges overlap > 50 %.
Grouping is transitive (union-find), so a rail DBSCAN split into three pieces still becomes one
instance: faces stays a physical count capped at 2, while face_fragments
records how many raw pieces went in. The merged centerline averages the faces' lateral profiles;
where only one face has support it extends straight and logs the interval in gap_spans.
7Type classification & confidence
Each run gets a type from cross-profile morphology
(classify.classify_guardrail_heuristic): a compact band at 0.40–0.90 m →
w_beam, a dense ground-to-0.8 m+ fill →
concrete, a very thin low line →
cable_suspect, otherwise unknown.
On the A4_5 dataset all 367 detected runs classify as w_beam — verified against the
raw cloud, that is the ground truth (there is no concrete barrier in A4_5).
Every run also carries a heuristic review-priority score in [0, 1]:
confidence = 0.35·support + 0.25·continuity + 0.25·extent + 0.15·height_consistency
- support — candidate density per metre (saturates at 500 pts/m)
- continuity — fraction of the run not covered by
gap_spans - extent — length relative to 40 m
- height consistency — low std of the height profile
Strong shoulder rails score ~0.95+; a suspected painted stripe ~0.6. The score ranks review priority — it never gates detection.
The noise-wall channel independent evidence
Walls are too tall for the guardrail gates, so they get their own channel that accumulates during the same streaming pass: 0.25 m XY cells, each holding a 0.25 m height histogram over 0.3–8 m plus raw 3D moments for a later PCA — memory is O(grid), never point lists.
Cell gates, then the same fitting machinery
A wall cell needs ≥ 6 points, a crest at 2.5–8 m, ≥ 2 occupied histogram bins spanning ≥ 1.5 m vertically (grazing-angle MLS returns are banded, so a plain "vertical fill" test would miss real walls). Surviving cells go through the same cluster-and-fit code as guardrails, with wall-tuned thresholds (length ≥ 13 m, width ≤ 1.8 m…).
Post-fit rejection — where the false positives die
| Gate | Rule | Kills |
|---|---|---|
| Crest spread | p90−p10 of top profile ≤ 1.5 m | tree rows, mixed clutter |
| Truck double-gate | reject if crest ≤ 4.2 m and length < 20 m | parked trucks / trailers |
| Adaptive planarity | planarity ≥ 0.55 (or ≥ 0.25 with vertical normal; line-degenerate bypass) | volumetric vegetation |
| Carriageway rejection | keep only at/outside the outermost same-side rail (fallback |offset| ≥ 6 m) | vehicles between edge rails |
| Elevated-structure rejection | wall bottom starting > 2 m above DEM | bridge parapet/deck artifacts |
Every rejected wall is appended to corridor_exclusions with an explicit reason
(wall_inside_carriageway, wall_elevated_structure), so QC can see
exactly what was dropped and why. Accepted walls export as type: noise_wall with
per-metre top/bottom height profiles — a bridge parapet's footing sits on the deck, not on the
ground under the bridge.
Outputs & quality control
Per segment
guardrails.json— instances + walls with centerline polyline,length_m,width_m,side(left/right/median),station_range_m,gap_spans, height profiles,type,confidence, plus the fullcorridor_exclusionsaudit list*_guardrails.xml— CAD/GIS export of the polylines- RGB + intensity overlays with extent bands, gap dashes, labels and a type legend; a red candidate-mask diagnostic; perspective renders with depth-tested overlays
run_summary.json— timings, counts, peak RSS, alignment checks
Built-in QC
- Image-space alignment metric: overlay curves are checked against the imagery by searching along the pixel normal; pass needs median lateral error within a width-aware budget per camera and per segment. Re-runs emit drift warnings (p90 error +0.05 m or visibility −0.02).
- Colour semantics are fixed: green = w_beam, cyan = noise wall, etc. — one legend, same meaning in every view.
- Memory guards: streaming + chunking + a DBSCAN watchdog keep dense outlier segments under the 32 GB Azure node budget without changing results on normal segments.
The whole thing in 25 lines of Python
Faithful sketch of detect.process_segment (real function names, simplified signatures):
def process_segment(records, run3_planes):
ground = build_ground_model(records, cell_m=0.75, ground_percentile=8) # local DEM
spine = RoadSpine(run3_planes) # road centerline spline
grid, wall_grid = OccupancyGrid(0.10), WallGrid(0.25) # bounded accumulators
for points in stream_records(records): # ≤ 4M-point chunks
station, offset = spine.project(points)
height = points.z - ground.sample(points.xy) # height above ground
grid.add(points[corridor_mask(station, offset, height)]) # |off| 1.5–10 (+median), h 0.2–2.0
wall_grid.add(points[wall_corridor_mask(station, offset, height)]) # |off| 1.5–26, h 0.3–8
cells = select_candidate_cells(grid) # rail band 0.35–0.85 m, mean h 0.42–0.78 m, ...
labels = DBSCAN(eps=0.20, min_samples=3).fit_predict(cells.xy)
labels = merge_cluster_labels(labels) # collinear ≤ 4.5 m + occlusion bridge ≤ 8 m
instances = [fit_centerline(c) for c in clusters(labels)] # 1 m bins, ≥ 12 m, width ≤ 0.75 m
instances = merge_parallel_faces(instances) # 2 faces < 1.3 m apart → one rail
walls = detect_wall_instances(wall_grid) # crest 2.5–8 m, planarity, truck gate
walls = filter_walls_outside_carriageway(walls, instances)
for inst in instances:
inst.type = classify_guardrail_heuristic(inst) # w_beam / concrete / ...
inst.confidence = score_run_confidence(inst) # review priority, not a gate
return instances, walls, corridor_exclusions # everything rejected is logged here
Run it:
uv run python -m guardrails.detect --data-dir /path/to/lane_points \
--segments 000,010,020 --out out/ --set merge_face_max_spacing_m=1.3
All ~140 thresholds live in guardrails/guardrails.default.json, mirrored by a frozen
DetectorConfig dataclass (equality asserted by tests). Overrides only via --set KEY=VALUE.
Known limitations (by design, documented)
- One spine per segment.
sideis derived purely from the signed offset to a single road spine (|offset| ≤ 3.5 m → "median"). A median barrier between two separate carriageways that sits far from this spine is labeled by sign alone — fixing this needs per-carriageway boundary input the detector doesn't currently receive. - The concrete class is currently unreachable in production. The candidate gates (mean height ≤ 0.78 m, cluster p95 ≤ 1.15 m) reject a dense full-height concrete wall before classification runs. Deliberate for A4_5 (which has none); a future concrete-barrier dataset needs those gates widened.
- Bridges / elevated structures: DEM-based heights are wrong on decks, so wall candidates whose bottom starts > 2 m above the DEM are rejected and logged rather than tuned around; wall detection and QC are not run/tuned on bridge segments.
- No ML anywhere — a feature for auditability and determinism, but it means new barrier geometries require threshold work, not retraining.
Source: 3dai.iolabs.pointcloud.guardrails — detect.py, corridor.py,
geometry.py, dedupe.py, classify.py · defaults from
guardrails.default.json · page generated 2026-07-17.