Guardrails × number_of_returns: what it buys against greenery
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
- 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).
- Returns post-filter (§4) — small, self-contained, config-gated, measurable today. ~5 k bushes for ~400 rail points at the recommended τ.
- 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. - Terrain leakage in
guardrail_support(§5b) — 18 k points, but only 1.15 % of the mask; lowest priority.
The numbers, once
| Population inside the mask | n | % of mask | nr>1 | nbhd rate p50 (r 0.5 m) | Returns can separate? |
|---|---|---|---|---|---|
| GT 66 guardrail | 1,393,683 | 90.1 % | 2.5 % | 0.022 | — (this is the target) |
| GT 74 low vegetation | 88,783 | 5.7 % | 11.9 % | 0.038 | no |
| ↪ of which hag < 0.3 m (shoulder grass) | 57,182 | 3.7 % | 8.2 % | 0.041 | no |
| GT 71 terrain | 58,477 | 3.8 % | 4.0 % | 0.025 | no |
| GT 75 medium vegetation (bushes) | 6,009 | 0.4 % | 64.5 % | 0.683 | yes |
| GT 5 tree | 0 | 0 % | — | — | 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:
| Split | n in split.json | Staged locally (usable now) |
|---|---|---|
| train | 64 | branch_000/002, 018, 031, 060, 066, 086, 090 (+ branch_001/002, branch_001/003 share ids with branch_000 — check the branch before using) |
| val | 64 | branch_000/003, 022, 023, 085, 087, 093, 094 |
| test | 14 | none 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:
- Implement — one change, behind a default-OFF config knob, with unit tests. Defaults must produce byte-identical outputs; prove it, do not assert it.
- 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.
- 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.
- 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. - 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.
- Publish a short HTML report with images after every pass — what changed,
what improved, what did not, results, next steps — via the
html-pageskill tomiro-plans-battlebox.pages.dev/pages/, and link the page URL in the commit message. - 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
- Run base config vs recommended config on the val segments only — same metrics, same renders, same vision judge. No tuning on them. If a val result is bad, you go back to train segments to fix it; you do not adjust a threshold to make val look better.
- Old-data parity: on a dataset without the
number_of_returnscolumn, outputs must be byte-identical to the pre-change detector. - Test split stays closed.
2d. Tooling to copy, and the workflow shape
| What | Where |
|---|---|
| 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 like | ai3d-382-pass9-report-20260907 |
| What a wrap-up should look like | ai3d-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
| What | File:line | Note |
|---|---|---|
| Second streaming pass that resolves mask rows | guardrails/outputs.py:29 _collect_point_masks() | Returns aligned (record_id, point_index, instance_id), or 6 arrays with collect_height_station=True |
| Rows accumulated | guardrails/outputs.py:181-197 | hit = inst >= 0; the three *_chunks.append(...) calls |
| Arrays concatenated / returned | guardrails/outputs.py:206-231 | insert here the filter runs once per segment on the concatenated arrays, just before return record_id, point_index, instance_id |
| Sidecar write | guardrails/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-931 | if 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 dict | guardrails/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
- 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. 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.- If a point has fewer than
min_neighbors = 5neighbours in the ball, forcerate = 0.0(keep). A sparse neighbourhood is not a population statistic; the own-nrfallback punches holes in sparse rail. - Drop the point if
rate > tau. Defaulttau = 0.20. - Scope:
w_beam-type instances only. Configurable list; default["w_beam"]. All measured bushes are onw_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). - No-op guarantees: knob OFF by default; and even ON, if
number_of_returnsis 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 ≥ 2is 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)
| Key | Default | Meaning |
|---|---|---|
returns_filter_enabled | false | Master knob. OFF = zero behaviour change. |
returns_filter_radius_m | 0.50 | Neighbourhood ball radius, (x, y, hag). |
returns_filter_tau | 0.20 | Drop above this multi-return rate. |
returns_filter_min_neighbors | 5 | Below 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.
| kind | r m | tau | drop 74 | drop 75 | good veg | bad 66 | veg/rail | P | R | F1 |
|---|---|---|---|---|---|---|---|---|---|---|
| point | 0.5 | 0.15 | 3,726 | 5,380 | 9,106 | 923 | 9.9 | 0.7472 | 0.6368 | 0.6876 |
| point | 0.5 | 0.20 | 2,749 | 4,975 | 7,724 | 386 | 20.0 | 0.7415 | 0.6372 | 0.6854 |
| point | 0.5 | 0.30 | 2,334 | 4,748 | 7,082 | 278 | 25.5 | 0.7389 | 0.6372 | 0.6843 |
| point | 0.5 | 0.40 | 2,242 | 4,607 | 6,849 | 259 | 26.4 | 0.7383 | 0.6373 | 0.6841 |
| point | 0.75 | 0.15 | 2,340 | 5,218 | 7,558 | 1,344 | 5.6 | 0.7402 | 0.6366 | 0.6845 |
| point | 0.75 | 0.20 | 1,969 | 4,827 | 6,796 | 270 | 25.2 | 0.7383 | 0.6372 | 0.6841 |
| point | 0.75 | 0.30 | 1,821 | 4,604 | 6,425 | 266 | 24.2 | 0.7376 | 0.6372 | 0.6837 |
| point | 0.75 | 0.40 | 1,758 | 4,592 | 6,350 | 262 | 24.2 | 0.7374 | 0.6372 | 0.6837 |
| cell 1 m | — | 0.15 | 2,823 | 4,976 | 7,799 | 2,289 | 3.4 | 0.7410 | 0.6361 | 0.6846 |
| cell 1 m | — | 0.20 | 2,271 | 4,925 | 7,196 | 543 | 13.3 | 0.7394 | 0.6370 | 0.6844 |
| cell 1 m | — | 0.30 | 2,246 | 4,925 | 7,171 | 543 | 13.2 | 0.7393 | 0.6370 | 0.6843 |
| cell 1 m | — | 0.40 | 1,941 | 4,543 | 6,484 | 310 | 20.9 | 0.7375 | 0.6371 | 0.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:
- Are they outside the detection corridor? Check
offset_magainstcorridor_offset_min_m1.5 /corridor_offset_max_m10.0 and the median-zone keys, and againstcorridor_exclusionsinguardrails.json. - Are they outside the height band (
min_height_m0.20 /max_height_m1.30) — e.g. rail on a fill slope where the SMRF ground is wrong? - Did their occupancy cells fail candidate selection (
min_rail_fraction0.40,max_cell_height_spread_m0.50,max_tall_fraction0.12) — i.e. vegetation adjacency killing the cell rather than the point? If so, the returns cue could serve a second, better purpose: cleaning the cell statistics before selection rather than cleaning the mask after it. - Or are they in instances that failed acceptance (
min_length_m12.0,min_longitudinal_coverage0.35) or were rejected by the zone/precision/edge gates (precision_gate.py,edge_gate.py,lane_xml.py— 085 was run withzone_interior_rejected_*active)? - Are they on records the segment did not consume? Compare the GT extent against
record_namesinpoint_masks.npz.
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
| What | Path |
|---|---|
| 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
- must Knob OFF →
point_masks.npzand every other output byte-identical to the pre-change run on 085 (and on one segment with walls/top-rail, e.g. an A4/5 median segment, to exercise branch A ofdetect.py:865). - must Knob ON, dataset without
number_of_returns→ byte-identical masks,returns_available: falsein the summary, one log line. - must Knob ON at τ = 0.20 on 085 with A1NR → GT-66 lost ≤ 1,000 points at ≥ 4,500 GT-75 (bush) points removed. Expected: 386 rail / 4,975 bushes / 2,749 low-veg.
- must P and F1 both up: P ≥ 0.740 (from 0.7293), F1 ≥ 0.685 (from 0.6803). Recall must not fall below 0.6370.
- must No drops on any instance type outside
returns_filter_instance_types; verifyguardrail_supportdrop count == 0 at the default. - should Runtime delta < 30 s/segment (one cKDTree over 1.5 M
3-D points, two
query_ball_pointcalls withworkers=-1). Peak RSS unchanged within 0.2 GB; the 10 GBmemory_budget_gbwarning must not newly fire. - should Before/after renders in the
railret/renders/style (RGB | number_of_returns | mask before | mask after, side + top, r = 8 m) for the three largest removed regions, and a fourth crop centred on the worst rail-loss region. Publish as an HTML image report per house rule. - must Unit tests beside
tests/test_point_masks.py: rate maths on a synthetic hedge-on-rail fixture; sparse-neighbourhood keep; absent-column no-op; type scoping; stats keys present and append-only.
7. Risks, and the house rules
Risks
- real The hedge that IS the barrier. Look at
railret/renders/removed_01_2281.8_1218.8.png: the dropped band is a continuous hedge running along the rail, and 258 of the 7,186 dropped points there are GT-66. Inremoved_03_2290.4_1224.8.pngthe filter nibbles red speckles directly off the cyan rail line (434 GT-66 in a 1,912-point drop). If a hedge is the visual barrier next to a rail, this filter deletes it from the guardrail mask — correct for a guardrail deliverable, wrong if anyone downstream reads the mask as “the barrier”. It also removes mask points along real rail where a hedge grows through. This is why τ = 0.20 and not 0.15, and why the acceptance gate is a hard GT-66 budget. - real Sparse rail sections. A thinly-sampled rail
run has small neighbourhoods; without the
min_neighbors = 5keep-fallback the rate degenerates to a per-pointnr ≥ 2test and punches holes in the beam (2.5 % of GT-66 is genuinely multi-return, mostly rim double-returns). Never substitute the point's own indicator when the ball is sparse. An untrusted rate may only KEEP. - benign Interaction with verticalsigns.
wt-vs-382'sguardrail_mask.pyunpaints tree points that fall insidew_beam/guardrail_support/guardrail_top_railmask rows. Points you drop from the mask simply stop being unpainted, i.e. they fall back to whatever verticalsigns painted — and since the dropped points are bushes at 0.3–1.2 m with a 64 % multi-return rate, tree paint is exactly right for them. No change is needed on the verticalsigns side, and none should be made. The only hard requirement is that thepoint_masks.npzformat stays stable: same keys, same dtypes,dedupe=False,instance_json_index=None, theinstance_typestring table not truncated (there is a documented<U16truncation bug forguardrail_supportin seg3d'sclasses.py:218— do not reintroduce it). - watch Single-segment evidence. Everything here is 085 only. Before defaulting the knob ON, replay at least the other A1B0 segments with masks (002, 003, 018, 022, 023, 031, 060, 066) and report drop counts by type even without GT.
House rules
- Work in a git worktree off
/home/ai/dev/3dai.iolabs.pointcloud.guardrails(e.g.git worktree add /home/ai/dev/wt-gr-returns feat/<TICKET>-returns-filter). Do not commit onmain. Do not touch/home/ai/dev/3dai.iolabs.pointcloud.guardrails-seg3d. - Commit often, one-line messages, prefixed with the guardrails ticket:
<TICKET> Neighbourhood multi-return post-filter on w_beam mask points. The repo's own history isAI3D-379 <subject>/AI3D-369: <subject>; match the current style. The ticket ID is a placeholder — ask Miro for it before the first commit. - No co-author trailers. No push. Leave the branch local; Miro reviews and pushes.
- Everything under
/home/ai/veg373_work/and/home/ai/dev/wt-vs-382/is read-only reference for this task. - Runtime budget: < 20 min/segment end to end. Stream per-file, float32, no full-cloud concat — the 32 GB RAM budget still applies.
- Publish a short HTML image report after the pass (changed / improved / not improved / results / next), per Miro's standing rule.
8. Evidence index
| What | Path |
|---|---|
| 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_returns | iolabs/common/segment_points_io.py in the repo venv (iolabs-common>=0.9.0, already pinned) |
| Sidecar writer SSOT | iolabs/common/point_masks_io.py |