AI3D-373 — split vegetation into low / medium / tree

2026-08-27 · for Miro · verticalsigns feat/AI3D-373-vegetation-split (/home/ai/dev/wt-vs-373) + seg3d feat/AI3D-373-vegetation-classes (/home/ai/dev/wt-seg3d-373, off feat/AI3D-369-production)

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

#DecisionWhy
D1Per-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.
D2Green = 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.
D3Height 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).
D4Low 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.
D5Candidates = 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.
D7Hedge 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.
D8Registry 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.
D9Colourless 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

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 heightclassLAStypical
< 0.5 mlow_vegetation3grass, weeds, ground cover on verges, median, embankments
0.5 – 2.0 mmedium_vegetation4hedges, bushes, brambles, young trees without a crown
> 2.0 mmedium_vegetation (knob vegetation_tall_class: tree / unclassified)4tall 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_vegetation 3, medium_vegetation 4 (tier 1, colours above); SIGN_TYPE_MAP entries; docstring tier list.
  • masks.py: PAINT_FIRST_CLASSES = the two vegetation classes (mirror of PAINT_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.md step 6d, recap_annotation.md class table.
  • QC loop on 085 / 002 / 003 → /tmp/veg_test: class raster (existing render_las.py), RGB raster + height raster of the same extent (small new script), 3 crops (verge, median, canopy edge). Vision agent compares against RGB; sweep green_exg_min 0.06/0.10/0.14 and band_mode; pick defaults; commit.
Phase 2 — verticalsigns: hedge verdict → medium_vegetation core
  • New hedge.py (or in trees.py): after detect_trees, for each tree detection gather its candidate points (candidate_xy_offset, candidate_heights, candidate_exg), run tree_instances._band_shape + _band_surface with the ti_hedge_* config; on a hedge verdict set type = "medium_vegetation" (record hedge_reason, keep experimental: true).
  • Config: tree_detection.hedge_split_enabled (default true) in _config_treedetect.py; allowlist in _config.py; test_config_split.py field count + last-field name.
  • _POINT_MASK_TYPE_MAP unchanged (type string passes through); dedup_trees_against_signs treats 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.py harness — a hedge slab (from test_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.py legend, deliver_pilot.sh README class list.
  • Bitbucket PRs: seg3d → feat/AI3D-369-production (or main, your call), verticalsigns → master. 1-line commits AI3D-373: ….
  • Re-pin wt-seg3d-fuse-prod-260827 and wt-vs-prod-260827 (copy models/!), re-fuse pilot 085, deliver to N: as 260827_pilot_b000_seg085 update, 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

RiskSev.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.medBilinear 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.medChromatic 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.lowAccepted for pre-annotation; detector classes keep precedence; annotators fix the rest.
Hedge verdict on short clusters (8 m minimum length): short bushes remain tree.medseg3d 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.lowCandidate-subset only, float32, sort-based group percentiles; measure on 085 (26 M) and the largest A1 segment.
Median vegetation vs vehicle sweep ordering (D6).lowVegetation 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.lowseg3d already joins by type + XY (H1 fix in AI3D-369).

Open questions

  1. 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.

  2. 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 vehicle as today.

  3. 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.

  4. 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.

  5. Tall green residual (> 2 m, not a detector tree)? answered 27 Aug

    default medium_vegetation — only the detector defines tree; hedges and trees are exclusive.

    alt vegetation_tall_class=tree or unclassified per run.

  6. 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).

  7. 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.