AI3D-373 — split vegetation into low / medium / tree
Summary
Today the fused LAS carries one vegetation class, tree (LAS 5), painted from the
verticalsigns tree masks; grass and hedges end up ground or unclassified
(or vehicle on the median). This adds
low_vegetation (LAS 3) and
medium_vegetation (LAS 4) next to
tree (LAS 5).
Two rules, two repos. A per-point colour + height-above-ground rule
lives in the seg3d fusion stage (it owns the corridor polygon, the tablecloth ground, the RGB and the
paint order, so "signs, guardrails, walls win" and "not above asphalt" are free and exact). A
per-cluster shape rule lives in verticalsigns: the existing, validated hedge verdict
(tree_instances._band_shape/_band_surface) is promoted into the detector so hedge-shaped
clusters leave the detector as medium_vegetation instead of tree.
Test bed: A1 branch 0 segments 085, 002, 003 (RGB, complete detector outputs). Visual QC on class raster + RGB raster side-by-side, iterate the greenness / band thresholds, then reviews, PRs, re-pin the production fuse worktree.
Key decisions
| # | Decision | Why |
|---|---|---|
| D1 | Per-point rule in seg3d (vegetation.py + a fusion step between the sign JSON paint and the vehicle sweep). verticalsigns only emits cluster-level classes. |
Only seg3d has all four ingredients at once: the asphalt-edge corridor polygon (A1 has no run4, so verticalsigns' road corridor is inert there), the tablecloth ground mask, every detector claim already painted, and RGB. Precedence = paint order; nothing to re-implement. Iteration is a 3.5-min fuse, no detector rerun. Portable numpy if we later move it. |
| D2 | Green = chromatic excess-green ExG = (2G−R−B)/(R+G+B) ≥ green_exg_min (start 0.10) and G > R, G > B. |
Scale-invariant (A1 stores 16-bit, other datasets 8-bit in uint16), brightness-invariant (shadowed grass is still green). Phase-6 study: crown ExG p50 +0.21, accepted man-made p75 0.11, bark 0.006 — a per-point threshold near 0.10 separates leaf from post/bark. |
| D3 | Height above a DTM built from hard points: 1 m grid, p10 z of ground+asphalt points, nearest-cell fill, bilinear sample. |
No DTM exists in seg3d; tablecloth ground is the best available surface and is in every segment. Cheap (sort + bincount), works on candidates only (≈3 M of 26 M points on 085). |
| D4 | Low vs medium by column height, not per point: 0.5 m XY cells, per-cell p95 of candidate heights → one band per cell. Low < 0.5 m, medium ≥ 0.5 m with no upper limit. | A hedge is a hedge from its foot up; per-point banding would give every bush a low skirt. Column p95 keeps objects whole. Miro (27 Aug): medium vegetation = relatively tall vegetation NOT detected as a tree; hedges and trees are exclusive — so the per-point stage never creates tree; knob vegetation_tall_class (default medium_vegetation) keeps tree/unclassified as alternatives above vegetation_medium_max_m. |
| D5 | Candidates = unclassified ∪ green ground, outside the "above asphalt" set. Detector tree points are the only tree. |
The tablecloth keeps grass as ground, so grass mostly IS ground today; only colour tells grass from gravel. Green ground → low. Detector classes (incl. detector tree) are never touched. |
| D6 | "Above asphalt" = the point's 0.5 m XY cell contains asphalt points (dilated 1 cell), not the whole corridor polygon. | On the dual carriageway the corridor polygon spans the median; median grass/hedges would be rejected and swept as vehicle. Asphalt occupancy rejects exactly what's over the road. Knob to fall back to the polygon. |
| D7 | Hedge verdict promoted into detect.py → detections typed medium_vegetation; seg3d maps it (LAS 4) and paints vegetation masks first so guardrails/signs/supports win. |
The rule exists, is tested and tuned (ti_hedge_*: grounded, flat-topped, ≥8 m long, stemless, ≤7.5 m tall); it just never reached verticalsigns.json. Conic-rule "shrub mounds" get the same test. Sidecar type strings flow through the existing writer. |
| D8 | Registry tier 1 for both new classes (with ground); no new priority_* knob. |
At the 1 cm production voxel class mixing inside a voxel is negligible; vegetation must lose to every hard-surface/detector class, which tier 1 already guarantees. |
| D9 | Colourless datasets (A45, parts of A1B1): the per-point stage skips (recorded in skips); only detector tree/medium_vegetation appear. |
Without colour, "green" is undefined; a geometry-only rule would paint fences and embankment clutter. Better nothing than wrong for annotators. Intensity-based fallback is a follow-up. |
How the RGB information is used
Each run3 record carries red/green/blue uint16 per point (A1: full 16-bit range). The
fusion stage turns them into a single scale-free greenness value:
s = R + G + B (s == 0 → no colour → not green)
ExG = (2·G − R − B) / s (chromatic excess green, −2…+2 theoretically, ≈ −0.3…+0.6 in practice)
green = ExG ≥ green_exg_min and G > R and G > B and s ≥ green_min_brightness
- Why chromatic: dividing by the sum removes exposure/shadow; a leaf at noon and the same leaf in shade share the ExG. Also removes the 8-bit vs 16-bit storage difference.
- Why not "G is the max channel" alone: grey asphalt, concrete and bark have G ≈ R ≈ B with G marginally largest under some white balances; the margin (
green_exg_min) is what rejects them. The phase-6 ExG study on A1 (docs/research/greenness-exg-phase6.md) gives the anchor numbers: crowns p50 +0.21 / p25 +0.14, man-made p75 +0.11, bark ≈ 0. - Brightness floor: optional (
green_min_brightness, default off). Very dark returns have noisy chroma; enable if shadow noise shows up in the QC rasters. - Colour is necessary, not sufficient: green is only consulted for points nobody else claimed (unclassified / ground), never over asphalt, and it is combined with height above ground. A green sign back or a green-painted fence caught by the detector stays sign/guardrail; an undetected green fence becomes medium vegetation — accepted for pre-annotation.
- What colour cannot do: brown winter grass, dry weeds, mown stubble stay
ground; conifers in deep shade may fall under the threshold (they are mostly detector trees anyway). The threshold is a knob and the first thing the QC loop tunes (sweep 0.06 / 0.10 / 0.14 on 085).
How low is separated from medium (and from tree)
1. Height above ground
From the points already classified ground or asphalt: 1 m grid, per-cell
10th-percentile z (robust to grass tops and stray low returns), empty cells filled from the nearest
filled cell (cKDTree on cell centres), then bilinear sampling → h = z − dtm(x, y) for every
candidate. Grass sits at h ≈ 0–0.3 m, hedges 0.5–2.5 m, crowns 3–15 m.
2. Column banding
Green candidates are binned into 0.5 m XY cells; each cell's band is decided by the 95th percentile of its candidate heights, and every candidate in the cell inherits it:
| cell p95 height | class | LAS | typical |
|---|---|---|---|
| < 0.5 m | low_vegetation | 3 | grass, weeds, ground cover on verges, median, embankments |
| 0.5 – 2.0 m | medium_vegetation | 4 | hedges, bushes, brambles, young trees without a crown |
| > 2.0 m | medium_vegetation (knob vegetation_tall_class: tree / unclassified) | 4 | tall bushes, crowns the detector missed, canopy overhang — "not detected as a tree" ⇒ medium by definition |
Consequences, deliberately accepted: grass directly under a crown the detector did not claim becomes
medium_vegetation (canopy columns). Per-point banding stays available as
vegetation_band_mode=point for comparison.
3. Tree vs hedge for detector clusters
The detector's tree clusters (≥ 2 m crown, RF ≥ threshold or conic/conifer rule) keep priority and are
never re-banded by colour. Inside verticalsigns each accepted cluster is put through the hedge test that
the instance splitter already runs out of band: grounded (p5 h < 2 m), low (p99 h ≤ 7.5 m), long
(≥ 8 m), stemless (< 1 strong stem seed / 10 m), continuous, flat-topped (relief ≤ 1.5 m). A hedge-shaped
cluster is emitted as medium_vegetation; everything else stays tree. seg3d maps
both through SIGN_TYPE_MAP. Fallback knob in seg3d (default off): re-band a detector tree
instance whose p95 height is below vegetation_tree_min_height_m to medium — a safety net
while the detector change is not yet in the production pin.
4. Precedence
ground → asphalt → lines → detector masks [vegetation first, guardrail, sign, supports last]
→ guardrail JSON → sign JSON → vegetation per-point (unclassified/ground, not above asphalt; low/medium only)
→ vehicle sweep (unclassified inside corridor) → voxel pick (tier: veg = ground < asphalt < …)
Phases
Phase 1 — seg3d: classes + per-point stage + QC loop core
classes.py:low_vegetation3,medium_vegetation4 (tier 1, colours above);SIGN_TYPE_MAPentries; docstring tier list.masks.py:PAINT_FIRST_CLASSES= the two vegetation classes (mirror ofPAINT_LAST_CLASSES).vegetation.py:ground_model(),greenness(),band(),classify_vegetation()→ masks + counters. Pure numpy/scipy, candidate-subset only, float32.fuse.py: step between sign JSON paint and vehicle sweep; skips for disabled / no colour / too few hard points;guard_metrics+stats.params.config.py+seg3d.default.json:vegetation_enabled, _from_ground, _asphalt_rule (asphalt_column|corridor), _low_max_m 0.5, _medium_max_m 2.0, _min_height_m −0.5, _tall_class tree|unclassified, _band_mode column|point, _band_cell_m 0.5, _column_percentile 95, _green_exg_min 0.10, _green_min_brightness 0, _ground_cell_m 1.0, _ground_percentile 10, _min_ground_points 1000, _tree_min_height_m 0 (off).- Tests: unit (DTM on a slope, greenness scale-invariance, banding modes), end-to-end on the vehicle-test template (codes 3/4/5, in-corridor → vehicle, median green survives with asphalt_column, detector tree untouched, disabled = today's output, no-RGB skip), config validation, registry/legend.
- Docs: README class table + paragraph,
fusion_spec.mdstep 6d,recap_annotation.mdclass table. - QC loop on 085 / 002 / 003 →
/tmp/veg_test: class raster (existingrender_las.py), RGB raster + height raster of the same extent (small new script), 3 crops (verge, median, canopy edge). Vision agent compares against RGB; sweepgreen_exg_min0.06/0.10/0.14 andband_mode; pick defaults; commit.
Phase 2 — verticalsigns: hedge verdict → medium_vegetation core
- New
hedge.py(or intrees.py): afterdetect_trees, for each tree detection gather its candidate points (candidate_xy_offset,candidate_heights,candidate_exg), runtree_instances._band_shape+_band_surfacewith theti_hedge_*config; on a hedge verdict settype = "medium_vegetation"(recordhedge_reason, keepexperimental: true). - Config:
tree_detection.hedge_split_enabled(default true) in_config_treedetect.py; allowlist in_config.py;test_config_split.pyfield count + last-field name. _POINT_MASK_TYPE_MAPunchanged (type string passes through);dedup_trees_against_signstreats medium_vegetation like tree.scripts/tree_instance_split.py: accept the new type (skip or treat as hedge) so it keeps working on new outputs.- Tests:
test_trees.pyharness — a hedge slab (fromtest_tree_instance_integration._hedge_slab) → medium_vegetation, a cone tree → tree, knob off → tree; point-mask writer characterisation tests stay green. - Run the detector on 085/002/003 from the worktree (
uv run verticalsigns-detect … --dump-point-masks, ~40 s/segment) into/tmp/vs373; fuse with--signs-masks /tmp/vs373; QC rasters again.
Phase 3 — reviews, PRs, production re-pin gate
- Review panel on both diffs: Sol (codex) + Opus adversarial in parallel; fix round by a worker agent; Fable final gate (code + visual outcome).
validate_las.py(allowed codes come from the registry — verify 3/4 pass),render_las.pylegend,deliver_pilot.shREADME class list.- Bitbucket PRs: seg3d →
feat/AI3D-369-production(or main, your call), verticalsigns → master. 1-line commitsAI3D-373: …. - Re-pin
wt-seg3d-fuse-prod-260827andwt-vs-prod-260827(copymodels/!), re-fuse pilot 085, deliver to N: as260827_pilot_b000_seg085update, report class counts + PNGs. Signs3 sweep must be rerun for the hedge split to reach production (guardrails3 untouched).
Later / out of scope follow-up
- Colourless datasets: intensity + roughness fallback for low vegetation (A45).
- Grass under canopy: hybrid rule (point < 0.5 m in a tall column with an empty 0.5–2 m band → low).
- A real 3-class vegetation model in verticalsigns (the RF today is tree-vs-not).
- Per-instance ids for hedges (currently detector instances only; low vegetation has no instance).
Risks
| Risk | Sev. | Mitigation |
|---|---|---|
| DTM bias on embankments: a 1 m cell on a 30 % slope spans 0.3 m of ground, so p10 sits at the downhill edge and uphill grass reads +0.2–0.3 m — still low band, but a 0.4 m weed patch can tip into medium. | med | Bilinear sampling of cell centres halves the error; knobs for cell size/percentile; QC on the 085 embankment. Alt: per-cell plane fit if it shows. |
| Colour balance differs between records/runs; one threshold may over/under-reach on some segments. | med | Chromatic ExG, not raw G; sweep on 3 segments from different stretches; per-run override via --set. |
| Green man-made objects the detectors missed (fences, painted rails, tarpaulins) become medium vegetation. | low | Accepted for pre-annotation; detector classes keep precedence; annotators fix the rest. |
Hedge verdict on short clusters (8 m minimum length): short bushes remain tree. | med | seg3d fallback knob (vegetation_tree_min_height_m) re-bands short detector trees by height; evaluate on 085 which default wins. |
| Memory / time: candidates on a 64 M-point segment could be 10 M+ (forest); percentile sorts and cKDTree fills must stay within 16 GB and a few tens of seconds. | low | Candidate-subset only, float32, sort-based group percentiles; measure on 085 (26 M) and the largest A1 segment. |
| Median vegetation vs vehicle sweep ordering (D6). | low | Vegetation runs before the sweep and only takes unclassified/ground; sweep takes what is left inside the corridor. Test covers it. |
json_index drift in the sign sidecar (edgedist compaction) — unchanged by this work but the new types ride the same sidecar. | low | seg3d already joins by type + XY (H1 fix in AI3D-369). |
Open questions
- Where should the per-point colour + height rule live?
default seg3d fusion (D1) — corridor, ground, precedence and RGB are all there; verticalsigns only decides tree vs hedge.
alt verticalsigns stage emitting low/medium masks — needs a second streamed pass over run3 (candidates start at 0.3 m), a rasterised run7 band test for the carriageway, and loading the guardrails sidecar for precedence; iteration = detector + fuse rerun.
- Median strip: allow vegetation between the two carriageways?
default Yes — reject only columns with asphalt beneath (D6); median grass/hedges get their classes.
alt Whole corridor polygon rejected; median residual stays
vehicleas today. - Height bands?
default low < 0.5 m, medium 0.5–2 m, tree > 2 m (ASPRS-style).
alt low < 0.3 m, medium 0.3–1.5 m — if the QC shows tall grass/weeds landing in medium.
- Green points the tablecloth kept as
ground?default Become
low_vegetation(D5); brown/grey ground stays ground.alt Only the unclassified residual is banded; grass stays ground.
- Tall green residual (> 2 m, not a detector tree)? answered 27 Aug
default
medium_vegetation— only the detector definestree; hedges and trees are exclusive.alt
vegetation_tall_class=treeorunclassifiedper run. - Hedge definition: promote the instance-splitter verdict as is (≥ 8 m long, ≤ 7.5 m tall, flat top)?
default Yes, plus the seg3d height fallback off; tune only after seeing 085/002/003.
alt Height-only: any detector tree with p95 height < 3 m is medium; skip the verticalsigns change for now (no signs3 rerun needed).
- Where does the seg3d branch merge?
default Into
feat/AI3D-369-production(production waits on this), then that branch to main as planned.alt Straight to main via its own PR.