AI3D-382 · detection pass 10: hard-column unpaint — precision-perfect, tree-safe, and its target class is not on this split

7 Sep 2026 · tuning segments 018 / 060 / 066 / 085 · roots: r9 (shipped config — the reference) | r10 (r9 + tree_detection.hard_column_unpaint_enabled = true, the only field changed) · code wt-vs-382 @ 74f7406cb570e7 · writeup /home/ai/veg373_work/returns/eval/r10_judgement.md · renders /home/ai/veg373_work/returns/analysis/r10_renders/ · previous rounds: pass 7 · pass 8 · pass 9 · before/after gallery · summary

  1. 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.
  2. 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 {}.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.

itemknob / commitverdictreason (one line)
hard-column unpainttree_detection.hard_column_unpaint_enabled false → true in r10 only (52d084d)needs_human — stays OFFPrecision-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 surface15 tree_detection.hard_column_* fields (74f7406)default OFF, verifiedBoth master switches checked in detect.py; default outputs bit-identical; run-summary key absent when off.
instance reject pathhard_column_reject_instance_enabled = falseleft offNot exercised in r10; the unpaint path alone is what was measured.
pole-seed pathoffmeasured no-op0 of 8 candidates coincide with a pole_other / sign_post cluster; the tuning split has no accepted plate-less pole.
head bandhard_column_head_band_m = 0.0, guard fix cb570e7review fixThe head-band arms are now weighed by the instance-fraction guard; bit-identical at the packaged 0.0.
hard_column_max_multi_return_fraction 0.015 → 0.10— (probed, rejected)do not relaxA 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 helpLowest 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-runEvery 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 &middot; 085 (2293.42, 1232.39), r = 8 m, panels <b>RGB | number_of_returns | r9 | r10</b> &middot; r9 paints a thin green vertical smear standing on the <b>bare carriageway</b>; r10 removes it (121 pts). The verge vegetation along the bottom-right edge is the identical green in both. <b>Correct removal.</b>
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.
BEFORE first &middot; same spot at r = 16 m &mdash; the collateral check. The 24 m conifer on the left and the whole shrub bank bottom-right are <b>pixel-equivalent r9 vs r10</b>; the only change in the crop is the on-road smear. <b>No collateral.</b>
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) &middot; true-colour zoom on the candidate. The marker lands on <b>bare asphalt between the lane markings</b>; a dark/reddish car-shaped blob sits on the lane with a thin smear above it. No column footprint &rarr; vehicle / motion artifact, human label <code>other_structure</code>, 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.
Identification (r9 truth probe) &middot; 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 &middot; 018 (&minus;3331.51, &minus;2188.28), truth <b>trunk</b>, column mrf <b>0.021</b> &mdash; the closest trunk to the 0.015 cap. r9 and r10 panels are <b>indistinguishable</b>: stem, crown and understorey all intact. <b>0.000 % lost.</b>
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 &middot; 060 (287.77, &minus;114.84), truth <b>trunk</b>, top gap <b>1.50 m</b> &mdash; 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. <b>0.000 % lost.</b>
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.
BEFORE first &middot; 066 (753.22, 302.23), truth <b>trunk</b>, crown_multi <b>187</b> &mdash; the weakest crown evidence in the truth table (sparse conifer bole). Stem and crown fully green in both panels. <b>0.000 % lost.</b>
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.
Context (r9 truth probe) &middot; 085 (2268.26, 1239.66), the <b>other</b> 085 candidate: a 14.7 m stem embedded in a 26 m broadleaf/conifer canopy, mrf 0.097. Labelled trunk, and r10 leaves all <b>50 807</b> 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 &middot; 018 <code>gantry_841</code> &mdash; a <b>16.6 m broadleaf crown</b>, orange multi-return foliage throughout, and <b>no column anywhere in the crop</b>. 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 &middot; 066 <code>gantry_217</code> &mdash; a <b>27 m conifer stand</b>. 7 859 painted points, 0 unpainted, mrf(h&ge;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 &middot; 085 <code>gantry_103</code> &mdash; a <b>33 m conifer stand</b>, 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 &middot; 085 <code>gantry_893</code> &mdash; 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.
Probe &middot; 085 <code>gantry_191</code> &mdash; only <b>168</b> painted points here and <b>all of them below 1 m</b>, i.e. under <code>hard_column_min_elev_m</code>. No cell reaches the 25-point floor at any cell size. Unreachable by construction, not a rule failure.
sitesegtree paint within 1.5 m (r9 = r10)lostmrf(h≥1 m) within 2.5 mmin 1 m-cell mrfmin 0.3 m-cell mrf
gantry_8410186 12700.3610.23230.0652 (span 1.13 m)
gantry_2170667 85900.3910.25610.1081
gantry_1030851 66700.9090.74000.6000
gantry_89308578200.8640.71590.5312
gantry_191085168 (all 0.3–1 m)00.115no 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

idlabelpainted r9painted r10lostlost %lost bands
085_2293_1232other_structure1906912163.68 %1–2 m 120, 4–10 m 1
018_−3332_−2188trunk33 46733 46700.000 %
018_−3220_−2175trunk40 30340 30300.000 %
060_288_−115trunk22 43022 43000.000 %
066_680_300trunk17 42917 42900.000 %
066_716_387trunk62 46762 46700.000 %
066_753_302trunk52152100.000 %
085_2268_1240trunk50 80750 80700.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)

metricr9r10delta
tp (GT tree, class 5)156 209156 2090
fn50 76050 7600
fp (all non-5)131 413131 396−17
definition FP (74+75)104 448104 4480
hard FP (66+71+76+70)26 96526 948−17 (all class 76)
precision0.54310.5431+0.0000
veg-tolerant precision0.85280.8529+0.0001
recall0.75470.75470

Painted GT by class: tree 156 209 → 156 209 (0), medium_veg 86 032 → 86 032, road 19 892 → 19 892, low_veg 18 416 → 18 416, guardrail 6 276 → 6 276, c76 571 → 554 (−17), c70 226 → 226. GT-tree loss target (~0): PASS, exactly 0.

5.3 det_diff r10 vs r9 (TOL 1 m)

segr9r10REMOVEDADDEDMOVEDtype / reason binsn_pointspositionpoint_masks.npz md5
0187575000equalidenticalidenticalSAME
0608080000equalidenticalidenticalSAME
0667171000equalidenticalidenticalSAME
08599000equalidenticalidenticaldiffers (121 pts)

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.

5.4 Points unpainted per segment

segtree paint r9tree paint r10removedaddedevents
0185 097 9565 097 956000
0607 163 4117 163 411000 (1 instance skipped: no foliage)
06612 794 91612 794 916000
08518 635 55218 635 43112101
total43 691 83543 691 714121 (−0.00028 %)01

5.5 canopy_eval 018 + 060 — every metric +0

metricr9r10
M1 canopy_recall_hi (> 10 m, 208 448 pts)0.832640.83264
M1b canopy_recall_mid (4–10 m, 1 462 918 pts)0.8980.898
M2 foliage_recall_seg (2 715 cells, 4 341 957 pts)0.906080.90608
M3 hard cells / points9 / 28 5569 / 28 556 (top-cell list cell-for-cell identical)
M3 tree paint > 1 m (018+060)10 153 85210 153 852
M4 signs / delineators / trees / GHOST6 / 56 / 93 / 06 / 56 / 93 / 0
M6 truth spots12/12, recall 1.000, 0 FP12/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

segr9r10deltahard-blob stage (unchanged code)guardrail-mask stage (unchanged code)hard-column stage
018242.4 s379.2 s+136.85.8 → 11.0 s (1.90x)7.7 → 21.1 s (2.74x)12.3 s
060307.1 s498.0 s+190.99.1 → 17.2 s (1.89x)39.3 → 112.0 s (2.85x)13.3 s
066414.4 s603.2 s+188.712.4 → 27.0 s (2.18x)38.8 → 52.0 s (1.34x)18.5 s
085369.7 s569.1 s+199.426.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.

findingstatusdetail
head band bypassed the instance-fraction guardfixed — cb570e7The 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 lotteryNOT fixed — needs a human + a re-run before the gatehard_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 huntedcleanNo 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 DictWriter restval 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