Fence detection — passes 10 and 11

AI3D-388 · branch t3code/implement-fence-detection · 2026-09-08 · 638 tests green · channel still default-off · off-state re-verified this pass: guardrails.json byte-identical to main's own output on segment 002, the XML differing only in its CreationDate

Against your 32 verdicts: 19 of 19 fences kept, 9 of 10 non-fences rejected. The one that survives is 015/2, the rest-area guardrail, and section 3 explains why I do not think this channel can take it. 312 candidates → 29 accepted, and the run's own verdicts match an offline replay of the shipped gates exactly, candidate for candidate.

This is the first pass with no labeller. Where I judged a candidate myself the reading is recorded in a separate file from yours (labels_agent_p10.json), so your ground truth stays uncontaminated by mine, and the two objects I could not resolve (112/9, 078/5) are scored in neither direction rather than guessed.

Where it stands

1 · The class boundary, applied: 19 of 19 fences, one false positive

077/5 REJECTED [body_like] - solar-panel array, 0.99 m thick, sheet 0.26. No material rule could name it
077/5 REJECTED [body_like] - solar-panel array, 0.99 m thick, sheet 0.26. No material rule could name it
059/7 REJECTED [body_like] - a BROWN hedge: green 0.04, so the colour branch is silent. Shape catches it
059/7 REJECTED [body_like] - a BROWN hedge: green 0.04, so the colour branch is silent. Shape catches it
028/7 REJECTED [structure_like] - tall grass: 5.05x more mass beside the line than on it
028/7 REJECTED [structure_like] - tall grass: 5.05x more mass beside the line than on it

Every one of the ten objects you called not a fence is now cut by a rule written for its own failure class, and all nineteen fences survive. The two rules that did most of that work were added from your last batch of verdicts:

  • body_like — the fence prior is a sheet, so a candidate that is a body by both thickness readings is rejected whatever it is made of. This is your "thin = fence, thick = wall" rule made explicit, and it is what finally caught the solar arrays and the brown hedge, which no colour or transmissivity test can see.
  • structure_like — armed at last, after five passes of measuring: a field extends laterally at fence height, so its flank is both continuously occupied and heavier than the line. Your tall grass reads 0.79 occupancy and 5.05× mass; the highest real fence reads 2.34.

2 · Retiring the 35 m seed rule: two fences back, one hedgerow in

128/5 - mesh on regular posts to 2.2 m, sheet 0.86, anchored at 38.0 m. Rejected ONLY for being 3 m too far out
128/5 - mesh on regular posts to 2.2 m, sheet 0.86, anchored at 38.0 m. Rejected ONLY for being 3 m too far out
077/6 - thin spike, width 0.325, crest 0.86, at 40.5 m. Probably an overgrown fence
077/6 - thin spike, width 0.325, crest 0.86, at 40.5 m. Probably an overgrown fence
077/4 - the cost: no spike, mass over 10 m of lateral, scalloped top, green 0.75. A hedgerow
077/4 - the cost: no spike, mass over 10 m of lateral, scalloped top, green 0.75. A hedgerow

The corridor accumulates evidence to 45 m but a pass-4 rule required a candidate to be anchored inside 35 m, on the premise that the extra 10 m would otherwise propose clutter. Measured, that premise does not hold: the rule rejects 32 of 312 candidates and only four of them would pass every other gate — the other 28 are clutter the shape and material rules catch anyway.

Of those four, two are fences (the first is unmistakable), one is a hedgerow and one I could not resolve. I checked whether any cue separates the hedgerow from the overgrown fence beside it: separating them on sheet share (0.34 against 0.42) would also delete three of your fences (008/7 0.32, 078/3 0.28, 103/6 0.32). So the trade is stated rather than tuned away — a false positive is reviewable, a missed fence is not — and the threshold stays a config key, so a narrower corridor re-arms the rule.

What I got wrong

3 · A correction: the crossing rule does not catch 015/2

015/2 ACCEPTED - your guardrail. 0 crossings; its nearest guardrail is 12.9 m away
015/2 ACCEPTED - your guardrail. 0 crossings; its nearest guardrail is 12.9 m away

I told you the new guardrail_crossing gate would take this. It does not. 015/2 reads zero crossings and its nearest guardrail is 12.9 m away — the green lines only look like they cut through it because the close-up renderer plots a whole guardrail polyline whenever any one of its vertices lands in the window. We were both reading a drawing artefact, and I should have checked the number before claiming the fix.

Measured across the sweep the rule fires on 6 of 312 candidates and every one is already rejected by guardrail_overlap at 0.0–0.13 m clearance, so on this corpus crossing is a strict subset of overlapping. It is kept — null-safe, one orientation test per vertex pair, and the failure it describes is real — but as a measured no-op, and the README says so.

015/2 therefore stays an open false positive. On every reading this channel takes it is your wooden post-and-rail fence 048/8: opaque 0.06 against 0.12, crest 0.91 against 0.88, width 0.54 against 0.48, top 1.25 m against 1.45 m. The remaining avenue is semantic rather than geometric — it stands at a rest area, and if the lane XML models parking zones the way it models carriageways, a candidate on a rest-area edge could be scoped out the way inside_carriageway scopes out the road.

Which rules actually do work

Fifteen rules, and a fair question is how many earn their place. Turning each one off in isolation and counting the candidates that then become accepted:

ruleuniquely rejectsnote
body_like7the workhorse of this pass
inside_carriageway4the edge-line gate
rail_like_top3
ragged_crest3
structure_like2tall grass, solar panel
guardrail_overlap1
wall_like1
vegetation, tall_mass, sparse, vehicle_like, wall_overlap, sparse_profile, guardrail_crossing, outside_corridor0redundant on this corpus

Two of those zeroes are worth naming. vegetation is now strictly subsumed by body_like: since the thin-sheet exemption is shared between them, every candidate the material rule rejects is also a body. It still earns its place by naming why — a hedge is reported as vegetation, a solar panel as a body — but it no longer changes a verdict. outside_corridor is unreachable by construction after this pass. The rest guard failure classes that simply do not occur in these 25 segments (parked cars, sparse evidence, walls); each was necessary when it was added, and removing them would regress on data outside this corpus.

The one architectural gap, now measured

Detection runs per segment, so a fence crossing a segment boundary is fitted twice and reported twice. I measured how much of the output that actually costs, by looking for accepted fences on the same side whose station ranges abut within 30 m at the same offset:

092/9  (10 m)  -- gap 0.0 m --  093/0 (103 m)      offsets -29.0 / -26.8
093/0 (103 m)  -- gap 0.0 m --  094/0 (104 m)      offsets -26.8 / -25.6
128/1  (66 m)  -- gap 0.5 m --  129/4  (27 m)      offsets -26.8 / -28.3
078/3  (29 m)  -- gap 3.0 m --  078/7  (17 m)      offsets  35.1 /  34.9

The first two chain: 092/9 → 093/0 → 094/0 is one 216 m fence reported as three features, each cut exactly at a segment plane (9514.2 m and 9616.2 m are segment boundaries, and the gaps are 0.0 m). 128/1 → 129/4 is another 93 m fence in two pieces. The last pair is fragmentation within one segment, so the fitter drops a run and restarts it 3 m later.

Stitching would turn 29 features into about 24 and give the consumer one feature per object. I have not built it: the detector's output contract is per-segment, and merging across segments changes what a segment's guardrails.json and XML mean. That is your call, not a threshold decision — but the cost of not doing it is now a number rather than a suspicion.

What I would not trust yet