Tree recall — plan

2026-08-29 · for Miro · verticalsigns tree stage, follow-up to AI3D-373 · evidence: 104 production tree_clusters.csv (A1B0), 2 fused segments, offline RF rescoring

Summary

The handoff's diagnosis is wrong in two places, and it changes the plan.

  • Not fragmentation — over-merge. DBSCAN (eps 1.5 m on 0.15 m seed cells) chains whole belts into single 30–140 m clusters; the RF then gives one all-or-nothing verdict on each. A centroid merge step would move 0 points (7 segments, D = 3 m). 61.6 % of all tree-path points die on that one RF verdict; every other reject combined is 3.8 %.
  • Height is not the yardstick — Miro is. The agents judged with a "≥3 m robust" rule and called the 002 line (2.5 m p98), the 060 belt (2.3/2.1 m) and the 085 verge (Fig. 5, 2.3 m) hedges/scrub. Miro judged Fig. 5 as trees (29 Aug), so that yardstick is too strict: no height gate anywhere; h_max_robust stays an info column. Miro also ruled Fig. 3 (060 green wall) not a tree and Fig. 8 (060 sparse blob) a tree — so the line is crown structure, not height: 2 m walls stay out, 2.3 m crowned verges are in. Prize ≈ 8.75 M pts (333 rf-rejects with p98 ≥ 3 m, vs 14.0 M accepted today) as the working figure; the p98 band is a sorting aid only. Precision: no known debt — the flagged 94 accepted sub-3 m clusters are not treated as FPs.
  • The RF is a compact-crown detector. crown_isotropy carries 28 % of importance and acts as a hard gate at ~0.5–0.6; all 26 reproducible training positives have isotropy ≥ 0.57; 83 % of tall rejects sit outside that support. A 100 m line scores p ≈ 0.10–0.16 no matter how it is cut. Threshold moves are ~1:1 recall:FP and never reach the lines.

Proposal: change the unit of the verdict, not the classifier. (1) Tile every long/large cluster into ~8 m units in its PCA frame, recompute features per tile, score each tile with the frozen RF. Tiles of a tree line look like the isotropic crowns the RF was trained on; scrub tiles inside 085's blob get rejected on their own. (2) No height gate — RF-only verdicts; optional relaxed RF threshold for tall units behind a knob. (3) First build a judged truth set on ~10 fused segments, because today only one wrong reject (085 c5, 869 pts) is human-verified — the recall case rests on cluster statistics. All behind knobs, seg3d untouched, no retraining unless you want it later.

Evidence (what the workflow found)

Where the points go (104 segments, 1,716 clusters, 40.4 M tree-path pts)

outcomeclusterspoints% ptsrf_p p50note
reject_rf_below_threshold1,14724.87 M61.60.17the loss
vegetation_rf (accepted)21413.97 M34.60.8376 % in 3 forest segments (051/015/016)
reject_crown_h (h_max < 2)961.50 M3.7all in [1.82, 2.00) m — hedge height, leave
conic_rule + conifer_rule180.04 M0.090/1147 rejects satisfy conic, 1/1147 conifer
reject_min_points2410.007 M0.02dust
reject_dist_to_road, reject_bridge_guard000inert on A1 (no run4 → dist = inf)

Offline rescoring with the shipped bundle reproduces the logged verdicts on 99.4 % of rows (the rest is CSV rounding) → cluster-level experiments do not need detector reruns. accepted/type columns are all-False/empty in prod tree_clusters.csv (detect.py:1289 passes dtype=None) — derive from outcome.

Length is the failure axis

len_major0–3 m3–66–1010–2020–40≥ 40
clusters reaching RF6133491831325844
crown_isotropy p500.430.500.550.510.410.30
accept rate0.070.240.280.290.220.11
points0.5 M1.1 M1.6 M2.9 M10.9 M21.8 M

The 44 longest clusters hold 54 % of all tree-path points and are accepted 11 % of the time. Median accepted cluster: 6 m long, isotropy 0.76. Forcing crown_isotropy on all rows: at 0.40 → 0 clusters pass, at 0.60 → 301, at 0.80 → 431.

Fig. 1crown_isotropy: accepted vs tall rejects (set A) vs short accepted (set B). The RF's top feature separates by shape, not by tree-ness.
Fig. 2 — RF probability. Tall rejects (h_max ≥ 3, n ≥ 60) have p50 0.21, p95 0.58 — a hard-rejected mass, not near-misses. Threshold 0.40 flips 65 tall and 69 short clusters (1:1).

Robust height as an analysis proxy (not a rule)

gate on rf-rejects (h_max ≥ 3, n ≥ 60; 431 cl / 19.43 M pts)clusterspoints
h_max_robust ≥ 2.539415.51 M
h_max_robust ≥ 3.03338.75 M
h_max_robust ≥ 3.52304.85 M

Biggest tall-robust rejects: 093/1 (1.10 M pts, 101 m, h_rob 3.6, p 0.16), 022/0 (0.77 M, h_rob 3.6), 086/0 (0.76 M, h_rob 6.4, iso 0.55, p 0.29), 094/0, 023/7, 087/2 (h_rob 4.6), 090/0 (h_rob 5.0). Biggest hedge-height "misses": 018/0 (1.24 M pts, 140 m, h_max 6.8 but h_rob 2.7), 002/0 (0.39 M, h_rob 2.5), 060/12 (0.23 M, h_rob 2.3). Caveat: p98 of a 400 k-pt cluster can hide real trees inside it — another argument for per-tile verdicts. After Miro's Fig. 5 call, treat these bands as a sorting aid for the truth set, not as tree/hedge truth.

Counterfactual on the 333 (frozen RF): set crown_isotropy alone to the accepted median → 119 pass (1.36 M pts); set the whole 3-D shape block (isotropy, plate_thickness, omnivariance, eigenentropy, curvature, core_rms) → 305/333 pass, 8.65 M pts; same substitution on 651 short rejects admits only 28 (0.44 M) → shape repair is not a scrub-admitting move. Upper bound, not a prediction — tiles must be measured.

Pictures (fused LAS; tree = mint, medium = teal, low = yellow-green)

Fig. 3 — Seg 060, the "missed tree line" from the handoff: a solid green wall, 31 k medium pts, 0 tree. Ruler: top 2.2 m (p95) / 2.7 m max. Miro (29 Aug): not a tree.
Fig. 4 — Seg 060 top-down: the 50 m belt is one 230 k-pt cluster (p 0.13). Note the sparse dotted canopies further left — far-field trees at low density that never seed; out of scope here.
Fig. 5 — Seg 085, agent-flagged "false positive" — Miro (29 Aug): these are trees. Agent view: 33 k mint "tree" points over flat dry verge scrub, top 2.3 m p95. Not a separate cluster — it is the interior of accepted cluster 2 (54 × 48 m, 369 k pts, p 0.7075, 0.03 above threshold).
Fig. 6 — Same area at scale (agent view): the mint carpet runs to the edge of the grass strip. 340 of 1,221 tree cells on 085 have canopy top < 3 m. Miro: trees — tiles here must stay accepted; this is the precision anchor for the eval.
Fig. 7 — Seg 085 correct tree (Miro: trees): trunks + closed canopy, 6.6 m p95. Cluster 5 next to it (869 pts, 7 m forest, h_rob 4.2) is the one human-verified wrong reject: p 0.5625.
Fig. 8 — Seg 060's only accepted tree (p 0.915, 1.2 k pts): a compact isotropic blob 2.98 m high — the kind the RF likes — next to the 217 k-pt hedge belt it scores at 0.10. Miro (29 Aug): tree.

Other facts that bind the design

Key decisions

  1. Change the unit of the verdict, keep the classifier. Tile large clusters → per-tile RF verdict. Rejected: centroid merge (moves 0 pts), threshold-only (1:1 FP, lines unreachable at p 0.10), conic/conifer relaxation (binding conjuncts are exactly what a belt fails), retrain-first (26 positives, three code blockers).
  2. No height gate; RF-only verdicts (Miro, 29 Aug). h_max_robust (p98; h_max is a single point + DEM error — 060/12: 4.86 vs canopy 2.2 m) is reported per tile as an info column and used to sort the truth set, never to reject.
  3. Recall metric = Miro-calibrated judged spots (judges shown Fig. 5/7 as "tree" anchors), never "medium points near trees" (overstates 4–70× vs seg3d's tall-column count) and not a height proxy. FP weighted 2×.
  4. Phase 0 is a gate. If the judged truth set shows the big rejected clusters are mostly not trees, stop and report.
  5. Everything behind knobs, default off in code; prod2_A1B0.config.json flips them after eval. CSV changes append-only. seg3d unchanged.
  6. Not touched: hedge split (stays off), candidates.min_height_m, sign path, guardrails, RF bundle, colour rules.

Phases

Phase 0 — Truth set + eval harness ½ day · Opus worker + Opus vision
  • Fuse 12 segments chosen by big-reject mass: 093, 022, 086, 094, 023, 087, 090, 031 + 002, 003, 066, 018 (wall-type negatives), plus existing 060/085. seg3d-fuse 2–4 min each. Guardrail masks exist only for 002/003/060/085 — use what exists (trees don't depend on them); record the flag.
  • Spot list (~70): every rf-reject with h_rob ≥ 3 and n ≥ 10 k in those segments; every accepted cluster with h_rob < 3; 5 random accepted; the 002/060 hedge lines as known negatives. veg_perspective.py at r = 8 m, az 45/225. Opus vision judges label ∈ {tree, not-tree} + canopy height, calibrated on your calls (Fig. 5, 7, 8 = tree; Fig. 3 = not); FP weighted 2×; contested spots (judge disagreement) go to a small gallery page for you.
  • scripts/tree_recall_eval.py <detector out root>: per truth spot, tree points painted within r = 6 m → recall (tree spots claimed), FP (non-tree spots claimed), weighted score; plus corpus proxies from tree_clusters.csv: accepted pts, rf-reject pts, both split by h_rob band (info only). Baseline from signs3_A1B0.
  • Tiny fix: write real accepted/type in tree_clusters.csv (detect.py:1289). Separate file from the sign-path clusters.csv, so the sign RF training reader is unaffected.
  • Gate: ≥ 60 % of big-reject spots judged tree → continue. Else stop and report.
Phase 1 — Tile-and-score 1–2 days · Opus worker, TDD

Seam: top of the reject ladder in detect_trees (trees.py:183), after features + context exist.

  • Knobs (_config_treedetect.py, tree_detection.*): tile_enabled False, tile_size_m 8.0, tile_trigger_len_m 12.0 (tile only if len_major > trigger), tile_min_points 60 (under-filled tiles merge into the nearest neighbouring tile of the same parent).
  • Mechanics: PCA frame of the cluster's XY → regular tile_size grid → per-tile index arrays (partition of the parent's candidate indices) → replace the parent in clusters/features by its tiles → compute_cluster_features per tile → re-run assign_context over the full list → unchanged ladder per tile. Accepted tiles emit as separate tree detections (seg3d joins by type + XY, fine).
  • CSV: append parent_cluster_id, tile_ix after edge_filtered_from (CSV_FIELDS append-only; RF reads by name). One row per tile; parent row kept with outcome tiled.
  • Why 8 m: 2× the median accepted len_major (6 m); a 100 m × 5 m line → 13 tiles at isotropy ≈ 0.6; 085's 54 × 48 m blob → ~40 tiles, scrub tiles (h_max ~2.3, flat) and canopy tiles get separate verdicts; an 8 × 2 × 2.2 m hedge tile keeps isotropy 0.25 / h_max 2.2 → still rejected.
  • Tests (write first): elongated 60 × 4 × 5 m fixture + _AlwaysTree → N tiles emitted, all points painted, forest_neighbor_count > 0 after context re-run; under-filled tile merge; per-tile h_max ≤ parent; tile_enabled=False → byte-identical output (golden); real-bundle smoke test (skips if models/ absent): synthetic line of isotropic crowns rejected whole, ≥ 50 % of tiles accepted.
  • Trap: copy models/ into the new worktree (git-ignored) or the tree stage runs disabled silently.
Phase 2 — Info columns + optional relaxed threshold ½ day · same worker
  • No height gate (Miro). h_max_robust is already per row (features.py:322); tiles get it automatically. Add parent_h_max_robust so a tile row shows its parent's value too (append-only).
  • tree_tall_relaxed_confidence (default −1 = off; eval 0.5) with tree_tall_relaxed_min_robust_h_m 3.5: units this tall pass at the relaxed RF threshold (accept-side only, never rejects). Rescues 085 c5 (p 0.56, h_rob 4.2); corpus ceiling ≈ 34 clusters / 0.68 M pts at 0.5 ∧ h_rob ≥ 3. Q4 decides.
  • Tests via the existing _run_with_feat harness; reason string appears in CSV.
Phase 3 — Evaluate 1 day incl. runs · workers + vision judges
  • Variants: V0 baseline · V1 tiles · V2 tiles + relaxed 0.5 for tall units. Run on the 12 truth segments + 051 (runtime probe: today 1,730 s worst case; must stay < 20 min; 5.4 M-pt cluster). 25–80 s/segment otherwise.
  • tree_recall_eval.py table per variant (recall / FP / weighted / proxies). Fuse V1 (or best) for 085, 060, 093, 086, 002; veg_perspective.py at the fixed truth spots; Opus judges old vs new blind; before/after gallery page for you.
  • If V1 under-delivers: tile-size sweep 6 / 8 / 10 m; then decide relaxed threshold; watershed-on-CHM tiles only if boundary-cut crowns are visible in renders.
  • Winner on all 104 segments in the background (~1.5–2 h) → corpus proxies + per-segment diff vs signs3_A1B0.
Phase 4 — Review + ship ½ day
  • Opus + Codex review in parallel, Fable gate (correctness, CSV contract, runtime, knob defaults).
  • prod2_A1B0.config.json flips the winning knobs; PR to master via bitbucket-pr; commits AI3D-NNN: one line.
  • seg3d: no change (no new sidecar type). Update the handoff + memory with the revised diagnosis (over-merge, hedge-height) so it is not re-derived.
Phase 5 (conditional: Phase 3 recall < 50 % of proxy) — Retrain on tile-level labels 2 days
  • Phase 3 yields ~hundreds of judged tiles — the labelled elongated-canopy data the current RF never saw. Retrain with dist_to_road_m/on_road_fraction dropped and h_max_robust added; new bundle name, old bundle kept.
  • Blockers to fix first: harvest needs run4; positives filter dist ≤ 20 m; positives read accepted=="True". Evaluate with GroupKFold by segment and on the judged spots; A45 (colourless, has run4) as a transfer check.

Risks

Open questions

Miro's answers (29 Aug) — all resolved, Phase 0 started:
  • Height / Fig. calls: Fig. 3 not tree; Fig. 5/7/8 tree; no height gate. Beyond that: "test and optimize" — the truth set decides, not a rule.
  • Q2 tiling: test both (tiles on/off) — V0 vs V1 in Phase 3.
  • Q3: default — contested-only gallery.
  • Q4: Jira AI3D-373; commits AI3D-373: ….
  • Q5 relaxed threshold: implement behind knob, test, report results.
  • Q6 retrain: alt — yes if Phase 3 recall < 50 % of the 8.75 M proxy (Phase 5 becomes conditional-mandatory).
  • Q7: evaluate on Abschnitt 1 only. Data: D:/a123-data/Abschnitt_1 (/mnt/d/a123-data/Abschnitt_1/branch_000/lane_points = the staged /home/ai/seg3d_stage_260807b/A1B0/lane_points, 131 segments, identical files; branch_001 11 more segments). No A45.

Original questions kept below for the record.

  1. Fig. 3 / Fig. 8 trees or not? resolved 29 Aug Miro: Fig. 3 (060 green wall) not a tree; Fig. 8 (060 sparse 3 m blob) tree; Fig. 5 (085 verge) tree. Judges anchored on Fig. 5/7/8 = tree, Fig. 3 = not. The line is crown structure, not height.
  2. Tiling is a per-unit verdict, not the hedge split (which stays off). OK to proceed?

    default Yes.

    alt No — threshold/relaxed-threshold changes only; recall ceiling then ≈ 0.7–1.4 M pts.

  3. Truth labels: Opus vision judges all ~70 spots, you see only the contested ones?

    default Yes — judges anchored on Fig. 5/7 (+ your Q1 answer); contested-only gallery for you.

    alt You judge the full gallery yourself before Phase 1 starts.

  4. Jira number?

    default Sub-task under AI3D-373; commits prefixed AI3D-373: until you give a number.

  5. Relaxed RF threshold (0.5) for units with h_rob ≥ 3.5 m?

    default Implement behind the knob; enable only if it adds 0 FP on the truth set.

    alt Skip; tiles + gate only.

  6. Retrain the RF (Phase 5) after Phase 3?

    default Not in this task; keep the judged tiles as labels for later.

    alt Yes, if Phase 3 recall < 50 % of the 8.75 M proxy.

  7. Check transfer on a road-corridor dataset (A45, 2 segments) before shipping?

    default Skip — knobs are off by default in code, A1-only config flip.

    alt Run 2 A45 segments if lane_points are on disk.

Appendix — artifacts and commands