Handoff — implement fence detection
Summary
Target: add a fence detection channel to the guardrail detector, emitting fence instances that are explicitly distinguished from guardrails (w_beam/concrete), noise walls (noise_wall), and vegetation (trees / hedges).
Key fact to internalise before touching code: fences are abundant in the data and are today the dominant false-positive source. There is no fence class not because fences were never seen, but because three separate gates were built specifically to kill them. Implementing fence detection means recovering what those gates deliberately discard, without regressing guardrail precision. It is not a classifier-threshold job.
Good news: the customer schema already defines the target classes — Fence (§7.6, layer pPLC_Zaun), Bush/hedge, Tree/Tree Line/Tree Group, Wall and noise_wall — so the four-way distinction is a specified deliverable shape, not one you have to invent. See Export target.
Current state — where fences die today
Output vocabulary today (guardrails/classify.py:10, README.md:255): w_beam | concrete | cable_suspect | unknown, plus derived companions guardrail_support / guardrail_top_rail, plus the separate noise_wall channel. No fence anywhere in tracked history (verified by git log -S"fence" --all; the only hits are the word "defence" and gate design notes).
| Stage | File | What it does to a fence |
|---|---|---|
| Candidate + cluster gates | guardrails/candidates.py, _model_core.py | Keeps only compact rail-band returns: cell max_mean_height_m 0.78 m, max_cell_height_spread_m 0.50 m, max_cluster_p95_height_m 1.15 m. A 1.2–2.0 m fence with full-height post/mesh mass is discarded before classification ever runs (README.md:281). |
| Precision gate G2 | guardrails/precision_gate.py:98 | "sparse low far-outboard fence/crest" — the explicit fence killer, added in f9cf984 (AI3D-335). Design rationale: dev/analysis/sol_gate_design.md:121-125 (XML distance alone is unsafe; low + sparse + outboard_gap_med_m ≥ 6.0 conjunction is mandatory). |
| Edge gate E1 | guardrails/edge_gate.py | edge_distance_med_m > 5.0 m drops it. Calibrated on A1 060/066/085: real rails ≤ 3.7 m from an XML edge, vegetation/noise runs ≥ 5.4 m. Most fences sit in that far-outboard band (README.md:145-198). |
| Survivors | — | Fences that pass all three are mis-typed w_beam. sol_gate_design.md:448-474 names them and states the missing evidence: "a real rail should show a continuous elevated horizontal beam plus periodic posts; a fence is usually post/wire without the beam mass, and a crest line has neither." |
Every rejection is already auditable: drops land in guardrails.json["corridor_exclusions"] with reason, edge_rule/gate id and a metrics snapshot. That list is your fence candidate pool — see Validation set.
Discriminators — fence vs the four confusables
The per-point evidence available: XY, height-above-ground (hag), number_of_returns (optional NPZ column, AI3D-382+ exports), RGB → excess-green index. Reader helpers: guardrails/record_io.py (load_number_of_returns, load_rgb_exg) — currently only on the greenery branch, see Reusable machinery.
| Class | Vertical signature | Periodicity | Returns / colour | Position |
|---|---|---|---|---|
| guardrail | compact band, mean 0.45–0.75 m, p90−p10 spread ≈ 0.03 m along station; opaque beam mass; bounded above | posts at 1.33 / 2.0 / 4.0 m catalogue spacings | single-return (≈2.5 % multi-return rim doubles); non-green | ≤ 3.7 m from an XML carriageway edge |
| fence | tall thin sheet ≈ 1.0–2.5 m, low mass per metre, no concentrated horizontal beam band; posts run ground→top | strongly periodic posts, typically wider spacing than a rail; wire/mesh between | mesh is partially transmissive → elevated multi-return rate but not green | far outboard, outboard_gap ≥ 6 m; roughly parallel to the corridor but not hugging the edge |
| noise wall | dense continuous vertical sheet 2.5–8 m; planarity ≥ 0.55, |nz| ≤ 0.35; crest spread ≤ 1.5 m | none (continuous) | single-return, opaque | at/outside the outermost same-side rail |
| hedge | fence-like height and linearity — hardest case; irregular, unbounded-above top profile; no hard planar face | none | high neighbourhood multi-return rate + green vote (ExG) | same far-outboard band as fences; often along a fence |
| tree | blobby: low linearity and low planarity (geometry.py:878), large hag spread within a cell | none | strongly multi-return + green | anywhere; already reported in corridor_exclusions |
Practical ordering of evidence strength for the hedge-vs-fence call (the one that decides whether this feature is usable): 1) neighbourhood multi-return rate, 2) green vote (ExG) — note it is frame-dependent, roughly half of genuine low guardrail points read green in a single frame, so it must be a neighbourhood vote and never a per-point test, 3) post periodicity (a hedge has none), 4) bounded-above top profile / crest-spread regularity.
Recommended architecture
Build a separate evidence channel, do not widen the guardrail gates. The noise-wall channel is the working precedent and should be copied structurally:
- Own streaming grid accumulation (O(grid), never point lists) —
geometry.WallGridEvidence, 0.25 m XY cells + height histogram; a fence channel needs the 0.3–3.0 m band rather than 0.3–8 m. - Own cell gates (cf.
select_wall_cells: count, top height, vertical fill, occupied bins, bin span). - Fitting reuses
detect_instancesthrough a view config (wall_view_configmaps everywall_*override onto the sharedDetectorConfig— mirror this asfence_view_config). - Own post-gates after fit (planarity/linearity, crest spread, vegetation veto, post-cadence requirement).
- Own list in
guardrails.json("fences": [...]), additive schema, master switchfence_detection_enableddefaulting false until validated — off must be byte-identical to today. - Own display id prefix + colour in
guardrails/colors.py(single source of truth for overlays;Wnnis walls, pick e.g.Fnn). - Gate exemption: like
edge_gate_apply_to_walls=false, the fence channel must not be run through E1 — being far from the carriageway edge is a fence feature, not a rejection reason.
Config rule (repo law, README.md:23-51): to add a key, add the field with type/default/Field range to the matching model slice (_model_core.py / _model_wall.py / _model_posts.py, or a new _model_fence.py to stay under 500 lines/module) and the same key with the same default to guardrails/guardrails.default.json — nothing else. tests/test_config.py asserts model/JSON default parity. with_overrides() is the only sanctioned config derivation.
Export: guardrails/export.py passes feature['type'] verbatim to write_xml.create_xml, which does not validate type strings — a fence type flows through the schema-1.1 XML path mechanically, the same way guardrail_support does. Whether the customer's schema has a legitimate fence feature type is an open question (below).
Reusable machinery — do not rebuild these
| Need | Where it already exists |
|---|---|
| Neighbourhood multi-return rate + green (ExG) vote, ball neighbourhood in (x, y, hag), shared KD-tree, "untrusted neighbourhood may only KEEP" semantics | guardrails/mask_filters.py (492 lines) on branch t3code/improve-guardrail-greenery-discrimination, worktree /home/ai/.t3/worktrees/3dai.iolabs.pointcloud.guardrails/t3code-fc369e5b @ 1bba057 (AI3D-387). Knobs: returns_filter_*, greenery_floor_*. Tests: tests/test_mask_filters.py, tests/test_greenery_floor.py. |
number_of_returns + RGB/ExG readers with the "column absent in old exports" contract | guardrails/record_io.py on the same branch (load_number_of_returns, load_rgb_exg). |
| Post periodicity: low-band histogram, peak prominence over a local noise floor, catalogue-spacing snap, confidence | guardrails/posts.py (PostCadence, RunLowBandEvidence, PostObservation). Config in _model_posts.py: post_low_band_min_m 0.10 / post_low_band_max_m 0.35, post_catalog_spacings_m (1.33, 2.0, 4.0), post_peak_min_prominence, post_peak_noise_sigmas. A fence needs a taller low band and its own spacing catalogue. |
| "Bounded above" test — the existing defence against a hedge/parapet standing over a member | guardrails/posts.py:1447-1460 (measure_beam_bottom / detect_top_member) and docs/AI3D-360-v-support-separation-design.md:909-915. |
Planarity / linearity / plane_normal_z_abs from streamed 3D moments (no point retention) | guardrails/geometry.py (wall PCA path), _wall_plane_evidence_accepted. |
| Instance fitting, face merge, occlusion bridging, centerline polylines | guardrails/geometry.py, guardrails/dedupe.py — reuse via a view config, do not fork. |
| Auditable rejection sink | corridor_exclusions in guardrails/corridor.py + detect.py. |
Export target — Fence already exists in schema 1.1
confirmed Source: /home/ai/dev/3dai.iolabs.pointcloud.modellingexport/docs/xml_schema_1_1_spec.md §7.1 / §7.6 / §12 layer table. The customer-facing class is defined and unclaimed, and so are the classes it must be distinguished from:
| § | Feature/Type | Container / geometry | Layer | Poster col |
|---|---|---|---|---|
| 7.6 | Fence | Polylines, ≥2 vertices, Annotation Display=false, no SortingCode, no Polyline/Type | pPLC_Zaun | 4 |
| 7.4 | Bush (= hedge, Hecke) | Splines, ≥2 control points, Display=false | sPLC_Hecke | 2 |
| 7.2 / 7.3 / 7.5 | Tree Line / Tree Group / Tree | Splines / closed Polyline / Point with trunk+crown Shapes | sPLC_Laubbaumreihe / Nutzungsart / PLC_Laubbaum | 1a / 1b / 3 |
| 7.8 | noise_wall | Polylines, attrs height_m, length_m, no Annotation | pPLC_Lärmschutzwand | 6 |
| 7.19 | Wall (masonry) | Polylines, attrs height_m, length_m | pPLC_Mauerwerk | 17 |
| 7.25 | Verge Post (Randpfosten) | Points, height_m, width_m, layer provisional (O-16) | PLC_Randpfosten | — |
This settles the taxonomy question: the four-way separation Miro asked for maps one-to-one onto schema classes — fence → Fence (polyline), hedge → Bush (spline!), tree → Tree/Tree Line/Tree Group, wall → Wall vs noise_wall. Note the container differs between Fence (Polylines) and Bush (Splines), so the classification decision changes the geometry container, not just a type string.
Writer behaviour: create_xml MUST NOT validate Type against a closed vocabulary (R-4.9.1) — producers may add classes — so emitting Fence needs no export-package change. Scope decision to confirm with Miro: whether the guardrails detector is the right producer for Fence/Bush, or whether that belongs to the segmentation pipeline (3dai.iolabs.pointcloud.3dsegmentation) — the guardrails repo already owns noise_wall, which is the precedent for it living here.
Validation set and evaluation protocol
no ground truth Confirmed 2026-07-18 (Miro, docs/plans/tcs-guardrails-recall-20260718.html:226): there is no client Bestand/asset inventory, step7_xml/abschnitt_4_5 is empty, and per-Saferoad-taxonomy reporting is unsupported. Evaluation is visual — a vision-capable agent inspects overlays; if a visible fence is undetected or a rail is stolen by the fence channel, fix it.
Known fences already identified (use as the seed positive set)
From dev/analysis/sol_gate_design.md:448-468, runs that survived the gates and are "mostly w_beam-labelled fences/vegetation":
A4/5: seg042/id1,id5,id9,id10; seg056/id10; seg058/id5;
seg076/id5,id6,id7,id9; seg088/id10; seg089/id7; seg090/id4
A2/A3: long w_beam "fence" lines at 15-30 m XML distance
Hard negative (must stay a guardrail): A3 seg164/id0 — near-identical
summarized evidence to a fence: 52.07 m XML distance, 0.695 m height,
0.925 confidence, empty height profile, outboard gap 11.78 m
Plus: everything already in corridor_exclusions with reason: "edge_gate" / precision-gate G2 across the A4_5 run outputs in out_a45_* — a ready-made, pre-filtered candidate pool with metrics snapshots attached.
Data
- A4_5 lane points:
/mnt/d/ai3d-aml/abschnitt_4_5_lane_points(143 segments, 367 detected runs;run3_planes.npzspine manifest,run7_lanes_*.xmllane XML). - A1/A2/A3 edges:
/mnt/d/ai3d-aml/edges_a123(a1_b000,a1_b001,a2,a3). - Segment-085 GT harness + A/B render tool + parameter sweep from AI3D-387:
dev/analysis/returns_085/sweep_085.pyon the greenery worktree — closest thing to a labelling harness that exists; extend it for fences rather than writing a new one.
Commands
uv sync
uv run python -m guardrails.detect \
--data-dir /mnt/d/ai3d-aml/abschnitt_4_5_lane_points \
--segments 042,056,058,076,088,089,090 \
--out out_fence/ \
--set fence_detection_enabled=true
uv run python -m guardrails.overlay_topdown --data-dir <lane_points> --out out_fence --segments all
uv run python -m guardrails.render_perspective --data-dir <lane_points> --out out_fence
uv run pytest
uv run ruff check . # line-length 100, pydocstyle google, D rules on (tests exempt)
Regression bar: with the channel off, guardrail output must be byte-identical to the current baseline (the wall channel set this precedent — "Guardrail list geometry and IDs are otherwise unchanged, regression-verified vs. the production baseline"). With it on, the A4_5 367-run guardrail set must not lose members.
Next steps (ordered)
- Measure before designing. Extend
dev/analysis/with a script that dumps, for every currentcorridor_exclusionsentry and every accepted run across A4_5 + the A2/A3 fence lines: hag histogram, per-cell hag spread, planarity/linearity, crest profile spread, bounded-above flag, neighbourhood multi-return rate, ExG green rate, post-cadence peak/prominence at several low-band windows. Output one CSV. Do not pick thresholds before this exists. - Label the seed set visually (perspective + top-down renders) into fence / hedge / tree / wall / rail for the runs listed above, so the CSV has a target column. Keep the labels in-repo as a small JSON next to the analysis script.
- Separate hedge from fence first. It is the make-or-break discriminator and it decides whether the returns column is mandatory. If
number_of_returnsis absent from the A4_5 export, establish that early — the whole vegetation veto depends on it (fall back to ExG + geometry only, and say so). - Build the channel:
_model_fence.pyslice + JSON defaults, grid accumulation, cell gates,fence_view_configfit, post-gates,"fences"output list, colour + display id, E1 exemption,fence_detection_enabled=falsedefault. - Wire the vegetation veto from the cherry-picked
mask_filters.pyneighbourhood machinery (neighbourhood rates only, untrusted → KEEP). - Cross-channel arbitration: one physical object must produce one instance. Decide precedence when a candidate qualifies as both fence and noise wall (height + planarity + continuity), and when a fence line runs parallel to and within merge distance of a rail. Add tests for both.
- Export + overlays: fence Feature blocks through
export.py, legend rows drawn only when the overlay contains a fence (matching the derived-class rule, so overlays without fences stay pixel-identical). - Validate visually on the seed segments, then the full A4_5 sweep; report recovered fences, mis-stolen rails (must be zero), and residual hedges typed as fences.
Risks and open questions
- Which producer owns
Fence? ask Miro The class is specified (§7.6) but no producer emits it. Confirm the guardrails repo is the right home versus the segmentation pipeline before building export plumbing — the detection channel is worth building either way, since it converts today's silent FPs into explained, typed rejections. - Hedge output shape. If hedges must also be emitted, they are
Bushon a Spline container, which this repo has never produced (everything here is polylines). Either restrict scope to "fence detected, hedge rejected with reason", or budget for spline fitting. - Guardrail label vocabulary is still open and will collide with any fence labelling work:
docs/guardrail-label-vocabulary-open.mdon the greenery branch (schema-1.1Annotation/Text, EDSP / ESP 2,0, unanswered by HBW as of 2026-09-04, v0.5.0).Fenceitself carriesDisplay=falseand no text, so it sidesteps that question entirely. - No transferable public definition.
research/guardrail_benchmark_taxonomies.mdis a substantiated survey: SemanticKITTI folds crash barriers intofence; nuScenes puts permanent guardrails and fences both instatic.manmade; SensatUrban folds highway barriers intoWall. Conclusion recorded there: these buckets are not interchangeable and a production taxonomy needs its own explicit component rules. Do not import a dataset's class definition. - Precision regression is the real danger. G2 and E1 exist because loosening far-outboard/low-mass rejection previously produced FPs. Any change that makes the guardrail path more permissive to reach fences is the wrong shape — keep the channels separate and prove the off-state is identical.
- Hedge-along-fence is the expected failure mode (a hedge grown through a wire fence gives you fence geometry and green multi-return). Decide the intended output for that case up front rather than discovering it in the sweep.
- Returns column availability:
number_of_returnsis optional in the per-segment NPZ contract (pre-AI3D-382 exports lack it) and zero is not a legal LAS count, so any rule must gate on the presence flag, not on the values. - Memory: 32 GB RAM / 16 GB VRAM Azure target. The wall channel is O(grid) by design for this reason; a fence channel that retains point lists will not deploy.
Suggested skills and hygiene
/diagnosefor the measurement pass;/tddfor the channel (the repo has 22 test modules and strong per-feature test precedent —test_detect_wall_integration.py,test_wall_geometry.pyare the templates)./code-reviewbefore merge;bitbucket-prto file the PR (git@bitbucket.org:ioholding/...).- Ticket: file under a new AI3D number; related tickets are AI3D-335 (precision gate), AI3D-374 (edge distance filters), AI3D-376 (schema 1.1), AI3D-387 (returns/greenery).
- Commit often on a dedicated branch;
mainhas an untracked pile ofout_*run dirs andrun_*.logfiles — do not commit those.