Handoff — implement fence detection

2026-09-07 · next agent on 3dai.iolabs.pointcloud.guardrails · repo /home/ai/dev/3dai.iolabs.pointcloud.guardrails, branch main @ 81760b2

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

StageFileWhat it does to a fence
Candidate + cluster gatesguardrails/candidates.py, _model_core.pyKeeps 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 G2guardrails/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 E1guardrails/edge_gate.pyedge_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).
SurvivorsFences 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.

ClassVertical signaturePeriodicityReturns / colourPosition
guardrailcompact band, mean 0.45–0.75 m, p90−p10 spread ≈ 0.03 m along station; opaque beam mass; bounded aboveposts at 1.33 / 2.0 / 4.0 m catalogue spacingssingle-return (≈2.5 % multi-return rim doubles); non-green≤ 3.7 m from an XML carriageway edge
fencetall thin sheet ≈ 1.0–2.5 m, low mass per metre, no concentrated horizontal beam band; posts run ground→topstrongly periodic posts, typically wider spacing than a rail; wire/mesh betweenmesh is partially transmissive → elevated multi-return rate but not greenfar outboard, outboard_gap ≥ 6 m; roughly parallel to the corridor but not hugging the edge
noise walldense continuous vertical sheet 2.5–8 m; planarity ≥ 0.55, |nz| ≤ 0.35; crest spread ≤ 1.5 mnone (continuous)single-return, opaqueat/outside the outermost same-side rail
hedgefence-like height and linearity — hardest case; irregular, unbounded-above top profile; no hard planar facenonehigh neighbourhood multi-return rate + green vote (ExG)same far-outboard band as fences; often along a fence
treeblobby: low linearity and low planarity (geometry.py:878), large hag spread within a cellnonestrongly multi-return + greenanywhere; 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:

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

NeedWhere it already exists
Neighbourhood multi-return rate + green (ExG) vote, ball neighbourhood in (x, y, hag), shared KD-tree, "untrusted neighbourhood may only KEEP" semanticsguardrails/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" contractguardrails/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, confidenceguardrails/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 memberguardrails/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 polylinesguardrails/geometry.py, guardrails/dedupe.py — reuse via a view config, do not fork.
Auditable rejection sinkcorridor_exclusions in guardrails/corridor.py + detect.py.

The greenery branch is unmerged and carries an unrelated scratch_376/ payload. Cherry-pick mask_filters.py + record_io.py deliberately rather than merging the branch wholesale.

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/TypeContainer / geometryLayerPoster col
7.6FencePolylines, ≥2 vertices, Annotation Display=false, no SortingCode, no Polyline/TypepPLC_Zaun4
7.4Bush (= hedge, Hecke)Splines, ≥2 control points, Display=falsesPLC_Hecke2
7.2 / 7.3 / 7.5Tree Line / Tree Group / TreeSplines / closed Polyline / Point with trunk+crown ShapessPLC_Laubbaumreihe / Nutzungsart / PLC_Laubbaum1a / 1b / 3
7.8noise_wallPolylines, attrs height_m, length_m, no AnnotationpPLC_Lärmschutzwand6
7.19Wall (masonry)Polylines, attrs height_m, length_mpPLC_Mauerwerk17
7.25Verge 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

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)

  1. Measure before designing. Extend dev/analysis/ with a script that dumps, for every current corridor_exclusions entry 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.
  2. 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.
  3. Separate hedge from fence first. It is the make-or-break discriminator and it decides whether the returns column is mandatory. If number_of_returns is 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).
  4. Build the channel: _model_fence.py slice + JSON defaults, grid accumulation, cell gates, fence_view_config fit, post-gates, "fences" output list, colour + display id, E1 exemption, fence_detection_enabled=false default.
  5. Wire the vegetation veto from the cherry-picked mask_filters.py neighbourhood machinery (neighbourhood rates only, untrusted → KEEP).
  6. 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.
  7. 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).
  8. 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

  1. 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.
  2. Hedge output shape. If hedges must also be emitted, they are Bush on 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.
  3. Guardrail label vocabulary is still open and will collide with any fence labelling work: docs/guardrail-label-vocabulary-open.md on the greenery branch (schema-1.1 Annotation/Text, EDSP / ESP 2,0, unanswered by HBW as of 2026-09-04, v0.5.0). Fence itself carries Display=false and no text, so it sidesteps that question entirely.
  4. No transferable public definition. research/guardrail_benchmark_taxonomies.md is a substantiated survey: SemanticKITTI folds crash barriers into fence; nuScenes puts permanent guardrails and fences both in static.manmade; SensatUrban folds highway barriers into Wall. 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.
  5. 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.
  6. 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.
  7. Returns column availability: number_of_returns is 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.
  8. 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