Guardrails × number_of_returns: what it buys against greenery

2026-09-07 · handoff for an agent working in the guardrails repo · measured on segment 085 against human ReCap ground truth

1. Verdict — read this before you plan anything

Returns give a small, clean, real win — and it is not the big win. On segment 085 the guardrail mask is 1.56 M points and 90.1 % pure against GT class 66 (guardrail). A neighbourhood multi-return filter removes ~89 % of the bushes that sit on the rail (5,380 of 6,009 points, i.e. 0.4 % of the mask) for ~0.9 k rail points, or ~4.6 k bushes for ~260 rail points at a safer threshold. Precision 0.729 → 0.747, F1 0.680 → 0.688. Worth doing, cheap, low-risk, and it is the only cue that reaches bushes/hedge standing through the rail band.

Returns do nothing for the item that actually costs you. The largest contaminant is the shoulder-grass strip inside w_beam: 57,182 points below 0.30 m above ground, only 8.2 % multi-return — radiometrically the same hard, opaque surface as the rail itself. No return-based rule can peel it off. Same for terrain leakage into guardrail_support (17,962 points, 27.8 % of that class, 4.0 % multi-return).

And precision is not your worst number: recall is. GT-centric on 085: P 0.729, R 0.638, F1 0.680. 41,453 GT-66 points are missed; 35,177 of them are in neither the guardrail mask nor the verticalsigns tree paint — nobody claims them at all. That is 30.8 % of the guardrail in this segment simply not detected.

Recommended order of work

  1. Recall investigation (§5c) — 35 k unclaimed GT-66 points. Biggest number on the page, and it is free to look at (render only, no code change).
  2. Returns post-filter (§4) — small, self-contained, config-gated, measurable today. ~5 k bushes for ~400 rail points at the recommended τ.
  3. Height-above-ground band gate on w_beam (§5a) — the 57 k grass strip. Potentially 10× the returns win, but unmeasured; must be swept on 085 first.
  4. Terrain leakage in guardrail_support (§5b) — 18 k points, but only 1.15 % of the mask; lowest priority.

The numbers, once

Population inside the maskn% of masknr>1nbhd rate p50 (r 0.5 m)Returns can separate?
GT 66 guardrail1,393,68390.1 %2.5 %0.022— (this is the target)
GT 74 low vegetation88,7835.7 %11.9 %0.038no
  ↪ of which hag < 0.3 m (shoulder grass)57,1823.7 %8.2 %0.041no
GT 71 terrain58,4773.8 %4.0 %0.025no
GT 75 medium vegetation (bushes)6,0090.4 %64.5 %0.683yes
GT 5 tree00 %n/a (zero trees in this mask)

Mask composition by instance type: w_beam 1,496,703 pts (91.2 % GT-66), guardrail_support 64,681 pts (only 45.4 % GT-66; 27.8 % terrain, 23.3 % low veg). No guardrail_top_rail instances on this file. All 6,009 bush points sit on w_beam, not on supports — which is why the filter is scoped to w_beam.

2. Working method (mandatory)

Miro, verbatim: “The guardrails implementation agent should probably work in a similar way to how you are working. By that, I mean the dataset should be split in a similar way between training, validation, and testing datasets. It should work in multiple passes. After each pass, it should look visually what the improvements were and what still needs to improve, and then go to another pass.”

2a. Data split — reuse the existing one, do not invent a new one

Split file: /home/ai/veg373_work/returns/split.json — seed 20260905, 142 segments of the A1 no-scan-angle-filter corpus, 64 train / 64 val / 14 test. Entries are branch_XXX/NNN. Locally staged with number_of_returns under /home/ai/veg373_work/returns/data/A1NR/lane_points/segment_NNN/ are exactly the 14 “truth_v4” segments:

Splitn in split.jsonStaged locally (usable now)
train64branch_000/002, 018, 031, 060, 066, 086, 090  (+ branch_001/002, branch_001/003 share ids with branch_000 — check the branch before using)
val64branch_000/003, 022, 023, 085, 087, 093, 094
test14none staged — keep it that way

The 085 problem, and the rule that was actually used. Segment 085 is the only segment with human ReCap ground truth, and it sits in the val split. That is a leak if you tune on it and then claim val as a clean gate. The rule applied to the analysis in this handoff, which you should keep:

  • 085 GT may be the tuning yardstick for point-level precision/recall (it is the only place those numbers exist at all).
  • The final gate then runs on the OTHER val segments (003, 022, 023, 087, 093, 094) with the vision judge rather than GT numbers — renders, judged correct / wrong / mixed.
  • The test split is never opened. Not once, not “just to look”.

Clean alternative, ask for it early: request one more human-annotated segment from Miro, chosen from the train list (e.g. branch_000/018, 031, 060 or 066 — already staged). With a train-split GT segment the tuning yardstick moves out of val and the whole split becomes honest. Ask before you start pass 1; annotation has lead time.

2b. The pass loop

One pass = one change. Never two. Every pass runs the full loop:

  1. Implement — one change, behind a default-OFF config knob, with unit tests. Defaults must produce byte-identical outputs; prove it, do not assert it.
  2. Run — on up to 3 train segments. Budget: < 20 min/segment, ≤ 32 GB RAM. If a segment blows either, kill it and optimise before continuing.
  3. Metrics — (i) the 085 GT match tables: P/R/F1 vs GT-66, points lost/gained by GT class, broken out per instance type; (ii) per-segment mask point counts on the train segments; (iii) a diff against the previous pass, not just against baseline.
  4. Visual check — this is not optional and not a formality. Render before | after for the largest changed regions and a handful of random small ones, in the 4-panel style: RGB | number_of_returns | mask before | mask after, side view + top-down. Then have a vision-capable model judge every render and label it correct / wrong / mixed. A pass whose renders were not looked at did not happen.
  5. Verdict — keep / retune / revert. If the change did not measurably help, throw the code away ruthlessly. A neutral change that adds a knob is a net loss. Reverting is the expected outcome of some passes, not a failure.
  6. Publish a short HTML report with images after every pass — what changed, what improved, what did not, results, next steps — via the html-page skill to miro-plans-battlebox.pages.dev/pages/, and link the page URL in the commit message.
  7. Pick the next improvement from what the renders show, not from a plan written before you had seen anything. Go again.

Stop conditions (any one of them ends the loop): the masks are right; or no further progress is possible without a human decision; or more/better data is needed. In all three cases, write a handoff rather than grinding on.

2c. Final gate

2d. Tooling to copy, and the workflow shape

WhatWhere
Render style (the 4-panel A/B figure)/home/ai/veg373_work/returns/eval/render_ab6.py
085 GT match (5 cm join, geoshift handled)/home/ai/veg373_work/returns/eval/gt085_match_r9.py
Detection diff between two runs/home/ai/veg373_work/returns/eval/det_diff.py
Per-class evaluation tables/home/ai/veg373_work/returns/analysis/canopy_eval.py
What a pass report should look likeai3d-382-pass9-report-20260907
What a wrap-up should look likeai3d-382-summary-20260907

Workflow shape that produced those reports: a worker agent implements → a cheap runner executes the segments → a vision-capable model judges the renders → a reviewer does an adversarial review → then the report is published. Keep the roles separate; the agent that wrote the change is the worst judge of whether its renders look right.

3. Repo reality check and exact insertion points

Work in /home/ai/dev/3dai.iolabs.pointcloud.guardrails (branch main), not in /home/ai/dev/3dai.iolabs.pointcloud.guardrails-seg3d. The -seg3d clone is the same Bitbucket repo but sits on the stale branch feat/AI3D-344-point-masks (2c654c9), which diverged at 99532c4, is 3,853 lines of guardrails/, and has no posts.py, no guardrail_support / guardrail_top_rail classes, no outputs.py. main (81760b2, v0.4.1) is 12,968 lines and is the pipeline that produced the mask this analysis measured (its run_summary.json carries support_count / post_total_count / top_rail_count / wall_count, all absent from -seg3d). All line numbers below are main @ 81760b2.

Checked and ruled out: /home/ai/dev/wt-seg3d-grb (feat/AI3D-380-guardrail-colour-gate) and /home/ai/dev/wt-seg3d-373 are worktrees of a different repo, 3dai.iolabs.pointcloud.3dsegmentation. They consume guardrail masks/JSON (guardrail_json.py, masks.py, classes.py); no newer guardrail detector lives there.

3a. Where per-instance point indices are finalised

WhatFile:lineNote
Second streaming pass that resolves mask rowsguardrails/outputs.py:29 _collect_point_masks()Returns aligned (record_id, point_index, instance_id), or 6 arrays with collect_height_station=True
Rows accumulatedguardrails/outputs.py:181-197hit = inst >= 0; the three *_chunks.append(...) calls
Arrays concatenated / returnedguardrails/outputs.py:206-231insert here the filter runs once per segment on the concatenated arrays, just before return record_id, point_index, instance_id
Sidecar writeguardrails/outputs.py:236 _write_point_masks()iolabs.common.point_masks_io.write_point_masks (dedupe=False, instance_json_index=None)Do not touch the format. verticalsigns reads it in guardrail_mask.py / rail_halfpost
Call sites (2)guardrails/detect.py:865-931if dump_point_masks: → branch A relabel_components (line 871, _collect_point_masks(..., collect_height_station=True, support_posts=...) then relabel_support_points), branch B plain 3-array call (line 910). Both must be covered — production runs branch A (enable_component_masks: true).
Run summary dictguardrails/detect.py:959 (support_count etc. at 984/988)Append the stats block here, never reorder existing keys

Why after the loop, not inside it. The measured statistic is a KD-tree over all of the segment's mask points at once (XY + height above ground). Doing it per chunk would change the neighbourhoods at chunk boundaries and would not reproduce the sweep. Memory at M = 1.56 M points: XY float32 12 MB + hag float32 6 MB + nr uint8 1.6 MB. Negligible against the 10 GB memory_budget_gb.

3b. Per-point attributes: what it takes to read number_of_returns

The guardrails detector reads no per-point attribute at all today — only points. grep -rn "number_of_returns\|intensity\|red" guardrails/*.py finds nothing outside raster/overlay filenames. Every reader goes through iolabs.common.segment_points_io.iter_points_chunks, imported at guardrails/record_io.py:14, guardrails/candidates.py:17, guardrails/outputs.py:15, guardrails/posts.py:34 — and that function streams only the points.npy member of the npz.

The good news: the schema is already an SSOT and already knows this key. .venv/lib/python3.12/site-packages/iolabs/common/segment_points_io.py declares NUMBER_OF_RETURNS_KEY = "number_of_returns", uint8, optional on read, and fills zeros for records written before AI3D-382 — with an explicit rule in its module docstring: “Zero is not a legal LAS return count … Never default to 1 — that fabricates a plausible-looking measurement.” iolabs-common>=0.9.0 is already pinned in pyproject.toml, so no dependency change is needed.

Minimal plumbing (recommended): add one helper to guardrails/record_io.py (that module already exists precisely for “guardrail-specific record reading additions”):

def load_number_of_returns(path: Path, n_rows: int) -> tuple[np.ndarray, bool]:
    """(uint8 (N,) return counts, available). Zeros + False when the key is absent."""
    with np.load(path) as data:
        if segment_points_io.NUMBER_OF_RETURNS_KEY not in data:
            return np.zeros(n_rows, dtype=np.uint8), False
        nr = np.asarray(data[segment_points_io.NUMBER_OF_RETURNS_KEY], dtype=np.uint8).ravel()
    if len(nr) != n_rows:
        logger.warning("%s: number_of_returns length mismatch; treating as absent", path.name)
        return np.zeros(n_rows, dtype=np.uint8), False
    return nr, True

Read it once per record before the chunk loop in _collect_point_masks and slice by row_offset — 1 byte/point, 6.6 MB for the largest 085 record, and it works identically for the chunked and the whole-record streaming paths. Do not try to teach iter_points_chunks to stream a second member; that is an upstream change for a 6 MB array.

Height above ground is already in hand. _collect_point_masks computes sth.height_m at outputs.py:99, and already collects it per masked row as cand_height (outputs.py:140,144,196) when collect_height_station=True. Set that flag whenever the returns filter is on, so both call-site branches deliver hag; XY comes from selected_points[valid][hit][:, :2], next to the existing z_chunks.append at outputs.py:199-201.

Config: the model is split into slices. Add the fields to guardrails/_model_core.py (DetectorCoreConfig, memory/IO block at the tail) and the same keys to guardrails/guardrails.default.json. Nothing else — unknown keys are rejected, --set KEY=VALUE then works for free (guardrails/config.py:parse_set_overrides).

4. Spec: neighbourhood multi-return post-filter on mask points

Rule

  1. Take the segment's finalised mask rows: XY (metres, frame-local is fine) and height-above-ground hag. Build the 3-D coordinate (x, y, hag) — hag, not z, so a canopy hanging over a post is a different neighbourhood from the post.
  2. rate[i] = |{j : ||p_j - p_i|| ≤ r and nr_j ≥ 2}| / |{j : ||p_j - p_i|| ≤ r}| over mask points only, r = 0.5 m.
  3. If a point has fewer than min_neighbors = 5 neighbours in the ball, force rate = 0.0 (keep). A sparse neighbourhood is not a population statistic; the own-nr fallback punches holes in sparse rail.
  4. Drop the point if rate > tau. Default tau = 0.20.
  5. Scope: w_beam-type instances only. Configurable list; default ["w_beam"]. All measured bushes are on w_beam; supports are contaminated by terrain, which this rule cannot see, and the low-τ sweep costs support rail points for nothing (1,141 low-veg / 57 rail at τ=0.15).
  6. No-op guarantees: knob OFF by default; and even ON, if number_of_returns is absent for a record (zeros, available=False) the filter must not drop a single row from that record. Zeros are “unknown”, never “single return” — nr ≥ 2 is false for 0 anyway, so the arithmetic is safe, but gate explicitly so the no-op is intentional rather than incidental, and log it once per segment.

Reference implementation to copy the maths (and the sparse-fallback reasoning) from — read-only, do not import across repos: /home/ai/dev/wt-vs-382/src/iolabs_point_cloud_detection_verticalsigns/returns_stripper.py, function neighbourhood_multi_return_rate (two cKDTree.query_ball_point(..., return_length=True, workers=-1) calls, one division; no Python-level neighbour lists). Branch feat/AI3D-382-returns-recall, PR #10. The verticalsigns guards (min_remaining_fraction, max_surviving_multi_return_fraction, …) exist because it strips whole DBSCAN clusters that then feed a frozen RF; you do not need them — you are removing rows from a sidecar, not reshaping a feature vector. Keep only min_neighbors.

Config keys (all new, defaults chosen so the run is byte-identical)

KeyDefaultMeaning
returns_filter_enabledfalseMaster knob. OFF = zero behaviour change.
returns_filter_radius_m0.50Neighbourhood ball radius, (x, y, hag).
returns_filter_tau0.20Drop above this multi-return rate.
returns_filter_min_neighbors5Below this the rate is forced to 0 (keep).
returns_filter_instance_types["w_beam"]Instance types the filter may touch. Empty list = all.

Stats (append-only, into the result dict at detect.py:959)

"returns_filter": {
  "enabled": true,
  "returns_available": true,          # false => every count below is 0
  "tau": 0.2, "radius_m": 0.5, "min_neighbors": 5,
  "mask_points_before": 1561384,
  "points_dropped": 7724,
  "points_dropped_by_type": {"w_beam": 7724, "guardrail_support": 0}
}

Never reorder or rename an existing run_summary.json key; append only.

The sweep (segment 085, GT-centric P/R/F1 vs GT-66)

Baseline full mask: P 0.7293 · R 0.6375 · F1 0.6803 · TP 72,912 · FP 27,070 · FN 41,453. “good veg” = GT 74/75/5 removed, “bad 66” = GT-66 removed.

kindr mtaudrop 74drop 75good vegbad 66veg/railPRF1
point0.50.153,7265,3809,1069239.90.74720.63680.6876
point0.50.202,7494,9757,72438620.00.74150.63720.6854
point0.50.302,3344,7487,08227825.50.73890.63720.6843
point0.50.402,2424,6076,84925926.40.73830.63730.6841
point0.750.152,3405,2187,5581,3445.60.74020.63660.6845
point0.750.201,9694,8276,79627025.20.73830.63720.6841
point0.750.301,8214,6046,42526624.20.73760.63720.6837
point0.750.401,7584,5926,35026224.20.73740.63720.6837
cell 1 m0.152,8234,9767,7992,2893.40.74100.63610.6846
cell 1 m0.202,2714,9257,19654313.30.73940.63700.6844
cell 1 m0.302,2464,9257,17154313.20.73930.63700.6843
cell 1 m0.401,9414,5436,48431020.90.73750.63710.6837

Why τ = 0.20 and not the F1-optimal 0.15: 0.15 buys 1,382 extra vegetation points but costs 2.4× the rail (923 vs 386). The veg/rail ratio jumps from 9.9 to 20.0 at 0.20. The F1 difference (0.6876 vs 0.6854) is noise next to a 537-point rail loss you would have to defend. Do not use the per-cell 1 m variant — it is strictly worse at every τ (2,289 rail points lost at 0.15) because a 1 m cell straddling rail and hedge condemns the whole cell. r = 0.75 m is dominated by r = 0.5 m: fewer bushes removed at the same rail cost.

5. Geometric follow-ups — separate, optional, each measured before shipped

5a. Height-above-ground band gate on w_beam unmeasured

The single biggest impurity: 57,182 mask points of GT class 74 sitting below 0.30 m above ground — the shoulder grass strip that runs continuously beside the beam. 3.7 % of the mask, ~10× the entire returns win.

The sweep did NOT test this. Every number in §4 is a returns sweep; no hag threshold was swept, and the cost in GT-66 is unknown. GT-66 in this segment has hag p10 = 0.37 m / p50 = 0.61 m / p90 = 0.87 m, so a 0.30 m floor looks nearly free — but “looks” is exactly what the returns analysis exists to replace. The detector's own min_height_m is 0.20 m and the _collect_point_masks replay deliberately lowers the floor to post_low_band_min_m = 0.10 m so post bases enter the mask (outputs.py:81-89) — a naive 0.30 m gate would undo that and amputate every guardrail_support base. Any gate must be w_beam-scoped and swept.

Do first: sweep hag ∈ {0.20, 0.25, 0.30, 0.35} on 085 with the same GT-centric P/R/F1 harness, per instance type, before writing any production code. Report GT-66 lost vs GT-74 removed exactly as the §4 table does.

5b. Terrain leakage in guardrail_support low priority

guardrail_support is only 45.4 % GT-66: 29,349 rail / 17,962 terrain / 15,059 low vegetation / 2,261 unmatched, out of 64,681. That is a real quality problem for the class, but it is 1.15 % of the whole mask, and the class exists to carry post geometry (see guardrails/posts.py, adopt_post_points / relabel_support_points, and docs/AI3D-360-v-support-separation-design.md), not to be point-pure. Returns are useless here (terrain 4.0 % nr>1). If you touch it, the lever is the post-cylinder claim radius / elevation cap in posts.py, not a point filter — and it will move post XY, which has an owner-acked baseline. Treat as a separate ticket.

5c. Recall investigation — the 35 k missed points start here

Of 114,365 GT-66 points: 72,912 (63.8 %) in the mask, 6,276 (5.5 %) claimed only by verticalsigns tree paint, 35,177 (30.8 %) claimed by nobody. All 114,365 match our cloud within 5 cm, so this is not a registration or coverage artefact — the detector simply does not reach them.

This is a render-and-look task before it is a code task. Produce a segment-085 overlay of the 35,177 unclaimed GT-66 points (top-down + a few 8 m perspective crops, the same style as railret/renders/) and answer, in this order:

Report the bucket split before proposing a fix. Note the r8/r9 tree-paint tables in RAILRET_085.md §1 already rule out “verticalsigns ate them”: the exclusive buckets are identical before and after the mask unpaint, and only 6,276 GT-66 points are tree-painted at all.

6. Verification

Harness

WhatPath
GT match, r9 flavour (5 cm cKDTree join, geoshift handled)/home/ai/veg373_work/returns/eval/gt085_match_r9.py
Original 085 matcher/home/ai/veg373_work/recall/gt/match085.py
The analysis to reproduce (all §1/§3 tables)/home/ai/veg373_work/returns/railret/analyze_085.py
Cached GT (already geoshifted)/home/ai/veg373_work/returns/eval/cache/gt085_local.npz
GT LAZ (human ReCap)/home/ai/veg373_work/recall/gt/segment_085_recap_classified_N.laz
Input with returns/home/ai/veg373_work/returns/data/A1NR/lane_points/segment_085/*_run3_points.npz (keys: points, scan_angle, intensity, red, green, blue, number_of_returns)
Baseline mask to diff against/home/ai/seg3d_out/260905/guardrails_A1B0/segment_085/point_masks.npz
DEM used for hag (0.75 m SMRF, same surface as the matcher)/home/ai/veg373_work/returns/diag/out/dem_085_r6pr.npz

Note: A1NR is the no-scan-angle-filter export; it is the only local dataset that carries number_of_returns. The older A1 staging has no such member — that is your free regression fixture for the “column absent” path.

Acceptance criteria

7. Risks, and the house rules

Risks

House rules

8. Evidence index

WhatPath
Full measurement note (all tables, method, class-code re-derivation)/home/ai/veg373_work/returns/railret/RAILRET_085.md
Machine-readable stats/home/ai/veg373_work/returns/railret/work/stats.json
Analysis script/home/ai/veg373_work/returns/railret/analyze_085.py
Renders (RGB | nr | mask before | mask after)/home/ai/veg373_work/returns/railret/renders/removed_0{1,2,3}_*.png
Reference stripper (read-only)/home/ai/dev/wt-vs-382/src/iolabs_point_cloud_detection_verticalsigns/returns_stripper.py
Returns loading pattern (read-only).../verticalsigns/returns.py, 1 m cell mrf in .../hardblob.py
Mask consumer contract (read-only).../verticalsigns/guardrail_mask.py
Schema SSOT for number_of_returnsiolabs/common/segment_points_io.py in the repo venv (iolabs-common>=0.9.0, already pinned)
Sidecar writer SSOTiolabs/common/point_masks_io.py

Class codes on this GT (re-derived against the ReCap legend, not the provisional reading): 66 guardrail, 74 low vegetation, 75 medium vegetation/bushes, 71 residual terrain, 2 asphalt, 5 tree, 64/65 lines, 70 delineator, 76 car noise. 66 = guardrail under either reading.