iolabs · AI3D point-cloud pipeline · 3dai.iolabs.pointcloud.guardrails

MLS Guardrail Detection — How It Works

2026-07-17 · for review by architecture · current state of the detector (branch main)

The detector finds highway guardrails and noise walls in mobile-laser-scanning (MLS) point clouds using purely classical geometry — no machine learning. It streams each road segment's points once, measures every point's height above a local ground model and its lateral offset from the road centerline, keeps only points that look like a compact rail band, and fits metre-spaced polylines through their 2D footprint. Every threshold is an explicit, documented config value, and everything that gets rejected is logged — nothing is silently dropped.

The pipeline at a glance

LiDAR records npz, streamed Ground DEM 0.75 m · p8 of Z Corridor crop station / offset / height Candidate cells 0.10 m grid + gates DBSCAN + occlusion bridge Centerline fit 1 m bins, PCA axis Face merge 2 faces → 1 rail Classify + score type, confidence Outputs JSON · XML · overlays Noise-wall channel 0.25 m grid · height histogram · post-gates runs in parallel on the same streamed points
One pass over the raw points feeds two independent evidence channels: a fine 0.10 m grid for guardrails and a coarser 0.25 m grid with a vertical height histogram for noise walls. Raw points are never accumulated — only these bounded grids survive the streaming pass.

Inputs: what a segment gives us

The road is split into ~143 segments (dataset A4_5). Each segment brings:

InputContentUsed for
*_run3_points.npzMLS point records (XYZ, millions of points), possibly several overlapping windowsAll evidence; streamed in ≤4M-point chunks
run3_planes.npzDivision-plane centres + normals along the roadThe road spine — a smoothed 3D spline used as centerline
topdown_tiles/*.json/.pngRaster frame metadata, RGB + intensity ortho tilesGeoreferencing the overlays and QC images
Key idea: every point is re-expressed in road coordinatesstation (metres along the spine), offset (signed metres left/right of the spine) and height (metres above local ground). Guardrails are trivial to describe in this frame: long, thin, at a fixed offset, ~half a metre tall.

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:

height above ground 0 m (road spine) 1.5 3.5 10 26 m → −1.5 −10 ← −26 m wall search band |offset| 1.5–26 m · height 0.3–8 m roadside corridor |offset| 1.5–10 m · height 0.2–2.0 m median zone |offset| 0.8–3.8 m W-beam ~0.6 m noise wall 2.5–8 m
Cross-section in road coordinates. Guardrail evidence is only collected in the blue/purple bands; the independent wall channel watches the wider, taller cyan band. Points above 2 m (trees, gantries) never enter guardrail detection at all.

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):

GateDefaultRejects
Min points per cell≥ 3isolated noise returns
Points in rail band 0.35–0.85 m≥ 2 and ≥ 40 %ground, kerbs, low vegetation
Mean height0.42 – 0.78 mmarkings (~0 m), walls/fences (>1 m)
Height spread per cell≤ 0.50 mfull-height structures, bushes
Tall-point fraction (1.3–4.5 m)≤ 12 %cells under trees / signs / gantries
Why it works: a W-beam presents an unmistakable vertical profile — a dense, thin band of returns at 0.45–0.75 m with almost nothing above it and only sparse post returns below. These five per-cell statistics capture exactly that, cheaply, without any learned model.

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):

5Centerline fit & acceptance gates

corridor points (top-down) 0.10 m cells → DBSCAN + bridge occlusion gap ≤ 8 m 1 m bins → smoothed centerline vertex = median lateral position per 1 m bin

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 gateDefaultIntuition
Length≥ 12 mrails 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 ma rail is thin; hedges are not
Straightness (≤ 25 m runs)≤ 0.5 m dev / 10 m windowshort runs must be near-straight
Cluster height p90−p10 / p95≤ 0.80 m / ≤ 1.15 muniform 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 ,
  • 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.

Merge: faces 0.3 m apart → 1 instance dashed = detected faces · green = exported centerline (faces: 2) Keep separate: barriers ≥ 1.3 m apart e.g. 1.74 m → two instances

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

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

GateRuleKills
Crest spreadp90−p10 of top profile ≤ 1.5 mtree rows, mixed clutter
Truck double-gatereject if crest ≤ 4.2 m and length < 20 mparked trucks / trailers
Adaptive planarityplanarity ≥ 0.55 (or ≥ 0.25 with vertical normal; line-degenerate bypass)volumetric vegetation
Carriageway rejectionkeep only at/outside the outermost same-side rail (fallback |offset| ≥ 6 m)vehicles between edge rails
Elevated-structure rejectionwall bottom starting > 2 m above DEMbridge 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 full corridor_exclusions audit 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)


Source: 3dai.iolabs.pointcloud.guardrailsdetect.py, corridor.py, geometry.py, dedupe.py, classify.py · defaults from guardrails.default.json · page generated 2026-07-17.