Handoff — seg3d: tree precedence over hard classes, and the mega-instance problem

2026-09-07 · for the agent working in /home/ai/dev/3dai.iolabs.pointcloud.3dsegmentation · all facts below verified against HEAD a584a8a

Goal

The verticalsigns detector paints tree (LAS 5) point masks that seg3d fuses in. Its DBSCAN instances sometimes merge into mega-instances (e.g. ~78 m long on segment 066) that swallow moving-vehicle streaks, road slivers, lane markings, barriers, lamp masts and gantry columns. Fix it on the seg3d side: everything seg3d is confident about must outrank tree. Only ground and unclassified should lose to it. Precedence removes most of the damage; instance splitting is a separate, probably unnecessary, second step.

Miro's framing: "Almost everything other than, perhaps, ground should have precedence over greenery."

How it works now (verified)

Stage order

fuse.fuse_segmentsrc/iolabs_point_cloud_segmentation_3d/fuse.py:1884-1892:

_load_state → _paint_ground → _resolve_surface → _paint_asphalt → _paint_lines_stage
→ _paint_masks   (detector sidecars: guardrails, then verticalsigns — trees land here)
→ _paint_guardrail_json → _paint_signs_json   (both: UNCLASSIFIED rows only)
→ _paint_vegetation → _paint_vehicles         (vehicles: UNCLASSIFIED rows only)
→ build_instances → decimate_with_map

Paint ranks

ThingLocationValue
PAINT_LAST_CLASSESmasks.py:96("guardrail_top_rail", "guardrail_support") — ranks +1, +2
PAINT_FIRST_CLASSESmasks.py:110frozenset({"low_vegetation", "medium_vegetation"}) — rank −1
_PAINT_RANKmasks.py:116derived from the two above; everything else rank 0
paint_ranks / paint_ordermasks.py:122 / masks.py:143rank lookup and its stable argsort
the paint itselffuse.py:270 _paint_mask_rowsrank < 0 group first + soft gate; everything else last-write-wins
the soft gatefuse.py:309soft_codes = (UNCLASSIFIED_CODE, BY_NAME["ground"].las_code)
tree classclasses.py:84SegClass("tree", 5, 4, (74,222,128)) — LAS 5, priority tier 4, rank 0
detector type → classclasses.py:118SIGN_TYPE_MAP["tree"] = "tree"

So today tree is a plain rank-0 detector class: it is painted after ground, asphalt and the lane lines with no gate at all, and it therefore overwrites asphalt (11), solid_line (64) and dashed_line (65). It also beats a plain guardrail (66) and wall (67) row, because the guardrails sidecar is loaded before the verticalsigns one and rank-0 rows are last-write-wins (fuse.py:316-320). Only guardrail_support (72) and guardrail_top_rail (74) already beat it, via PAINT_LAST_CLASSES.

Vehicles are a separate mechanism and this is the part that matters most: a point hanging over the carriageway is UNCLASSIFIED at _paint_masks time (asphalt only paints the road surface). _paint_vehicles (fuse.py:1647) then sweeps leftover = state.classification == UNCLASSIFIED_CODE (fuse.py:1682) inside the corridor polygon into vehicle (73). A mega-tree that already claimed those rows blocks that sweep — and a soft gate on tree will not unblock it, because UNCLASSIFIED is itself soft. This is the 066 vehicle-streak failure.

The changes

Proposed precedence table

Classvs tree todayvs tree afterHow
unclassified (1)tree winstree wins keepsoft code
ground (2)tree winstree wins keepsoft code
low_vegetation (3) / medium_vegetation (4)no contestno contest keepsame sidecar, one row = one instance = one class; and _paint_vegetation (fuse.py:1542) only touches unclassified + ground rows, so it never re-bands a tree unless vegetation_tree_min_height_m is armed
asphalt (11)tree winsasphalt wins changesoft gate
solid_line (64) / dashed_line (65)tree winslines win changesoft gate
guardrail (66) / wall (67)tree winsrail/wall win changerank −1 vs rank 0 in the same pass
sign (68) / gate (69) / delineator (70)no contest in practicethey win changesame as above (only bites where the guardrails sidecar also claims the row)
guardrail_support (72) / guardrail_top_rail (74)they winthey win keepPAINT_LAST_CLASSES, unchanged
vehicle (73)tree blocks the sweepvehicle wins needs change 2the soft gate does not reach this — see below

Change 1 — the one-liner do this first

src/iolabs_point_cloud_segmentation_3d/masks.py:110:

PAINT_FIRST_CLASSES: frozenset[str] = frozenset({
-    "low_vegetation", "medium_vegetation",
+    "low_vegetation", "medium_vegetation", "tree",
})

No other code change is needed for it: _PAINT_RANK (masks.py:116) is derived from the frozenset, and _paint_mask_rows gates every rank < 0 row on soft_codes. PAINT_FIRST gives exactly the semantics Miro asked for — "beats ground and unclassified, loses to everything else" — because the gate is literally (UNCLASSIFIED_CODE, ground). Verified, not assumed.

Two second-order effects to know about, both wanted:

Change 2 — the follow-on, required the one-liner alone does not fix 066

The vehicle sweep only considers UNCLASSIFIED rows, so tree paint over the carriageway still wins by squatting. Let the sweep reclaim tree rows — src/iolabs_point_cloud_segmentation_3d/fuse.py:1682:

-    leftover = state.classification == UNCLASSIFIED_CODE
+    leftover = np.isin(
+        state.classification,
+        (UNCLASSIFIED_CODE, BY_NAME["tree"].las_code),
+    )

Rationale to put in the docstring: the sweep is already the pipeline's "what is hanging over the road" rule, and a detector tree whose crown allegedly covers the carriageway between the outer asphalt edges is a vehicle streak far more often than a canopy. Gate it behind a config key so it can be turned off per dataset: add vehicle_reclaim_tree: bool = True next to vehicle_enabled (_config_model.py:118, docstring at _config_model.py:67, default in seg3d.default.json:17) and follow that field's existing pattern exactly.

Alternatives considered and rejected: moving the tree paint after _paint_vehicles (invasive, breaks the "masks are one pass" invariant and the instance bookkeeping), and dropping tree from the mask sidecar in seg3d (loses real trees).

Change 3 — docs + tests that will fail

Not to be changed: paint_signs_from_json (fuse.py:687) and _sign_footprint_rows (fuse.py:645) already paint UNCLASSIFIED rows only (fuse.py:683), so the JSON-only tree cylinders already obey the desired precedence. Change 2's reclaim covers them too.

Mega-instance splitting — optional second step

The idea: break a tree instance where its footprint is cut by a non-vegetation class, then drop fragments below a min point count / min extent.

Recommendation: do not implement it now. The evidence says precedence removes the visible damage. The gallery's "Hard structure removed" section is exactly the class of error changes 1+2 handle (road panel under the gantry on 093, road slivers, lane markings, barriers). What splitting would add on top is only instance-level hygiene — the 78 m instance stays one instance id even after its non-tree rows are stripped, so the instance table and any per-instance geometry (bbox, footprint, height) stay wrong even though the per-point classification is right.

Decide it on the number, not the picture: after changes 1+2, re-run and look at the tree instance table for 066 (stats / instances.csv). If long instances are still there and anyone consumes tree instance geometry downstream, then split; the natural place is a post-pass over MaskInstance.global_rows before build_instances (fuse.py:1885-1898), splitting on connected components of the surviving rows in XY and dropping fragments under a configurable min size. Note that the real fix for instance identity is upstream in the detector's DBSCAN, not here — flag it back rather than growing seg3d a clustering stage.

Verification

Inputs

Segments and what to look at

  1. 066 — the mega-instance segment. Moving-vehicle streaks currently painted tree must become vehicle (73). This is the headline check for change 2.
  2. 093 — road panel under the gantry: must become asphalt (11), change 1.
  3. 003 — lamp mast / gantry column swallowed by a tree instance. expect no fix Neither change helps here: seg3d has no independent claim on those rows, so they stay tree unless the detector emits them as sign/gate. Report it back to the verticalsigns side rather than inventing a seg3d rule.
  4. 085 — the acceptance segment, because it has human ground truth.
  5. 018 / 060 — regression only: tree-heavy verge, must not lose tree points to the vehicle sweep.

Checks

Risks

House rules