The pass fires exactly once on the whole tuning split: 121 points on 085, at (2293.42, 1232.39) — truth candidate 085_2293_1232, the human-labelled vehicle / motion artifact standing on the carriageway. 018 / 060 / 066: 0 columns, 0 points, and point_masks.npz md5 is identical to r9 on all three.
Zero tree damage, proven twice. All 7/7 truth trunks lose 0.000 % of their paint. On the 085 human GT the loss is exactly 0: tp 156 209 in both roots, recall 0.7547 unchanged, lost_tree_bands {}.
The only measurable gain is 17 points. 085 hard FP 26 965 → 26 948 (−17, all class 76) — only 17 of the 121 removed raw points have a GT twin within 5 cm, because the artifact is sparse.
Regression clean. 0 detections removed / added on all four segments, no pre-existing JSON or CSV field changes value anywhere, clusters.csv and rail_halfposts.csv md5 SAME, canopy_eval M1/M1b/M2/M3/M4/M6 all +0 (provably — the mask is byte-identical on 018/060), 0 errors in the four logs.
The catch: the target class does not exist on this split. 5 gantry_or_gate sites probed, 0 points unpainted at each, and the renders show why — every one is canopy (16.6 m broadleaf, 27 m and 33 m conifer stands), no steel column in the crop. The exhaustive r9 scan at a 6.7x-relaxed mrf cap yields 8 candidates over all four segments, 7 of them trunks.
Runtime is not the problem it looks like. +137…+199 s/segment vs r9, but that is machine contention: stages that are bit-for-bit unchanged are themselves 1.7–2.9x slower in the r10 run. Corrected, the pass itself costs ~6–19 s/segment. Worst segment 10.1 min, inside the 20 min budget.
Review found two majors; one is fixed, one is deliberately left for the gate.Fixed (cb570e7): the hard_column_head_band_m arms were added after the instance-fraction guard, so the head band could unpaint 52 % of an instance with the cap at 0.5 (reproduced) — the guard now weighs drop | unpaint and is bit-identical at the packaged head_band = 0.0, with a regression test. Not fixed: hard_column_max_bbox_m = 1.5 vs the (max−min+1) · cell_m bbox convention means every 2-cell blob measures 2.0 m, so only single-cell columns can qualify, hard_column_max_cells = 4 is unreachable, and an identical mast is caught or missed purely by grid phase (0.3 m mast: 64/100 phases; 0.5 m: 36/100). That, not scene content, is the likelier reason r10 fires once.
Verdict needs_human — gate decision: keep the code merged with the knob OFF as shipped, do not promote the r10 config, and decide ON/OFF on the validation gate where the lamp-mast / gantry-column cases actually live (003). Widening the bbox rule would invalidate every measured number on this page, so it was left as a gate decision too.
0. How to read the renders
Each 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) | r9 (the BEFORE reference) | r10.
The BEFORE column always comes first; top row oblique, bottom row the same points top-down; each column is labelled in the image itself.
Two figures in section 2 and one in section 3 are r9 truth-probe zooms reproduced for identification, captioned individually.
unmaskedtreemedium_vegetationsign-ish (sign / pole / delineator)guardrailother
Click any image for full resolution; ← / → (or swipe) step through the images of that card only; Esc closes.
1. What changed
One new optional stage, hard-column unpaint: it reconsiders exactly the blobs hardblob deliberately keeps (≤ 4 cells, plan bbox ≤ 1.5 m — the trunk exception) and unpaints them only when the column is hard (low multi-return fraction), tall (≥ 4 m span) and has no crown above it. The two passes never contend for the same cells.
item
knob / commit
verdict
reason (one line)
hard-column unpaint
tree_detection.hard_column_unpaint_enabled false → true in r10 only (52d084d)
needs_human — stays OFF
Precision-perfect and tree-safe here (1 true positive, 0 trunk damage), but the class it exists for is absent from the tuning split. Decide at the gate.
knob surface
15 tree_detection.hard_column_* fields (74f7406)
default OFF, verified
Both master switches checked in detect.py; default outputs bit-identical; run-summary key absent when off.
instance reject path
hard_column_reject_instance_enabled = false
left off
Not exercised in r10; the unpaint path alone is what was measured.
pole-seed path
off
measured no-op
0 of 8 candidates coincide with a pole_other / sign_post cluster; the tuning split has no accepted plate-less pole.
head band
hard_column_head_band_m = 0.0, guard fix cb570e7
review fix
The head-band arms are now weighed by the instance-fraction guard; bit-identical at the packaged 0.0.
A 6.7x relaxation adds zero new mast candidates and pulls all 7 truth trunks into the candidate set. What is missing is a mast, not a knob.
hard_column_cell_m 1.0 → 0.5 / 0.3
— (probed, rejected)
does not help
Lowest 0.3 m-cell mrf at the two mainland gantry sites is 0.065 (span 1.13 m, fails the 4 m gate) and 0.108.
hard_column_max_bbox_m = 1.5 vs the (max−min+1)·cell_m bbox
— (review finding, open)
needs a human + a re-run
Every 2-cell blob measures 2.0 m, so only single-cell columns qualify and max_cells = 4 is unreachable; grid phase alone decides (0.3 m mast 64/100 phases, 0.5 m 36/100). Widening it invalidates the r10 numbers, so it is a gate decision.
2. The one hit — 121 points on 085
The whole effect of the pass on the tuning split, in one spot correct removal
seg 085 · centroid (2293.42, 1232.39) · npts 121 · cells 2 · bbox 0.62 × 0.47 m · h 1.00 → 5.81 m (span 4.81 m) · mrf 0.0083 · instance 0 (tree, vegetation_rf). There is no second event to sample — this is event 1 of 1.
BEFORE first · 085 (2293.42, 1232.39), r = 8 m, panels RGB | number_of_returns | r9 | r10 · r9 paints a thin green vertical smear standing on the bare carriageway; r10 removes it (121 pts). The verge vegetation along the bottom-right edge is the identical green in both. Correct removal.BEFORE first · same spot at r = 16 m — the collateral check. The 24 m conifer on the left and the whole shrub bank bottom-right are pixel-equivalent r9 vs r10; the only change in the crop is the on-road smear. No collateral.Identification (r9 truth probe) · true-colour zoom on the candidate. The marker lands on bare asphalt between the lane markings; a dark/reddish car-shaped blob sits on the lane with a thin smear above it. No column footprint → vehicle / motion artifact, human label other_structure, confidence 0.55.Identification (r9 truth probe) · the same zoom with r9 tree paint on. The green smear over the roadway is what r10 removes; the crown mass in this crop belongs to the neighbouring conifer and is untouched.
121 points removed, of which 17 have a GT twin — all class 76, all in the 1–2 m band. The 69 painted points that remain at the spot are all in the 0.3–1 m band, below hard_column_min_elev_m = 1.0: unreachable by construction, not a partial failure.
3. The trunks that stayed — the three most marginal ones
Every truth trunk keeps 100.000 % of its paint 7/7 at 0.000 %
Shown are the three trunks with the smallest margin against each of the three separating gates, plus the second 085 candidate for context. If the rule were going to damage a tree anywhere on this split, it would be in these four crops.
BEFORE first · 018 (−3331.51, −2188.28), truth trunk, column mrf 0.021 — the closest trunk to the 0.015 cap. r9 and r10 panels are indistinguishable: stem, crown and understorey all intact. 0.000 % lost.BEFORE first · 060 (287.77, −114.84), truth trunk, top gap 1.50 m — the closest trunk to the 0.75 m crown gate. Panels indistinguishable; the sign panel and its two posts at ~(5, 4.7) m are also unchanged. 0.000 % lost.BEFORE first · 066 (753.22, 302.23), truth trunk, crown_multi 187 — the weakest crown evidence in the truth table (sparse conifer bole). Stem and crown fully green in both panels. 0.000 % lost.Context (r9 truth probe) · 085 (2268.26, 1239.66), the other 085 candidate: a 14.7 m stem embedded in a 26 m broadleaf/conifer canopy, mrf 0.097. Labelled trunk, and r10 leaves all 50 807 of its painted points in place.
Margins that held: column mrf 0.021 vs cap 0.015 (1.4x), top gap 1.50 m vs gate 0.75 m (2x), crown_multi 187 vs floor 40 (4.7x). Nothing was unpainted above 5.81 m anywhere on any segment.
4. The “misses” that are not misses
All 5 gantry_or_gate sites on the tuning split are canopy recall unmeasurable here
These are the sites where a gantry column ought to be, taken from the run’s own gantry_or_gate detections. Look at the RGB and number_of_returns panels: every one is a tree. This is the evidence behind “the target class does not exist on this split”.
Probe · 018 gantry_841 — a 16.6 m broadleaf crown, orange multi-return foliage throughout, and no column anywhere in the crop. 6 127 painted points within 1.5 m, 0 unpainted. Not a missed mast: there is nothing hard to unpaint.Probe · 066 gantry_217 — a 27 m conifer stand. 7 859 painted points, 0 unpainted, mrf(h≥1 m) 0.391, lowest 1 m-cell mrf 0.256 (17x the 0.015 cap). Same story.Probe · 085 gantry_103 — a 33 m conifer stand, mrf 0.909, lowest cell mrf 0.740. 1 667 painted points, 0 unpainted.Probe · 085 gantry_893 — same class of scene, mrf 0.864. 782 painted points, 0 unpainted.Probe · 085 gantry_191 — only 168 painted points here and all of them below 1 m, i.e. under hard_column_min_elev_m. No cell reaches the 25-point floor at any cell size. Unreachable by construction, not a rule failure.
site
seg
tree paint within 1.5 m (r9 = r10)
lost
mrf(h≥1 m) within 2.5 m
min 1 m-cell mrf
min 0.3 m-cell mrf
gantry_841
018
6 127
0
0.361
0.2323
0.0652 (span 1.13 m)
gantry_217
066
7 859
0
0.391
0.2561
0.1081
gantry_103
085
1 667
0
0.909
0.7400
0.6000
gantry_893
085
782
0
0.864
0.7159
0.5312
gantry_191
085
168 (all 0.3–1 m)
0
0.115
no cell ≥ 25 pts at any cell size
No cell at any of these sites clears the hard-cell test at any cell size (1.0 / 0.5 / 0.3 m) even with the mrf cap relaxed to 0.10. The tree paint at these sites is multi-return foliage, not steel.
5. Results
5.1 Truth hit table — painted tree points within 1.5 m of each candidate
id
label
painted r9
painted r10
lost
lost %
lost bands
085_2293_1232
other_structure
190
69
121
63.68 %
1–2 m 120, 4–10 m 1
018_−3332_−2188
trunk
33 467
33 467
0
0.000 %
—
018_−3220_−2175
trunk
40 303
40 303
0
0.000 %
—
060_288_−115
trunk
22 430
22 430
0
0.000 %
—
066_680_300
trunk
17 429
17 429
0
0.000 %
—
066_716_387
trunk
62 467
62 467
0
0.000 %
—
066_753_302
trunk
521
521
0
0.000 %
—
085_2268_1240
trunk
50 807
50 807
0
0.000 %
—
7/7 trunks untouched (far under the 2 % tolerance); 1/1 non-tree column unpainted. Truth labels: masts/TRUTH.md — 7 trunk, 1 other_structure, 0 mast, 0 gantry_column.
5.2 085 human GT (1 872 942 points, 100.000 % matched within 5 cm)
No pre-existing key changes value on any detection in any segment. The only JSON difference is three append-only keys (hard_column_count, hard_column_unpainted_points, hard_column_max_span_m) on tree-path trees only (41 / 52 / 64 / 4 rows). tree_clusters.csv: same row counts (95 / 120 / 100 / 37), no shared column changes, four new append-only columns. clusters.csv and rail_halfposts.csv md5 SAME on all four. Pre-existing convention, not new: the 085 tree that lost 121 painted points keeps its old n_points — unpaint passes edit the mask, not the detection record.
9 / 28 556 (top-cell list cell-for-cell identical)
M3 tree paint > 1 m (018+060)
10 153 852
10 153 852
M4 signs / delineators / trees / GHOST
6 / 56 / 93 / 0
6 / 56 / 93 / 0
M6 truth spots
12/12, recall 1.000, 0 FP
12/12, 1.000, 0 FP
This is not “within noise” — it is provable: point_masks.npz is byte-identical on both segments, so nothing could have moved.
5.6 Runtime
seg
r9
r10
delta
hard-blob stage (unchanged code)
guardrail-mask stage (unchanged code)
hard-column stage
018
242.4 s
379.2 s
+136.8
5.8 → 11.0 s (1.90x)
7.7 → 21.1 s (2.74x)
12.3 s
060
307.1 s
498.0 s
+190.9
9.1 → 17.2 s (1.89x)
39.3 → 112.0 s (2.85x)
13.3 s
066
414.4 s
603.2 s
+188.7
12.4 → 27.0 s (2.18x)
38.8 → 52.0 s (1.34x)
18.5 s
085
369.7 s
569.1 s
+199.4
26.1 → 44.3 s (1.70x)
9.3 → 9.5 s (1.02x)
32.1 s
The +137…+199 s is mostly machine contention: stages whose code did not change are themselves 1.7–2.9x slower in the r10 run. Corrected by the hard-blob factor the new pass costs ~6–19 s per segment (2–5 % of runtime) — about what hardblob costs, because it repeats the same collect + cell-bin work over the same instances. Worst segment 603 s = 10.1 min, inside the 20 min/segment budget.
6. Review of 74f7406..c2ba65b
Scope: hardcolumn.py, the detect.py hook, the config model, the packaged JSON, README, 27 tests. 1 839 pytest passed, ruff and format clean.
finding
status
detail
head band bypassed the instance-fraction guard
fixed — cb570e7
The hard_column_head_band_m arms were added after the hard_column_max_instance_fraction guard, so the head band could unpaint 52 % of an instance with the cap at 0.5 and instances_skipped = 0 (reproduced). The guard now weighs drop | unpaint, is provably bit-identical at the packaged head_band = 0.0, and has a regression test.
max_bbox_m vs the bbox convention — grid-phase lottery
NOT fixed — needs a human + a re-run before the gate
hard_column_max_bbox_m = 1.5 against _blob_plan_bbox_m’s (max − min + 1) · cell_m makes every 2-cell blob measure 2.0 m. So only single-cell columns qualify, hard_column_max_cells = 4 is unreachable, and an identical mast is caught or missed purely by grid phase: a 0.3 m mast passes in 64/100 phases, a 0.5 m mast in 36/100. This is the likelier reason r10 fires once on the whole split — likelier than scene content. Widening it invalidates every measured number above, so it was left for the gate decision.
everything else hunted
clean
No co-author trailers; the default-off gate is real (both knobs checked in detect.py); hook order canopy → hardblob → hardcolumn → guardrail pinned end-to-end through process_segment; _is_tree_path_tree provenance correct; position genuinely is world XY so the seed join is right; _apply_unpaint and the canopy-extras join reused unchanged; CSV strictly append-only with DictWriterrestval keeping the empty-vs-0 distinction; run-summary key absent when off; all working arrays per-instance int32 / int64 / float32 with no whole-cloud allocation. Six minors listed separately.
7. Next
The validation-split gate is still pending for this feature, and it must not be tuned on. Segments 003 / 022 / 023 / 087 / 093 / 094 were deliberately not looked at, rendered or tuned on in this round. The lamp-mast and gantry-column cases the pass exists for are the 003 panels in the before/after gallery — those are gate cases, not tuning cases.
Decide ON/OFF at the gate, not here. Ship the code merged with hard_column_unpaint_enabled = false exactly as it is now; do not promote the r10 config to “recommended” on tuning evidence that amounts to 121 points and 17 GT points.
Resolve the bbox / grid-phase finding before the gate run, because it changes what the gate measures. Either fix the convention ((max − min) · cell_m, or raise max_bbox_m to 2.0) or accept single-cell-only behaviour explicitly — but re-run the tuning split afterwards, since every number on this page assumes the current rule.
If the gate shows the pass still misses the 003 masts, the first thing to measure there is the painted cell mrf at the mast. This split says foliage overlap keeps it well above 0.015 whenever the column sits inside a crown — that, not the crown gates, is the likely blocker. The crown gates have 2x–4.7x margin against the trunk truth and should stay put.
Do not spend the mrf cap. Relaxing 0.015 → 0.10 (6.7x) adds zero mast candidates and pulls all 7 truth trunks into the candidate set, where only the crown gates keep them.