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 it059/7 REJECTED [body_like] - a BROWN hedge: green 0.04, so the colour branch is silent. Shape catches it028/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 out077/6 - thin spike, width 0.325, crest 0.86, at 40.5 m. Probably an overgrown fence077/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
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:
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
32 labels is a small set to have tuned this many thresholds
against, and several came from a single object: the sheet floor at
0.25 sits between 078/3 at 0.28 and 028/8 at 0.17, and the flank ratio at 3.0
between 103/6 at 2.34 and 028/7 at 5.05. Those are real gaps, but they are
gaps of one or two samples.
The 14-segment test split has never been opened and I am
leaving it closed. It is the only genuinely held-out data left and it should be
spent on a release decision, not on this loop.
Six accepted candidates carry no verdict from you (077/4,
077/6, 078/5, 112/9, 122/0, 128/5). Three are my own readings — one fence, one
probable fence, one hedgerow — two I could not resolve, and 122/0 has never
been looked at by anyone. At 29 accepted, that is a fifth of the output resting
on my judgement rather than yours.
Hedges are still silent rejects. You said you would not be
against a separate class for them; emitting one would make the vegetation and
body rules auditable instead of invisible. That is an output-contract change,
so I have not made it unasked.