AI3D-382 · detection pass 11: the skirt that works — and the hard column that had to go

7 Sep 2026 · tuning 018 / 060 / 066 / 085 · gate 003 / 022 / 023 / 087 / 093 / 094 · roots r9 (shipped, reference) | r11 (r9 + skirt_unpaint_enabled = true) | val_r11 (r9 + skirt + hard-column) · branch feat/AI3D-382-returns-recall @ 625b671 · writeups eval/r11_judgement.md, eval/val_r11_judgement.md · renders analysis/r11_renders/, analysis/val_r11_renders/ · previous: pass 8 · pass 9 · pass 10 · gallery · summary

  1. The skirt pass ships (default OFF, ON in the recommended config). Tuning gate and validation gate both pass with margin: 0 GT-tree points lost on the 085 human GT, 0 detections added or removed on all 10 segments, removal 0.23–2.35 % of tree paint per segment, 46/46 rendered regions judged correct (20 tuning + 26 gate), 0 wrong-tree removals, 0 crown holes.
  2. 085 human GT, the headline number of the whole ticket: precision 0.4893 (base) → 0.5431 (r9) → 0.6084 (r11), veg-tolerant 0.7413 → 0.8528 → 0.8908, with recall pinned at 0.7547 and tp identical at 156 209 in all three. Definition FP −22.1 %, hard FP −29.0 % against r9.
  3. hard-column unpaint is gone from the codebase. The gate criterion was explicit — revert if it hits nothing — and it hit nothing: 0 columns, 0 points, hard_column_count = 0 on all 322 validation trees, while the two known 003 mast cases stayed fully tree-painted. hardcolumn.py, its tests, its 15 knobs, its 4 CSV columns and its README section are deleted (53a1ee7).
  4. The lesson of this round is the naive rule. The rule that was swept (“unpaint everything below the lowest gap in the cell”) removed 85.4 % of GT tree paint at its best sweep row and 67 % of all tree paint on 066; the vision pass judged 99.83 % of removed points to be real crown or trunk. Three changes rescued it: a hard height cap, a 3×3-cell profile, and a minimum low-layer point count — and the trunk-column detector that was supposed to be the safety net was dropped, because with those three it had nothing left to protect.
  5. Review found and fixed one real bug and three doc drifts. min_gap_m used round() where the spec says ceil(), so a 0.3 m bin with a 1.0 m gap accepted 0.9 m of air (8264882, bit-identical at the packaged 1.0 / 0.25, 2 tests added). The unpaint window really starts at min_elev_m, nothing above the gap is required, and the README mask-count arithmetic omitted two passes (625b671).
  6. Cost: runtime +27.9 % on the gate split, worst segment 575 s = 9.6 min of the 20 min budget. The stage is a third full record-streaming pass that rebuilds the paint arrays from scratch; sharing one _collect_painted_points across hardblob / guardrail-mask / skirt is the obvious follow-up.
  7. Verdict skirt: keep + recommend hard column: reverted — 1 825 tests green, ruff clean, branch 13 ahead of origin, not pushed.

0. How to read the renders

Every comparison render is one spot in the raw no-scan-angle-filter cloud, left to right: true RGB | number_of_returns (grey = 1, orange = 2, red = ≥ 3) | BEFORE | AFTER. The BEFORE column always comes first; top row oblique, bottom row the same points top-down; every column is labelled in the image itself.

In sections 3–4 the removed points are drawn in white and drawn last, in both the BEFORE and AFTER panel, so kept paint can never occlude them — white in the BEFORE panel is exactly what disappears in the AFTER panel. White was chosen because sign-ish is magenta. Section 1 (the naive rule) predates that palette fix and simply shows paint before / after.

unmaskedtreemedium_vegetationsign-ish (sign / pole / delineator)guardrailotherremoved by the skirt

Click any image for full resolution; ← / → (or swipe) step through the images of that card only; Esc closes.

1. The lesson — what the naive skirt rule did to the crowns

“Unpaint everything below the lowest gap” destroys trees 99.83 % of removed points were crown or trunk

Source skirt/JUDGE.md (30 renders, all viewed) and skirt/skirt_rule.py. Its ceiling table said all 9 063 painted cells on 085 have a ≥ 0.75 m gap. That was read as “the premise holds”. It was the bug: inside a crown, the 0.25 m bins of a single 1 m column are sparse, so the lowest gap lands inside the crown and everything under it goes — trunk, lower crown, the lot.

The naive rule on 085 (2317.39, 1229.64) — panels RGB | number_of_returns | r9 paint | paint after the swept rule. The 29.5 m conifer keeps its top and loses everything from the ground to ~12 m: trunk and lower crown. 4 726 482 points removed here alone. The top-down row is the diagnosis: square whole-cell holes punched through a closed crown — 1 m cells dropped over their full height.
The naive rule on 085 (2317.39, 1229.64) &mdash; panels <b>RGB | number_of_returns | r9 paint | paint after the swept rule</b>. The 29.5 m conifer keeps its top and loses <b>everything from the ground to ~12 m</b>: trunk and lower crown. 4 726 482 points removed here alone. The top-down row is the diagnosis: <b>square whole-cell holes punched through a closed crown</b> &mdash; 1 m cells dropped over their full height.
The naive rule on 066 (685.23, 356.76), a 17.2 m closed crown — green lost at 8–15 m with no gap to ground anywhere below it. 5 061 454 points. The premise “every painted cell has a lowest empty run” was never evidence: inside a crown the 0.25 m bins of a single 1 m column are sparse, so “the lowest gap” lands inside the crown.
The naive rule on 066 (685.23, 356.76), a 17.2 m closed crown &mdash; green lost at 8&ndash;15 m with <b>no gap to ground</b> anywhere below it. 5 061 454 points. The premise &ldquo;every painted cell has a lowest empty run&rdquo; was never evidence: inside a crown the 0.25 m bins of a single 1 m column are sparse, so &ldquo;the lowest gap&rdquo; lands <i>inside</i> the crown.

Over the 475 completed sweep rows the minimum GT-tree loss was 133 402 of 156 209 (85.4 %). On 066 the applied rule removed 67 % of all tree paint. The trunk-column exception never rescued it: it protected 0–537 of 9 063 cells. 13 of 30 tiles were labelled wrong_tree_removed, carrying 27 790 512 of 27 837 510 removed points.

What actually fixed it (skirt/SPEC_skirt.md §1, each change measured on 085 GT):

changeknobwhy it workstree loss on 085
1. hard height capskirt_unpaint_max_elev_m = 2.0A point above the cap can never be unpainted, whatever the profile says. Makes the worst case structurally bounded: a checkerboard hole at 8–18 m is no longer expressible.133 402 → 586 (cell-only, cap 1.25)
2. 3×3-cell profileskirt_unpaint_neighbourhood_cells = 1The gap must be empty across a 3 × 3 m column, which is what “the crown really stops here” means. Crown-internal holes and trunk columns get filled in by neighbours.3 118 (cell only) → 0 (3×3), same G / cap
3. minimum low-layer sizeskirt_unpaint_min_layer_points = 200The trunk / sapling guard the trunk-column detector failed to be: a trunk base contributes tens of points to a 1 m cell, a grass or bush skirt hundreds. Monotone: 50 → 172 lost, 100 → 47, 150 → 1, 200 → 0.0
4. drop the trunk-column exceptionWith (2) + (3) it had nothing left to protect and every version tested cost recall. The safety net that looked essential was a symptom of the broken rule.

Generalisable: the sweep’s own summary statistic (“every cell has a gap”) was the failure signature, not the licence. It took a vision pass on 30 renders to see it — no scalar in the sweep table said “this is eating crowns”.

2. What changed in the code

itemcommitverdictreason
skirt unpaint, new optional stage70958d0 knobs, c2bcd4d pass, 957ec79 docskeep, default OFFSegment-wide 1 m cell grid, 0.25 m vertical histogram summed over the 3×3 neighbourhood, unpaint everything below the lowest ≥ 1 m empty run when that floor is ≤ 2.0 m and the cell’s own low layer has ≥ 200 points. Runs after hardblob and guardrail-mask; paint-only, no RF-facing field touched.
enabled in the recommended config884aadcrecommended = ONconfigs/a1nr_returns_recommended.json now sets skirt_unpaint_enabled: true; the packaged default stays false, so default outputs remain bit-identical.
hard-column unpaint53a1ee7 (removal)REVERTED — deletedGate verdict. hardcolumn.py and tests/test_hardcolumn.py deleted; hook stripped from detect.py; 15 knobs out of _model_treedetect.py and verticalsigns.default.json; HARD_COLUMN_CSV_FIELDS gone from TREE_CSV_FIELDS; README row + section removed. Zero hard_column references remain in any .py / .json / .md / .toml.
min_gap_m rounding8264882bug fixedUsed round() where the spec documents ceil(): at bin = 0.3 / gap = 1.0 the pass accepted 0.9 m of air as a 1 m gap. Bit-identical at the packaged 1.0 / 0.25; 2 tests added.
3 doc-vs-behaviour drifts625b671docs fixed(a) the unpaint window starts at min_elev_m, so a 0.05–0.8 m band keeps its sub-0.3 m residue (226 of 600 in a probe); (b) nothing above the gap is required — a grass strip with no crown at all is fully unpainted; (c) the README mask-count arithmetic omitted two passes.
tree_clusters.csv header53a1ee74 columns droppedThe only non-additive change in this pass, and only because hard-column is gone. With the skirt off, masks / JSON / run_summary are unaffected; with it on, two append-only columns (skirt_unpainted_points, skirt_cells) are added and rows the pass did not run on stay empty, not 0.
tests1 825 passDown from 1 838 by exactly the deleted test_hardcolumn.py. ruff clean; format clean.

Knob defaults as shipped: cell_m 1.0 · bin_m 0.25 · min_elev_m 0.3 · max_elev_m 2.0 · min_gap_m 1.0 · neighbourhood_cells 1 · min_layer_points 200 · max_instance_fraction 0.5 · profile_top_m 40.0. The safety invariant h < gap_floor ≤ max_elev_m is asserted in the unit tests and verified on real data with 0.30 m of headroom at worst.

3. What the skirt removes — tuning split (r9 → r11)

20 tiles, 89.5 % of all removed points inspected, 0 wrong-tree removals 100.00 % point-weighted correct

Six of the twenty, chosen for what they prove: the two largest regions, the guardrail case, the understory case, the trunk-safety case, and the worst-looking truth-trunk probe. Point-weighted split of the sample: 78.69 % road / kerb / guardrail / shoulder, 21.31 % shrub / grass, 0 % mixed, 0 % unclear, 0 % wrong_tree_removed.

BEFORE first · 085, region #3, 38 118 pts, h 0.3–1.21 m · panels RGB | number_of_returns | r9 (white = what the skirt removes) | r11. White is the painted road-edge line plus understory blobs at the drip-line of a 27 m stand. The stand is pixel-identical between the r9 and r11 panels.
BEFORE first &middot; 085, region #3, <b>38 118</b> pts, h 0.3&ndash;1.21 m &middot; panels <b>RGB | number_of_returns | r9 (white = what the skirt removes) | r11</b>. White is the painted <b>road-edge line</b> plus understory blobs at the drip-line of a 27 m stand. The stand is pixel-identical between the r9 and r11 panels.
BEFORE first · 066, the largest single region on the tuning split, 61 214 pts. A 1 m-wide dead-straight shoulder / verge strip running > 20 m along the carriageway; the 22.6 m crown to the SE is untouched. This is also truth-trunk probe 066_680_300’s “18.9 % loss” — the strip passes through the 1.5 m probe cylinder.
BEFORE first &middot; 066, the largest single region on the tuning split, <b>61 214</b> pts. A 1 m-wide dead-straight <b>shoulder / verge strip</b> running &gt; 20 m along the carriageway; the 22.6 m crown to the SE is untouched. This is also truth-trunk probe <code>066_680_300</code>&rsquo;s &ldquo;18.9 % loss&rdquo; &mdash; the strip passes through the 1.5 m probe cylinder.
BEFORE first · 018, 6 233 pts at 0.30–0.87 m: the guardrail beam and the road-edge line, dead straight in the RGB panel. Exactly the class the r9 guardrail-mask pass cannot reach, because these points sit outside the rail corridor.
BEFORE first &middot; 018, <b>6 233</b> pts at 0.30&ndash;0.87 m: the <b>guardrail beam</b> and the road-edge line, dead straight in the RGB panel. Exactly the class the r9 guardrail-mask pass cannot reach, because these points sit outside the rail corridor.
BEFORE first · 018, 6 415 pts — the understory case: scattered forest-floor blobs at 0.3–1.48 m under a 9.5 m crown that keeps all of its paint. The magenta delineator standing right beside the removal is untouched, which is the sign/delineator confusion check.
BEFORE first &middot; 018, <b>6 415</b> pts &mdash; the understory case: scattered <b>forest-floor blobs at 0.3&ndash;1.48 m</b> under a 9.5 m crown that keeps all of its paint. The magenta delineator standing right beside the removal is untouched, which is the sign/delineator confusion check.
BEFORE first · 085, 1 034 pts — the trunk-safety test. Three isolated forest-floor bushes inside a 29.5 m conifer stand go; the bare stems beside them keep their paint. This is what min_layer_points = 200 buys: a trunk base contributes tens of points to a 1 m cell, a bush contributes hundreds.
BEFORE first &middot; 085, <b>1 034</b> pts &mdash; the trunk-safety test. Three isolated <b>forest-floor bushes inside a 29.5 m conifer stand</b> go; the <b>bare stems beside them keep their paint</b>. This is what <code>min_layer_points = 200</code> buys: a trunk base contributes tens of points to a 1 m cell, a bush contributes hundreds.
BEFORE first · 085 truth trunk 085_2268_1240, the worst-looking probe row (15.5 % of the points within 1.5 m). Not stem damage: the truth point sits on the vegetation/carriageway boundary, so the cylinder swallows the road-edge line and fringe understory. The human GT independently reports 0 tree points lost on 085, so all 7 879 are GT low_veg / medium_veg / road / guardrail.
BEFORE first &middot; 085 truth trunk <code>085_2268_1240</code>, the worst-looking probe row (<b>15.5 %</b> of the points within 1.5 m). Not stem damage: the truth point sits on the vegetation/carriageway boundary, so the cylinder swallows the road-edge line and fringe understory. The <b>human GT independently reports 0 tree points lost on 085</b>, so all 7 879 are GT low_veg / medium_veg / road / guardrail.

Max removed height 1.699 / 0.992 / 1.526 / 1.437 m (018 / 060 / 066 / 085): the 2.0 m cap never binds, 0 points at or above 2.0 m, 0 points below 0.3 m. 52–87 % of removed points have no retained tree paint above 3 m in their own cell — detached verge / kerb / rail strips, not columns under a crown.

4. The gate renders — validation split (val_r9 → val_r11)

26 regions, 89.3 % of removed points inspected, 26/26 correct gate PASS

Segments 003 / 022 / 023 / 087 / 093 / 094, never tuned on, first look. No threshold was changed after opening them. Heights here are an independent proxy (RecordIndex 2 m-cell z-p5) because these segments have no cached DEM.

GATE · BEFORE first · 022 (−2930, −1997), 190 984 pts — the largest removal on the whole validation split. A shrub / bramble carpet on the embankment under two crowns; both crowns are identical in r11. This region is why 022 loses 2.35 % of its paint against 0.23 % on 094.
GATE &middot; BEFORE first &middot; 022 (&minus;2930, &minus;1997), <b>190 984</b> pts &mdash; the largest removal on the whole validation split. A <b>shrub / bramble carpet on the embankment</b> under two crowns; both crowns are identical in r11. This region is why 022 loses 2.35 % of its paint against 0.23 % on 094.
GATE · BEFORE first · 023 (−2886, −1929), 39 830 pts, mrf 0.054 — a razor-straight kerb / guardrail line. The mrf of the removed points is the tell: single-return hard structure, not foliage.
GATE &middot; BEFORE first &middot; 023 (&minus;2886, &minus;1929), <b>39 830</b> pts, mrf 0.054 &mdash; a razor-straight <b>kerb / guardrail line</b>. The mrf of the removed points is the tell: single-return hard structure, not foliage.
GATE · BEFORE first · 003 (−3340, −3664), 6 040 pts, mrf 0.742 — a forest-floor bush patch between conifer trunks. The trunks keep their paint. Highest-mrf region in the top-20 and still correct.
GATE &middot; BEFORE first &middot; 003 (&minus;3340, &minus;3664), <b>6 040</b> pts, mrf 0.742 &mdash; a <b>forest-floor bush patch between conifer trunks</b>. The trunks keep their paint. Highest-mrf region in the top-20 and still correct.
GATE · BEFORE first · 022 (−2943, −1963), 5 022 pts, mrf 0.094 — a guardrail line, and the magenta sign standing next to it survives untouched. 26/26 rendered regions judged correct, 89.3 % of removed points inspected.
GATE &middot; BEFORE first &middot; 022 (&minus;2943, &minus;1963), <b>5 022</b> pts, mrf 0.094 &mdash; a <b>guardrail line</b>, and the <b>magenta sign standing next to it survives</b> untouched. 26/26 rendered regions judged correct, 89.3 % of removed points inspected.

Crown safety, three independent ways: M1 (> 10 m) and M1b (4–10 m) are bit-identical val_r9 → val_r11; tree paint > 1 m falls by only 14 433 of the 583 552 removed points, i.e. 97.5 % of all removed paint is below 1 m AGL; and the crown silhouette above the removal is pixel-identical in every r9/r11 pair.

Honest note, not a blocker: on 022 and inside the woods on 003 / 087 the skirt removes genuine woody understorey, not only verge grass and guardrail. That is what the pass is for — that material is not tree — but it is why 022 loses 2.35 % against 094’s 0.23 %, and why one instance on 087 loses 46.4 % of its points (inside the 0.5 guard, detection row unchanged).

5. The 003 mast cases — why hard-column was reverted

The pass unpainted 0 points on all six validation segments revert

These are the exact cases the feature was designed for, held out of the tuning split on purpose since pass 10. hard_column_count = 0 on all 322 validation tree detections; on the tuning split the pass ever fired once, for 121 points.

GATE · 003 (−3329, −3671), the lamp / camera mast — the M3 top hard cell, 20 082 pts, mrf 0.033, span 1.0–8.55 m. This is the single case hard-column unpaint was written for. 0 points removed within 1.5 / 3 / 6 m: the mast stands as a solid green column, full height, in both val_r9 and val_r11.
GATE &middot; 003 (&minus;3329, &minus;3671), the <b>lamp / camera mast</b> &mdash; the M3 top hard cell, 20 082 pts, mrf 0.033, span 1.0&ndash;8.55 m. This is the single case hard-column unpaint was written for. <b>0 points removed within 1.5 / 3 / 6 m</b>: the mast stands as a solid green column, full height, in both val_r9 and val_r11.
GATE · 003 (−3321, −3679), the gantry column next to the r9 hard_blob_reject. The ~8 m column is green top-to-bottom in both panels; removed = 0 in the whole 14 m disc.
GATE &middot; 003 (&minus;3321, &minus;3679), the <b>gantry column</b> next to the r9 <code>hard_blob_reject</code>. The ~8 m column is green top-to-bottom in both panels; <code>removed = 0</code> in the whole 14 m disc.
GATE · 003 (−3257, −3699), a vineyard / orchard trellis run — posts and wires painted as tree. hard-column removes 0 points of it. Two genuine masts plus a whole trellis run are tree-painted on six unseen segments and the pass touches none of them.
GATE &middot; 003 (&minus;3257, &minus;3699), a <b>vineyard / orchard trellis run</b> &mdash; posts and wires painted as tree. hard-column removes <b>0</b> points of it. Two genuine masts plus a whole trellis run are tree-painted on six unseen segments and the pass touches none of them.
GATE · 022 (−2949, −2011), a real tree trunk (mrf 0.069, span 11.4 m) in the relaxed mast-candidate list. The trunk keeps its full green column in val_r11; the 40 075 points that do change here are the skirt removing understorey around it. Every changed point on the validation split is attributable to the skirt.
GATE &middot; 022 (&minus;2949, &minus;2011), a real <b>tree trunk</b> (mrf 0.069, span 11.4 m) in the relaxed mast-candidate list. The trunk keeps its full green column in val_r11; the 40 075 points that do change here are the <b>skirt</b> removing understorey around it. Every changed point on the validation split is attributable to the skirt.

Three independent default gates each veto the 003 lamp mast on their own: (1) it is tree instance 52 whose entire above-1 m paint is the mast, so inst_mrf = 0.0367 < min_instance_mrf 0.15 → skipped as “no foliage” and never reconsidered; (2) the cell has mrf = 0.0331 against a max_multi_return_fraction cap of 0.015; (3) the crown test reports crown_evidence = True because a real conifer stands within 3 m.

Nothing about the pass was unsafe — it simply does not work at its shipped defaults. It cost extra code, four CSV columns, one JSON field and a share of the runtime for zero measured benefit over 10 segments. A rule that could reach this mast has to start from the inst_mrf < 0.15 population the current gate throws away: a new design, not a retune. Also worth recording: the pass-10 max_bbox_m / grid-phase finding was never fixed, and all four bbox-fix commits touched only the now-deleted files — none of that work is live.

6. Results

6.1 085 human ground truth (1 872 942 pts, 100.000 % matched within 5 cm)

metricbase (pre-AI3D-382)r9 (shipped)r11 (skirt ON)delta r9 → r11
tp (GT class 5 = tree)156 209156 209156 2090
recall0.75470.75470.75470
fn50 76050 76050 7600
fp (all non-5)163 018131 413100 538−30 875
precision0.48930.54310.6084+0.0653
veg-tolerant precision0.74130.85280.8908+0.0380
definition FP (74 + 75)104 44881 383−23 065 (−22.1 %)
hard FP (66 + 70 + 71 + 76)26 96519 155−7 810 (−29.0 %)
painted tree points (whole segment)18 635 55218 432 163−203 389 (−1.091 %)

Removed GT points by class: tree 0 (0.00 %) · low_veg −11 483 (62.4 % of its r9 paint) · medium_veg −11 582 (13.5 %) · guardrail −4 148 (66.1 %) · road −3 540 (17.8 %) · c70 −122 · c76 0. Height band of every removed GT point: 0.3–1 m 30 657, 1–2 m 218, nothing else. lost_tree_bands {}. The offline model predicted precision 0.6101 and the detector measured 0.6084 — the ~2 % gap is a half-cell grid-phase shift (the detector keys cells on floor(xy − frame_origin), the offline sweep on floor(world_xy)) plus a DEM difference, and it is two-sided across segments, which is the signature of a phase shift rather than a rule mismatch.

6.2 Validation gate table (val_base | val_r9 | val_r11)

metricval_baseval_r9val_r11verdict
M1 canopy_recall_hi (> 10 m, 2 373 922 pts)0.017500.769290.76929bit-identical
M1b canopy_recall_mid (4–10 m, 7 841 834 pts)0.750310.903100.90310bit-identical
M2 foliage_recall_seg (14 449 cells)0.494220.860050.86005bit-identical
M3 hard_cells / hard_points111 / 137 00926 / 34 05727 / 34 928+1 cell, bookkeeping
M3 hard_points / tree paint0.003630.000680.00069flat
M4 sign / delineator / tree / GHOST4 / 88 / 331 / 05 / 88 / 322 / 05 / 88 / 322 / 0unchanged
M6 spot recall / fp_rate1.000 / 0.5001.000 / 0.5001.000 / 0.500unchanged
detections (6 segments)4234154150 added / 0 removed
clusters.csv / rail_halfposts.csv md5 vs val_r9SAME 6/6stable
tree paint removed / added vs val_r958 600 912−583 552 (0.996 %) / +0in band
hard-column points unpainted0 on all six segmentsrevert
runtime total (worst segment)1 360.5 s (308 s)1 970.7 s (447 s)2 519.7 s (575 s = 9.6 min)+27.9 %

The single M3 move is 022 cell (−2922, −1994), 871 points: those points are tree-painted in val_r9 too — removing the low layer under them raised the cell’s remaining above-1 m mrf signature past the M3 test. No new contamination is created. Gate integrity: cfg/val_r11.json had hard-column enabled as well, but it reported instances_touched = 0 / points_unpainted = 0 / rejected = 0 on all six segments, so the skirt-only config that ships is exactly what was gated.

6.3 Per-segment removal, all 10 segments

splitsegtree paint beforeremovedaddedshareinstances touchedmax instance lossskippedmax removed h
tuning0185 097 95632 47400.637 %4 / 412.57 %01.699 m
tuning0607 163 41119 07700.266 %5 / 522.59 %00.992 m
tuning06612 794 91669 43700.543 %4 / 641.61 %01.526 m
tuning08518 635 552203 38901.091 %2 / 41.25 %01.437 m
gate00339 32100.657 %420.4 %01.67 m*
gate022303 48502.351 %3034.9 %02.12 m*
gate023102 00600.842 %115.8 %02.45 m*
gate08771 31000.584 %846.4 %01.92 m*
gate09352 43000.599 %138.9 %01.83 m*
gate09415 00000.227 %58.4 %01.53 m*

* gate heights are the RecordIndex z-p5 proxy, not a DEM: 17 of 583 552 points read ≥ 2.0 m on it (max 2.45 m), which is proxy noise on sloped verges, not a cap violation — the detector’s own cap is enforced against its live ground model. 0 points added anywhere; 0 instances skipped anywhere. Detection layer identical on all 10 segments: 0 removed, 0 added, position and n_points bit-identical, no pre-existing JSON or CSV cell changed.

7. What did not improve, and what is still open

8. Next