AI3D-336 — “Too far from the edge line” delineator filter
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.
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:
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:
- Tier 1 — distance > 30 m: remove (current behaviour), optionally sparing high-saturation points (would save the three A1B0 junction posts at 30–48 m with 50k+ saturation).
- Tier 2 — distance > 15 m and saturation < 150: remove. Adds 12 kills across the three corridors (3 A45 + 8 A1B0 + 1 A1B1), all rescue-stage or isolated reflective hits in fields, while every confirmed junction/ramp post stays (their saturation is ≥ ~200).
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.
| Output | Delineators before | Removed | What was removed |
|---|---|---|---|
out_final (A4/5, hand-cleaned reference) | 88 | 0 | nothing — the clean set is untouched |
signs2_A45 (production) | 589 | 12 | tier 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) | 2258 | 11 | tier 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) | 93 | 4 | tier 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):
| Column | Meaning |
|---|---|
edgeline_edge_dist_m | distance (m) from this cluster to the nearest step7 edge line, ramps included |
edge_filtered_from | on 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"
}
}
- No
run7_lanes*.xmlbeside the segments → the stage logs a warning and does nothing. - Re-running with a larger threshold restores previously removed rows (the original label is kept in
edge_filtered_from); running twice with the same threshold changes nothing. - Only type
delineatoris screened — signs, posts and gantries are never touched.
Technical appendix
Files changed (commit 3c9098c, 12 files, +730/−35)
edgedist.py(new) — the post-pass: annotate every cluster, flip far accepted delineators, pruneverticalsigns.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, vectorizededge_distances()); the carriageway band gate (band_at) is deliberately unchanged. Cache prefix bumpedrun7xml_→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 nineedge_linekeys (xml_*, carriageway widths,paint_fallback_enabled) were unsettable from user configs.tests/test_edgedist.py(new) + extensions totest_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.