AI3D-339 — TCS ground filter + rule-based conifer detection (no ML training)

2026-07-17 · rev 5 (rewritten for the implementation agent; 2026-07-18 research folded in) · Jira AI3D-339 (commit prefix AI3D-339:) · sibling of plan 2 (A1 greenness) · all compute on battlebox

Summary

Brief (verbatim constraints): use TCS to better detect signs and greenery, improve conifer detection, no ML training. This plan is purely geometric and colour-free; validated on A4_5.

Two tracks:

  • TCS Option C (primary): build the existing p8 DEM from TCS-ground-classified points only → better HAG → better height gates for signs (1.5–6 m band, 0.90/0.60 seed gates), delineators (0.7–1.5 m band), tree seeds (vertical_span ≥ 1.5).
  • Conifer: §1a dedup fix + a colour-free conic hand-gate OR-bypass around the veg RF in trees.py. The RF cannot pass conifers (its positives never contained one; its top feature crown_isotropy is information-free for cone-vs-pole: cone 0.973 / cylinder 0.996 / sphere 0.998); the bypass is the only path that surfaces them. Both halves needed — §1a alone only fixes trees the RF already passes.

Why a rule, not the RF: the shipped bundle carries its own feature_names (ml.py:437-441 getattr loop) — new ClusterFeatures fields cannot reach it without a retrain, and its training labels are machine-generated with zero human supervision (never cite its cv_roc_auc 0.98). New features have value only if a RULE consumes them.

Verification reality: no labels exist or will. All verification is label-free: ΔDEM raster audit, HAG band-flip census, per-class count deltas, and a visual overlay pass on A4_5. The agent runs that visual pass itself — for static overlay PNGs it just views the images directly; when it needs to inspect the actual cloud it drives CloudCompare on battlebox via Codex computer-use (codex-bb-runtime, optional — only when flat images aren't enough) to open, compare, and capture screenshots or short videos. That agent pass is the acceptance bar. Faults the agent sees, it fixes on the spot without asking a human for a second opinion. The same artifacts double as a review view a human can open on escalation, never as the first pass.

Environment: battlebox only (ssh battlebox "bash -s" <<'EOF' … EOF; WSL user ai; base python has no numpy — repo venvs at ~/dev/3dai.iolabs.pointcloud.verticalsigns and ~/dev/3dai.iolabs.pointcloud.tablecloth @ d612f67). Data: /mnt/d/Data/02 AI 3D modeling/00_data/ — A4_5 has all 13 curated hard segments (incl. 033 bridge case, 065/066 TCS's prior target); /mnt/d is slow 9p — intermediates to local ext4. Worktree branch current with master; commit per work item, draft PR, never push master.

Contract (binding decisions)

Decisions

DecisionOperative consequence
Additive-only is HARD — new field names, never in-place semantic changes; a config flag is NOT protectiongreenness and verticality feed the shipped RF and sign-path rules (classify.py:91, :112, :230). Existing fields stay byte-identical; new rules read new fields. Enforced by Phase 4's byte-identical gate + pinned regression tests.
Dataset assignment is forced by the colour splitEverything here → A4_5 (sentinel-white RGB — colour-independent by design). A1 = only live colour → plan 2. A3 → nothing (spatially mixed colour, all-zero scan_angle). Intensity is the only radiometric channel trustworthy corpus-wide; hi_intensity_fraction is the only allowed radiometric conjunct.
TCS = Option C, not Ap8 DEM built from TCS-ground points only — keeps DEM semantics that ~15 height-gate constants were tuned against. Option A (raw TCS surface) only if C leaves slope-driven FNs.
Option C via write_cleaned_npz, zero shared-package changeTCS-classify each *_run3_points.npz → ground-only npz (tablecloth/reader/io.py:120-163, row-subset, keys preserved) → repoint only the DEM build's file list (detect.py:465-471; files at :453). The shared builder (raster/ground.py:93-96) never learns TCS exists.
Fork a verticalsigns TCS profile; never inherit lip-first defaultsTableclothConfig (tablecloth/config.py:30-61, frozen dataclass — construct directly). Its defaults are tuned for pavement-lip retention; smrf_max_window_m=3.0 lets trucks survive into the surface. Sweep {3, 6, 12} × elev {0.05, 0.15, 0.30} × slope {0.15, 0.30}.
Conifer mechanism = colour-free, two-cue conic hand-gate OR-bypass (shape AND texture — literature requires ≥2 independent cues for vegetation)All thresholds in config (never hard-coded); ships off-by-default; emissions tagged reason="conic_rule", experimental: True. Texture conjunct reads the existing change_of_curvature/omnivariance fields.
decile_fill_fraction = the rule's abstention guardOcclusion-starved radius profiles (far side missing → low apex_deg) must abstain, not guess.
verticality_raw: omitNo rule reads it → dead weight under no-training (Q4 default).
Greenness: separate plan (A1-only)Nothing here touches the RGB path in grid.py.

Killed options — do not rediscover

Validation rule for any NPZ field

Check provenance, not nomenclature. Validate on VALUES (min/max/nunique) per dataset — per cluster where scan provenance mixes (A3 RGB). Validate any "verified"/"labelled" artifact on who produced it (qc_verdicts.json is LLM output, not human). Prefer cluster-geometry features over upstream fields wherever both could serve.

File partition (parallel agents)

Serialized contract commit first (Phase 2: config.py + ClusterFeatures declarations + _features_to_row). Then disjoint bodies: Solfeatures.py; Groktrees.py; Sonnet — TCS module + detect.py wiring + pyproject.toml. Nobody touches ml.py or grid.py. Field names frozen in the Phase-2 prompt, quoted verbatim downstream. Phase 4 is a hard barrier. Line-number caution: some report line refs have drifted — re-grep, don't trust (verified anchors: dedup call detect.py:718-720; ground build detect.py:465-471 off files at :453).

Phases

Phase 0 — fix the ml.py:9 provenance docstring ~1 line · any agent

ml.py:9 calls qc_verdicts.json "the human QC verdicts". It is LLM output (Sonnet/Opus inspector batches + Fable adjudication, order-matched, detector @74d6942) — the docstring asserts a guarantee the data does not carry. Replace with an accurate provenance statement. Land first, standalone.

Done when: read back; no code path touched.

Phase 1 — TCS Option C integration (PRIMARY) L · Sonnet · new module + detect.py + pyproject.toml

Dependency (resolved 2026-07-18): the tablecloth wheel is not on Nexus (verified: authenticated resolver reports not-found). Use path source via [tool.uv.sources] (commented precedent pyproject.toml:33-43); zero-setup on battlebox (sibling checkout + venv @ d612f67). Publish iolabs-point-cloud-tablecloth 0.1.0 to Nexus (wrap-up flow) before CI/Azure needs this branch — note it in the PR.

API: classify_ground(points, config) → kept_mask (tablecloth/ground.py:27), build_smrf_surface_streaming (:49), classify_against_surface (:100), classify_mask_from_chunks (:120); SmrfSurface = grid, xmin, ymin, cell_m (:17-24).

Mechanism: new module tcs_ground.py: per record, classify → write_cleaned_npz(source, dest, kept_mask) → filtered path list → repoint the DEM build only (detect.py:465-471).

critical Repoint the DEM input only — never the candidate input. accumulate_candidates (detect.py:491) keeps reading the original run3. TCS removes vegetation as non-ground: feed cleaned NPZ to the candidate path and every tree disappears, presenting as "TCS didn't help", not as a bug. Distinct names (run3 / run3_ground) + a call-site assertion.

Mechanism A/B: if the C-profile sweep leaves slope artifacts, csf_cloth has road-corridor-validated starting profiles per slope class (Cai 2018): flat → rigidity 3 / res 0.3 / thresh 0.15 / 150 iter; gentle → 2 / 0.2 / 0.15 / 200; steep (cuttings) → 1 / 0.3 / 0.2 / 300. Pick per segment terrain. CSF has a verified bridge-as-ground failure interacting with the bridge guard (trees.py:144-146, tuned on segment 033) — regression-check 033 whenever csf_cloth is tried.

Done when (all label-free, A4_5):

  1. ΔDEM raster audit before any detector run — (Option-C DEM − baseline p8), histogram + map of |Δ| > 0.15 m cells; attribute each region: vehicle/canopy removal (intended) vs terracing/fill vs slope/wall climb (ground climbing vegetated slopes or retaining walls → HAG≈0 → objects vanish from residue; check cells adjacent to walls/noise barriers) vs crest cut-off (convex embankment tops cut from ground → false residue).
  2. Band-flip census — recompute HAG for every current detection under the C DEM; count crossings of 0.30/0.60/0.70/0.90/1.5/4.5/6.0 m boundaries. Near-zero flips on clean segments + concentrated flips on contaminated ones = working as designed.
  3. Per-class detection-count deltas on curated segments (volume, not correctness).
  4. Flag off ⇒ DEM byte-identical.
  5. Runtime/RSS benchmark; cleaned npz to local ext4, not /mnt/d.
Phase 2 — contract commit (serialized; blocks 1/3a/3b) S · one agent

Pure declarations, zero logic — every multi-agent file, landed once, alone.

  • config.py: TCS block (enable flag + profile params); conic-gate block (enable flag default off + all thresholds as config fields). Each field needs its loader line (tree_detection.get(…) pattern, config.py:617-646).
  • features.py:15-59: declare new ClusterFeatures fields, 0.0 defaults, no computation: taper_slope_n, taper_ratio, apex_deg, h_over_width, azimuthal_coverage, crown_h_over_w, decile_fill_fraction. No verticality_raw, no greenness_exg. Names are the contract.
  • detect.py:152-190 (_features_to_row): add every new field to the dict literal — otherwise the features never reach clusters.csv and the distribution dumps are empty.
  • NOT touched: ml.py, TREE_FEATURE_NAMES, existing greenness/verticality semantics.

Done when: full pytest green; one real A4_5 segment shows new columns as 0.0 in clusters.csv; the shipped veg bundle loads and scores byte-identically.

Phase 3a — _conicity features (TDD) M · Sol · owns features.py

The synthetic table IS the fixture — tests first, from idealized cone/cylinder/sphere samplers. Assert direction and ordering, not margins (samplers have no occlusion/clutter; real margins will be weaker):

featureconepolebroadleaf
taper_slope_n≈ −1.03≈ 0.00≈ +0.96
apex_deg10.6–29.00.8–1.938.7
h_over_width1.9–5.430–701.4

Method (_conicity(xy, heights, h_min, h_max)): zn ∈ [0,1], 10 deciles; per-decile radius = 90th pct of XY distance from the decile's median XY (median → robust to one-sided scan; percentile-of-distance → robust to far-side occlusion). Guard: zeros if <5 deciles have ≥10 points. Vectorize via bincount per _core_rms_median (features.py:77-113). Emit decile_fill_fraction alongside. azimuthal_coverage: implement (16 sectors above 0.5·H), expect nothing.

Not doing: any change to _verticality, greenness, or any existing field (hard additive constraint); any grid.py change.

Done when: TDD table green; regression tests pin verticality/greenness byte-identical on synthetic clusters; distribution dump of the new columns across A4_5 tree-path clusters — including change_of_curvature/omnivariance for the texture-conjunct calibration (do real values bimodally separate around the seed thresholds, or smear? feeds the Phase-5 enable decision).

Phase 3b — §1a dedup fix + conic OR-bypass M · Grok · owns trees.py

§1a — dedup type filter ~3 lines: dedup_trees_against_signs (trees.py:180-198) currently drops any tree within 2.0 m of any sign-path detection — including the pole_other the sign path itself emitted for that conifer (classify.py:195). Filter suppressors before sign_xy is built (trees.py:188-190):

SUPPRESSING = {"sign", "delineator", "gantry_or_gate"}   # NOT pole_other / sign_post

Type strings per detect.py:218-222; sign_post is a distinct internal type (reported as pole_other in the sidecar, detect.py:227-232) — exclude both. Don't touch the radius or detect.py:718-720.

Conic OR-bypass — at the emit decision (trees.py:147-150): emit if confidence ≥ threshold OR (gate enabled AND conic rule passes). Scope change vs rev 4: the rule is two-cue by default — shape (taper) AND surface texture — because the verified literature refutes single-cue-family vegetation rules (foliage can read linear or planar at a single scale; every validated rule system requires ≥2 independent cues). Foliage is scattering-rough; a pole/mast is smooth. All thresholds from config; seed values:

shape   = (f.taper_slope_n <= -0.4 and 5 <= f.apex_deg <= 35
           and 1.5 <= f.h_over_width <= 12)
texture = (f.change_of_curvature >= cc_min or f.omnivariance >= omni_min)
           # existing fields — rules may read them today; calibrate both
           # thresholds from Phase 3a's A4_5 distribution dump
conic = (shape and texture
         and f.hi_intensity_fraction < 0.2
         and f.h_max >= 2.5 and f.on_road_fraction < 0.6
         and f.decile_fill_fraction >= fill_min)   # abstain on occlusion-starved profiles

seed values Unvalidated; must not ship enabled by default. Upstream hard gates still apply (tree_min_points, tree_crown_h_min_m, tree_max_dist_to_road_m, bridge guard trees.py:144-146) — the bypass skips only the RF. Emissions get reason="conic_rule" + experimental: True. A texture_cue_enabled config field (default on) allows A/B-ing the texture conjunct off during Phase 5 diagnostics. If A4_5 distributions show change_of_curvature/omnivariance don't separate foliage from poles at cluster level, the fallback texture cue is a per-cluster sphere/cylinder neighbor ratio (planar/solid → ~100, canopy low; pure XYZ, no return numbers needed) — that variant needs a new feature field, so it goes through a contract-commit addendum, not ad hoc.

Leave alone (verified not miss causes): seed gate (trees.py:33-50), tree_crown_max_area_m2=4.0, tree_dedup_radius_m=2.0, tree_eps_m=1.5, tree_crown_h_min_m=2.0.

Done when: unit tests — dedup: tree at 1.0 m from pole_other survives, from sign dropped, empty list short-circuits; bypass: conic synthetic emits at sub-threshold confidence, pole synthetic does not, occlusion-starved cone abstains, gate-off ⇒ identical to today. Then A4_5 curated segments: tree-count delta from §1a alone, then §1a + gate, counts split by reason.

Phase 4 — test barrier S · serialized · after 1 + 3a + 3b

Hard barrier; single agent owns the fix pass. Full pytest on battlebox.

Acceptance gate for additive-only: all new flags off ⇒ end-to-end output on 2–3 real A4_5 segments byte-identical to baseline except the §1a dedup delta. Any other diff is a bug — especially in greenness/verticality columns or sign-path detections.

Phase 5 — label-free verification battery + agent visual pass M · battlebox, A4_5
  • ΔDEM audit + band-flip census (Phase 1 items 1-2).
  • Detection-count deltas per class × curated segment × {baseline, +§1a, +gate, +TCS}.
  • Overlay inspection — agent-first. Generate --dump-point-masks + per-cluster overlays for every conic_rule emission and every band-flipped detection on the 13 curated segments. The agent views these itself and judges conifer-vs-pole and gate-flip sanity: read the overlay PNGs directly for the quick pass; for anything ambiguous, open the cluster's actual point cloud in CloudCompare on battlebox (Codex computer-use via codex-bb-runtime, optional — desktop stays on battlebox, local screen free), rotate/compare, and capture screenshots or a short video to keep as the record. This agent pass is the acceptance bar. Any fault the agent sees — a pole tagged conic_rule, a mis-flipped band, a bad threshold — it fixes immediately (tighten the gate, correct the code) and re-runs, no human second opinion required. The saved overlays/screenshots double as a browsable review view for optional human escalation after the agent's pass.
  • Conic-gate distribution check (Phase 3a dump) — if real A4_5 values smear across the seed thresholds instead of separating, the gate ships off, period.

Then adversarial review (Opus — shipped-RF OOD surface + sign-path regression; Sol — _conicity numerics; Grok — the run3/run3_ground seam + bypass logic), serialized fix pass, re-run the Phase-4 byte-identical check, draft PR on the working branch.

Risks

risksevmitigation
In-place change to live RF inputs → silent degradationriskHard additive-only rule; Phase-4 byte-identical gate; pinned regression tests on greenness/verticality.
TCS cleaned-NPZ fed to the candidate path → every tree disappearsriskDistinct run3/run3_ground names; call-site assertion; named review item.
OR-bypass promotes poles to treesriskTaper + h/w moat; hi_intensity_fraction conjunct; abstention guard; off-by-default; experimental + distinct reason; agent visual pass (§Phase 5) before any default-on, fixing what it sees; texture-cue conjunct as the next dial.
Ground climbs slopes/walls (HAG≈0 → objects vanish); crest cut-off manufactures residuewarnNamed attribution categories in the ΔDEM audit; inspect cells adjacent to walls/noise barriers; expect scan-direction asymmetry; per-slope-class csf_cloth profiles as fallback on cutting-heavy segments.
Seed thresholds unvalidated; no labels everwarnGate off by default; real-distribution dump before any enable; explicit visual acceptance bar.
TCS lip-first defaults inherited (trucks survive smrf_max_window_m=3.0; 0.05/0.15 never tested off-pavement)warnForked profile + {window × elev × slope} sweep + ΔDEM audit first.
Shared-worktree racewarnSerialized Phase-2 contract commit; disjoint bodies; Phase-4 barrier; commit per work item.
§1a double-emission (bypassed conifer coexists with its pole_other)warnAccepted; experimental: True; confidence-arbitrated merge is follow-up.
tree_eps_m=1.5 merges conifer+hedge → taper destroyedokRule abstains (correctly); blob-split is recorded follow-up.
9p throughput on /mnt/dokBatch per segment; read each npz once; intermediates to ext4.

Open questions (for Miro)

Resolved since rev 4 and removed: conic gate ships off-by-default (decided — Phase 3a/5 evidence drives the enable); TCS dependency = path source, wheel verified absent from Nexus (Phase 1); verticality_raw omitted (no rule reads it); the agent's visual pass is the acceptance bar (the only one the no-labels reality permits — agent looks first and fixes what it sees, human review is escalation only). Standing note, not a question: qc_verdicts.json's counts are order-matched to a detector 6 commits stale — a landmine for whoever next runs --mode vegetation; re-derive with coordinates or retire before any future training use.

  1. Q1 — is the OR-bypass's FP exposure acceptable — is experimental: True enough cover? Depends on what downstream consumers do with experimental trees; not answerable from this repo.