Tree recall — plan
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_robuststays 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_isotropycarries 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)
| outcome | clusters | points | % pts | rf_p p50 | note |
|---|---|---|---|---|---|
reject_rf_below_threshold | 1,147 | 24.87 M | 61.6 | 0.17 | the loss |
vegetation_rf (accepted) | 214 | 13.97 M | 34.6 | 0.83 | 76 % in 3 forest segments (051/015/016) |
reject_crown_h (h_max < 2) | 96 | 1.50 M | 3.7 | — | all in [1.82, 2.00) m — hedge height, leave |
conic_rule + conifer_rule | 18 | 0.04 M | 0.09 | — | 0/1147 rejects satisfy conic, 1/1147 conifer |
reject_min_points | 241 | 0.007 M | 0.02 | — | dust |
reject_dist_to_road, reject_bridge_guard | 0 | 0 | 0 | — | inert 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_major | 0–3 m | 3–6 | 6–10 | 10–20 | 20–40 | ≥ 40 |
|---|---|---|---|---|---|---|
| clusters reaching RF | 613 | 349 | 183 | 132 | 58 | 44 |
| crown_isotropy p50 | 0.43 | 0.50 | 0.55 | 0.51 | 0.41 | 0.30 |
| accept rate | 0.07 | 0.24 | 0.28 | 0.29 | 0.22 | 0.11 |
| points | 0.5 M | 1.1 M | 1.6 M | 2.9 M | 10.9 M | 21.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.
crown_isotropy: accepted vs tall rejects (set A) vs short accepted (set B). The RF's top feature separates by shape, not by tree-ness.Robust height as an analysis proxy (not a rule)
| gate on rf-rejects (h_max ≥ 3, n ≥ 60; 431 cl / 19.43 M pts) | clusters | points |
|---|---|---|
| h_max_robust ≥ 2.5 | 394 | 15.51 M |
| h_max_robust ≥ 3.0 | 333 | 8.75 M |
| h_max_robust ≥ 3.5 | 230 | 4.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)
Other facts that bind the design
- Per-cluster candidate indices survive to the reject ladder (
trees.py:183);compute_cluster_featuresis a pure function of five candidate slices + scalars already in scope;assign_context(ring_fill_ratio, forest_neighbor_count — both RF inputs) must be re-run after any re-clustering. - RF colour-blind:
greennesshas 0 splits in 400 trees (it was constant 0.000 in all training rows);greenness_exgnot in the vector and shows no cluster-level separation (accepted p50 0.16 vs rejects 0.14). No colour rule in this plan. - 13.9 % of RF importance (
dist_to_road_m,on_road_fraction) is frozen on A1 at values outside the training support of both classes; the inf→1e6 clamp happens to be recall-favourable (210 passes vs 14 at d = 0). Nothing tuned on A1 transfers to a road-corridor dataset untested. - Retraining is possible (qc_verdicts.json + out_final/out_trees exist; 263/297 training rows reproduced) but: 26 positives, harvest needs run4 (A1 has none), positives need dist ≤ 20 m (A1 = inf), positives read
accepted=="True"which prod tree CSVs never write. Not a quick move. - Tests:
tests/test_trees.py30 tests / 2 s, suite 608 / 10 s; no test loads the real bundle, none has > 1 cluster per segment, none an inf corridor.
Key decisions
- 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).
- No height gate; RF-only verdicts (Miro, 29 Aug).
h_max_robust(p98;h_maxis 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. - 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×.
- Phase 0 is a gate. If the judged truth set shows the big rejected clusters are mostly not trees, stop and report.
- Everything behind knobs, default off in code;
prod2_A1B0.config.jsonflips them after eval. CSV changes append-only. seg3d unchanged. - 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.pyat 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 fromtree_clusters.csv: accepted pts, rf-reject pts, both split by h_rob band (info only). Baseline fromsigns3_A1B0.- Tiny fix: write real
accepted/typeintree_clusters.csv(detect.py:1289). Separate file from the sign-pathclusters.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_enabledFalse,tile_size_m8.0,tile_trigger_len_m12.0 (tile only iflen_major> trigger),tile_min_points60 (under-filled tiles merge into the nearest neighbouring tile of the same parent). - Mechanics: PCA frame of the cluster's XY → regular
tile_sizegrid → per-tile index arrays (partition of the parent's candidate indices) → replace the parent inclusters/featuresby its tiles →compute_cluster_featuresper tile → re-runassign_contextover 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_ixafteredge_filtered_from(CSV_FIELDSappend-only; RF reads by name). One row per tile; parent row kept with outcometiled. - 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-tileh_max≤ parent;tile_enabled=False→ byte-identical output (golden); real-bundle smoke test (skips ifmodels/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_robustis already per row (features.py:322); tiles get it automatically. Addparent_h_max_robustso a tile row shows its parent's value too (append-only). tree_tall_relaxed_confidence(default −1 = off; eval 0.5) withtree_tall_relaxed_min_robust_h_m3.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_featharness; 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.pytable per variant (recall / FP / weighted / proxies). Fuse V1 (or best) for 085, 060, 093, 086, 002;veg_perspective.pyat 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.jsonflips the winning knobs; PR to master viabitbucket-pr; commitsAI3D-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_fractiondropped andh_max_robustadded; 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
- evidence Only one human-verified wrong reject exists (085 c5). Whether the 333 tall-robust rejects are tree lines is unverified → Phase 0 gate. Ground truth on 2 of 104 segments today.
- judge calibration The agents' yardstick (≥3 m robust) disagrees with Miro on Fig. 5. Every judged label in Phase 0/3 must be anchored on Miro's examples (Fig. 5/7/8 tree, Fig. 3 not) — the line is crown structure, not height, which vision judges get wrong without anchors.
- RF on tiles The 305/333 counterfactual is an upper bound. Real tiles may still read "plate-like" or sparse (thin one-sided scans); an 8 × 2 × 2.2 m hedge-wall tile will likely stay rejected (isotropy 0.25) even if Miro calls it a tree — then only a threshold move or Phase 5 reaches it. Fallbacks: tile size, relaxed threshold, Phase 5.
- tile boundaries Crowns cut by a tile edge give half-crowns; mitigated by 8 m tiles + min-point merge; watershed variant if renders show it.
- runtime / RAM Tiles partition points so feature cost stays ~O(n log n), but
assign_contextis O(k²) over more clusters and segment 051 already takes 1,730 s. Measure early; 32 GB cap. - semantics drift
forest_neighbor_countcounts tiles as neighbours (training positives p50 9.5 vs prod 1 — favourable direction, but a behaviour change for every cluster within 8 m of a tiled one).cluster_idno longer unique per physical object —parent_cluster_idkeeps traceability; diff tooling keyed on cluster_id breaks. - hedge-split lookalike Per-tile verdicts are not the hedge split (nothing is relabelled by shape or height; rejects simply stay unclaimed) — but it is close enough that you should confirm (Q2).
- transfer Everything is tuned on A1 with two RF features frozen; A45/A1B1 with a road corridor untested. Knobs default off; Phase 5 A45 check optional.
- minor, noted only 10 m candidate ceiling decapitates tall crowns (13 clusters pinned); lateral paint reach only 0.65 m beyond the outermost tall column; far-field sparse canopies (Fig. 4, upper left) never seed;
reject_crown_h1.5 M pts sit at 1.82–2.00 m, within DEM error of the 2 m floor — none addressed here.
Open questions
- 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_00111 more segments). No A45.
Original questions kept below for the record.
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.- 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.
- 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.
- Jira number?
default Sub-task under AI3D-373; commits prefixed
AI3D-373:until you give a number. - 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.
- 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.
- 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
- Evidence pack:
/home/ai/veg373_work/recall/—report_1..5.md(stats, frag, visual, code trace, critique),stats/clusters_rescored.csv,code-trace/tree_clusters_scored.csv(all 1,716 rows + rf_p),critic/setA_with_robust.csv,critic/counterfactual_out.txt,critic/covariate_shift.csv,frag/*.png,visual/renders/(42 renders). - Handoff:
/home/ai/veg373_work/handoff-tree-recall.md. Code worktree:/home/ai/dev/wt-vs-373(branch off it; copymodels/). - Rescoring:
cd /home/ai/dev/wt-vs-373 && uv run python /home/ai/veg373_work/recall/stats/rescore.py. - Detector run / fuse / render commands: as in the handoff ("Data and how to run"). Geoshift CSV→LAS:
run3_geoshift.json(+694541.44, +5386372.37), translation only. - Prior pages: vegetation-split plan, gallery.