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.
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.
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).
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.
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).
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.
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 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.
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):
change
knob
why it works
tree loss on 085
1. hard height cap
skirt_unpaint_max_elev_m = 2.0
A 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 profile
skirt_unpaint_neighbourhood_cells = 1
The 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 size
skirt_unpaint_min_layer_points = 200
The 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 exception
—
With (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
item
commit
verdict
reason
skirt unpaint, new optional stage
70958d0 knobs, c2bcd4d pass, 957ec79 docs
keep, default OFF
Segment-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 config
884aadc
recommended = ON
configs/a1nr_returns_recommended.json now sets skirt_unpaint_enabled: true; the packaged default stays false, so default outputs remain bit-identical.
hard-column unpaint
53a1ee7 (removal)
REVERTED — deleted
Gate 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 rounding
8264882
bug fixed
Used 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 drifts
625b671
docs 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 header
53a1ee7
4 columns dropped
The 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.
tests
—
1 825 pass
Down 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 removals100.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 · 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 · 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 · 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 · 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 · 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.
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)
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 · 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 · 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 · 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.
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 segmentsrevert
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 · 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 · 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 · 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.
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)
metric
base (pre-AI3D-382)
r9 (shipped)
r11 (skirt ON)
delta r9 → r11
tp (GT class 5 = tree)
156 209
156 209
156 209
0
recall
0.7547
0.7547
0.7547
0
fn
50 760
50 760
50 760
0
fp (all non-5)
163 018
131 413
100 538
−30 875
precision
0.4893
0.5431
0.6084
+0.0653
veg-tolerant precision
0.7413
0.8528
0.8908
+0.0380
definition FP (74 + 75)
—
104 448
81 383
−23 065 (−22.1 %)
hard FP (66 + 70 + 71 + 76)
—
26 965
19 155
−7 810 (−29.0 %)
painted tree points (whole segment)
—
18 635 552
18 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.
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
split
seg
tree paint before
removed
added
share
instances touched
max instance loss
skipped
max removed h
tuning
018
5 097 956
32 474
0
0.637 %
4 / 41
2.57 %
0
1.699 m
tuning
060
7 163 411
19 077
0
0.266 %
5 / 52
2.59 %
0
0.992 m
tuning
066
12 794 916
69 437
0
0.543 %
4 / 64
1.61 %
0
1.526 m
tuning
085
18 635 552
203 389
0
1.091 %
2 / 4
1.25 %
0
1.437 m
gate
003
—
39 321
0
0.657 %
4
20.4 %
0
1.67 m*
gate
022
—
303 485
0
2.351 %
30
34.9 %
0
2.12 m*
gate
023
—
102 006
0
0.842 %
11
5.8 %
0
2.45 m*
gate
087
—
71 310
0
0.584 %
8
46.4 %
0
1.92 m*
gate
093
—
52 430
0
0.599 %
13
8.9 %
0
1.83 m*
gate
094
—
15 000
0
0.227 %
5
8.4 %
0
1.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
Recall did not move, at all, anywhere. tp 156 209 on the 085 GT in base, r9 and r11; M1 / M1b / M2 bit-identical on both splits. This pass is purely a precision pass — every point it can touch is below 2 m.
The mast / gantry recall problem is untouched and now has no code behind it. Two genuine masts plus a whole vineyard trellis run are tree-painted on the six unseen segments, and after the revert nothing in the codebase addresses them.
Runtime went the wrong way: +27.9 % on the gate split, and the tuning-split stage cost (5.3–22.1 s) already misses the spec’s own < 10 s/segment budget on 066 and 085. The skirt is a third full record-streaming pass and it discards the returns channel it asks for.
The fraction guard has never fired.max_instance_fraction = 0.5 is measured against the whole instance, so with a worst case of 46.4 % it did not trigger on any of the 10 segments. It is untested in production terms.
Skipped rows are indistinguishable from zero-effect rows in the sense that both report 0/0; only the empty-vs-0 CSV distinction separates “the pass did not run” from “it ran and removed nothing”.
Four commits carry a Co-authored-by: Cursor trailer injected by the Cursor CLI. HEAD was amended clean; the earlier ones cannot be fixed without a rebase, which house rules forbid. Needs a human decision.
8. Next
Share one _collect_painted_points pass across hardblob / guardrail-mask / skirt. It is the single change that buys back most of the +27.9 %, and it is the same finding pass 10 raised for hard-column — now with a stage that actually ships.
Decide the trailer question and then push. Branch feat/AI3D-382-returns-recall is 13 commits ahead of origin, working tree clean, not pushed; PR #10 is open.
Do not promote skirt_unpaint_enabled to the packaged default. It is ON in configs/a1nr_returns_recommended.json only; the packaged default stays false so default outputs stay bit-identical, exactly as the three passes before it.
If the mast class is wanted back, start from the population the old gate discarded (inst_mrf < 0.15, i.e. instances whose entire above-1 m paint is the column) and treat it as a new design with its own spec and its own gate. Do not retune the deleted knobs.
Consider measuring the fraction guard against the touched cells rather than the whole instance, so it can actually protect a small instance; then re-run the tuning split, since it would change measured numbers.