AI3D-336 — “Too far from the edge line” delineator filter

2026-08-20 · for Miro and anyone new to the vertical-signs detector · branch t3code/delineator-edge-line-filter, commit 3c9098c

Summary

The detector sometimes labels orchard and field stakes as roadside delineator posts (Leitpfosten). A new cleanup step now removes any accepted delineator that stands more than 30 m from the nearest road edge line, where the edge lines come from the step7 lane XML (run7_lanes*.xml).

The filter now has two tiers: tier 1 removes anything beyond 30 m from every known line (edge features + painted lane lines), and tier 2 removes the 15–30 m band when there is additionally no paved surface of any kind nearby (road-context saturation < 150). Tested on Battlebox against Abschnitt 1 (both branches) and Abschnitt 4/5: it removes 27 false delineators (12 + 11 + 4) across the three production outputs, touches nothing on the hand-cleaned reference output, and is idempotent. Removed entries are parked in a per-segment edge_filtered_detections.json stash and come back verbatim if the config is loosened.

What it does

Real delineators stand about 0.5–2 m outside the carriageway edge — that is their job. So if something the detector called a “delineator” stands tens of metres from every edge line the road model knows, it is almost certainly a stake in a field, not road furniture. The filter runs as the last post-processing step, after the stages that add delineators back (lattice, reject-rescue, rail half-posts), so it screens their output too — the rescue stage is where most far-off false positives come from.

edge lines (from step7 XML) real delineators, 0.5–2 m off the edge → kept 30 m from nearest edge line (far_max_distance_m) plantation-stake row in a field → removed
Top-down sketch. The step7 XML describes the road’s edge lines (blue). Everything the detector accepted as a delineator is measured against the nearest edge line — of any road piece, including motorway ramps. Beyond 30 m (amber dashes) it is removed.

One parsing change was needed to make this safe: the XML also contains ramp edges without a role label (features named like C_6, no “outer/inner” in the ID). The loader used to throw those away. In the newest Abschnitt 4/5 XML they are 28 of the 38 edge features — without them, real delineators along ramps would look up to 92 m “from the road” and be wrongly deleted. They are now part of the distance pool (and only of the distance pool; the on-road band gate is unchanged).

Why 30 m

The threshold was not guessed — it comes from measuring every previously-accepted delineator in the August production outputs against the newest XMLs, then checking the far ones by eye in the QC imagery. Two populations emerged with a gap between them:

Measured distances of all 2,940 production delineators to the nearest step7 edge line, and distance vs road-context saturation
Measured data, all 2,940 production delineators (A4/5 + A1 both branches), after the lane-line change: distance is now the minimum over edge features AND painted lane-line families (Center Lines, Central Axis). Ringed points moved >1 m closer because of a painted line. Top: distance per source — everything beyond the 30 m cut is rescue-stage stake rows plus one junction post. Bottom: distance against road-context saturation; the far-but-high-saturation points are posts beside paved ramps missing from the XML entirely, and the shaded low-saturation corner is the candidate zone for a tighter second-tier cut (not implemented).

Update 2026-08-20 (later): painted lane lines are now part of the measurement

The distance pool was extended from the edge features to ALSO include the XML’s painted lane-line families ("Center Lines" dash segments, "Central Axis", "Single-Side Central Axis") — knob edge_line.far_include_lane_lines, default on; new audit column edgeline_line_dist_m next to edgeline_edge_dist_m. Effect on the production outputs at the same 30 m cut: removals go 18 → 15, and the three no-longer-removed are precisely the likely-real ones — a rail half-post beside a marked-but-edgeless area (44.4 m → 4.6 m) and two of the three borderline junction posts in A1 segment 121 (35.8 → 28.3, 30.4 → 21.7). The A4/5 junction posts of segments 132–135 barely move — that infield has no lines of any family — so the 30 m default stands.

Update: the two-tier rule below is now implemented (default on)

Yes — but not on distance alone. The step7 XML already contributes its ramp edges (the roleless C_* features are in the distance pool), yet its ramp coverage has gaps, and real posts along unmapped ramp stretches measure 15–48 m. What separates those from field stakes at the same distance is road-context saturation (roadctx_n_sat, already in clusters.csv): a post beside an unmapped paved ramp sees hundreds to tens of thousands of saturated returns within 15 m; a stake in an orchard sees a handful. A candidate two-tier rule measured on the production outputs:

Implemented as described (without the tier-1 saturation exemption — the lane-line pool already spares the high-saturation junction posts except the one still 39 m from every line). A stored saturation of 0 is treated as "not measured" and is never tier-2-killable — this keeps the tier inert on legacy outputs that predate the roadctx_n_sat column, verified on out_final. Rail half-posts stay tier-1-only (no per-row saturation record; they are anchored to detected guardrails anyway).

Results on real data

Validated two ways on Battlebox: (1) the post-pass applied to copies of the 2026-08-12 production outputs, and (2) full end-to-end detector runs on Abschnitt 1 (both branches, production config) and Abschnitt 4/5 segment 066 — both give the same removals.

OutputDelineators beforeRemovedWhat was removed
out_final (A4/5, hand-cleaned reference)880nothing — the clean set is untouched
signs2_A45 (production)58912tier 1: plantation rows in segments 038 & 049 (35–50 m, all rescue-stage); tier 2: three more low-saturation rescue rows (17–19 m, segs 038/085). The 44 m rail half-post of seg 012 is no longer removed — painted lane lines put it 4.6 m from a road
signs2_A1B0 (production, Abschnitt 1 branch 0)225811tier 1: rescue rows 014/048 and one junction-121 post still 39 m from every line; tier 2: eight low-saturation rescue/reflective rows at 15–29 m (segs 023/073/085/104/123/124). Two of the three borderline junction-121 posts are spared by the lane lines
signs2_A1B1 (production, Abschnitt 1 branch 1)934tier 1: rescue cluster in segment 007 (36.6–41.2 m); tier 2: one more at 29.7 m / sat 28 (seg 008)

caveat Segment 121 (A1 branch 0) is a motorway junction whose infield ramp is not in the XML. Two of its three removals (reflective_top at 35.8 and 47.9 m) could be real ramp posts — the only removals not clearly false. If recall there matters, check the crops or raise far_max_distance_m to 50 for that corridor.

Where to see the difference in the data

Three places, from coarsest to finest:

1. Run-level tally — post_pass_summary.json

Every detector run now reports the stage next to the other post-passes. From the Abschnitt 1 test run:

{
  "delineator_lattice": 0,
  "rescue_stage": 12,
  "rail_half_post": 67,
  "far_from_edge_line": 5   ← new
}

2. Per-detection audit — segment_*/clusters.csv

Two new columns at the end of every row (every cluster gets them, not just removed ones — so the decision can be audited or re-run with a different threshold without re-detecting):

ColumnMeaning
edgeline_edge_dist_mdistance (m) from this cluster to the nearest step7 edge line, ramps included
edge_filtered_fromon removed rows: the label the row carried before the filter took it (e.g. rescue_stage)

A removed row reads: accepted=False, type= (empty), reason=far_from_edge_line, e.g. in the A1 test run, segment_048/clusters.csv cluster 40: edgeline_edge_dist_m=43.115, edge_filtered_from=rescue_stage.

3. Final deliverable — segment_*/verticalsigns.json

The removed detections are simply gone from the JSON (which is what downstream LAS fusion reads), e.g. signs2_A45 drops from 589 to 579 delineator entries.

Concrete places to look on Battlebox

Path (Battlebox, WSL)What it is
~/edgedist_test/e2e_A1B0/fresh end-to-end run, Abschnitt 1 branch 0, segments 014/026/048/121, production config — 5 removals
~/edgedist_test/e2e_A1B1/same for branch 1, segments 007/008 — 3 removals
~/edgedist_test/e2e_out/A4/5 segment 066 smoke run — 0 removals, columns annotated
~/edgedist_test/signs2_A45/ etc.copies of the production outputs with the filter applied (originals in ~/seg3d_out/ are untouched — diff them)
/mnt/d/a123-data/Abschnitt_1/branch_00*/lane_points/A1 input data; the latest step7 XML (2026-07-22) already sits beside the segments

Quick way to list every removal in an output tree:

grep -l far_from_edge_line segment_*/clusters.csv | while read f; do
  awk -F, 'NR==1{for(i=1;i<=NF;i++)h[$i]=i} $h["reason"]=="far_from_edge_line"{print FILENAME, $h["cluster_id"], $h["edgeline_edge_dist_m"]"m"}' "$f"
done

Tuning / turning it off

Both knobs live in the edge_line section of the config JSON (defaults shown):

{
  "edge_line": {
    "far_filter_enabled": true,
    "far_max_distance_m": 30.0,        // tier 1: distance alone
    "far_include_lane_lines": true,    // measure against painted lane lines too
    "far_tier2_enabled": true,         // tier 2: distance + no pavement nearby
    "far_tier2_distance_m": 15.0,
    "far_tier2_max_saturation": 150    // roadctx_n_sat below this = "no pavement"
  }
}

Technical appendix

Files changed (commit 3c9098c, 12 files, +730/−35)
  • edgedist.py (new) — the post-pass: annotate every cluster, flip far accepted delineators, prune verticalsigns.json (incl. JSON-only rail half-posts), restore on threshold raise.
  • run7_xml.py — roleless ramp edges (C_*) now loaded into a union distance pool (Run7RoadModel.all_edges, vectorized edge_distances()); the carriageway band gate (band_at) is deliberately unchanged. Cache prefix bumped run7xml_run7xml2_.
  • detect.py — stage registered last in the post-pass order (lattice → rescue → rail half-posts → far-from-edge-line); two append-only CSV columns.
  • _config_roadcontext.py, _config.py, verticalsigns.default.json — the two new keys; also fixes a pre-existing bug where nine edge_line keys (xml_*, carriageway widths, paint_fallback_enabled) were unsettable from user configs.
  • tests/test_edgedist.py (new) + extensions to test_run7_xml.py, test_detect.py, test_config_split.py — 573 tests pass, ruff clean.
Why the stage runs last, and what feeds it

Three earlier post-passes can add delineators after per-segment detection: lattice admission, reject-rescue, and rail half-posts. The rescue stage is the main producer of far-from-road false delineators (it re-admits rejected clusters on physical plausibility alone, and plantation stakes are physically plausible delineators). Running the distance screen after all three means every admission path is covered by the same rule.

JSON-only detections (rail half-posts have no clusters.csv row) are screened by position directly; they cannot be auto-restored by a threshold raise, but a full re-run re-emits them before the filter runs, so the pipeline stays idempotent end-to-end.

Evidence trail
  • Distance censuses: out_final (88 accepted delineators), signs2_A45/A1B0/A1B1 (589/2258/93), against the newest per-corridor XMLs from the N: drive (A4/5: run7_lanes_20260723-163130.xml; A1: run7_lanes_branch_000_20260722-092426.xml, …branch_001_20260722-092434.xml).
  • Visual confirmation in QC overlays: segment 049 (A4/5) far “delineators” sit in orchard rows beside the motorway; segment 087’s 13 m one is a stake on the embankment; segments 131–135 (A4/5) and 121 (A1B0) are junctions where ≥10 m detections line real ramps.
  • XML geoshift verified identical to the segment frame (run3_geoshift.json), so distances are computed without any coordinate transform.