AI3D-371 · ReCap-annotated segment → training data

2026-08-26 · plan for Miro · repo 3dai.iolabs.pointcloud.mlsegmentation · branch t3code/0a2985e1

Summary

Build scripts/import_recap_export.py: it joins the 12 per-class ReCap E57 exports of branch_000/segment_085 back onto the LAS that was handed to ReCap (segment_085_seg3d.las, PDRF 7, uint16 RGB + intensity, EPSG:25832) by the repo-standard 1 mm hash (+ ≤1 cm nearest-neighbour fallback), maps ReCap's mixed ASPRS/seg3d/custom class codes to seg3d LAS codes through a new fail-closed, versioned contract YAML, and writes the drop-in artifacts the existing pipeline already consumes: segment_085_seg3d_recap.npz (points + intensity + RGB + scan_angle + classification), _report.json, _stats.json, plus a review LAZ. Then it wires the segment into a dataset lane + config so ingest_preannotations → prepare_dataset --emit canonical pointcept runs end to end with features [xyz, intensity, rgb] — i.e. "ready for training".

Why not the upstream seg3d-recap-import: it needs a _seg3d.npz (only the LAS exists on N:), it reads E57 raw without applying the ReCap pose translation (→ 100 % unmatched), and its filename resolver cannot parse 2 Terén.e57 / 11 Asphalt.e57 (no class prefix, Czech names, ASPRS codes). Harness-side implementation now; upstream fix ticket later.

Method: strict red/green/refactor per unit, driven by a dynamic Workflow; Grok 4.6 does the cycles, Opus 5 only on the join unit, Fable only plans (this) and reviews at the end.

Findings (what is actually on N:)

N: is Google Drive (stream-only), not visible from WSL — data must be pulled with PowerShell first.

Export folder reCap export_classfied_v2

FileRecordsReCap meaningProposed seg3d code → train id
2 Terén.e57772,100ASPRS 2 Ground (cs)71 ground → 6 terrain
3 Nízká vegetace.e57143,597ASPRS 3 Low vegetation71 ground → 6 terrain Q1
4 Střední vegetace.e57178,198ASPRS 4 Medium vegetation5 tree → 1 vegetation_trees Q1
5 Tree.e57206,969ASPRS 5 High veg (renamed)5 tree → 1 vegetation_trees
10 Rezervováno.e57508ASPRS 10 Reserved1 unclassified → 9 void Q2
11 Asphalt.e57392,827ASPRS 11 Road surface2 asphalt → 8 other_background
64 solid_line.e5741,353seg3d user code64 → 8 other_background
65 dashed_line.e575,382seg3d user code65 → 8 other_background
66 guardrail.e57114,365seg3d user code66 → 0 guardrail
70 delineator.e571,055seg3d user code70 → 3 pole
100 bordel.e5735custom "junk"1 unclassified → 9 void Q2
101 car noise.e5716,486custom (was 72 in v1)1 unclassified → 9 void Q2

Key decisions

  1. Training geometry, RGB and intensity = the source LAS, never the E57. The E57 contributes only XYZ (for the join) and the class (from the filename).
  2. Harness-side importer (src/dataset/recap_export.py + src/dataset/e57.py + CLI) instead of the Nexus seg3d-recap-import, for the three blockers in the summary. Reuse upstream primitives verbatim: io_npz.surface_keys, iolabs.common.point_hash.match_keys, classes.legend(). No second rounding scheme (spec §8.5).
  3. Two-stage class mapping. New contract configs/contracts/recap_export_map_v1.yaml maps ReCap export code → seg3d LAS code; the frozen ontology_v1.yaml then maps seg3d code → train id as today. Fail-closed: unknown keys error, every export file must resolve to one entry, an export code absent from the map is an error (no silent void). load_preannotation's upstream-class validation and map_las_labels stay untouched.
  4. Outputs are drop-ins for the existing lanes so nothing downstream changes: labels/segment_085/segment_085_seg3d_recap.npz (keys points, intensity, red, green, blue, scan_angle, classification, instance_id, class_codes, class_names, pipeline frame = world frame), segment_085_seg3d_recap_report.json (carries unmatched_rate so select_preannotation's QC gate applies), segment_085_stats.json (las_geoshift: null, instances: [], provenance block), and segment_085_recap_classified.laz for review in CloudCompare/ReCap.
  5. Join rule = upstream's: exact 1 mm hash first, cKDTree ≤ --tolerance (default 0.01 m) for misses; LAS rows hit by two export files with different classes → error listing the files (no last-wins); LAS rows hit by no file → seg3d 1 unclassified (→ void). Guards: --max-unmatched on exported points (default 0.02) and on unlabeled LAS rows.
  6. Instance ids: written as 0 (ReCap drops them; the las_split instances are pre-annotation and would be stale after relabelling). Recorded as a limitation in the report.
  7. pye57 becomes a core dependency (small wheel, no CUDA); tests write a tiny E57 with pye57 into tmp_path — no downloads, CPU suite stays Torch-free. Q4
  8. Dataset lane data/00_external/a1_recap_v1/ (tier-0 inbox raw_drop/normalize_sources.py converts LAS→LAZ, dvc add). Corridor id a1_b000_segment_085, tier train, in a new configs/contracts/corridor_splits_a1_v1.yaml; config configs/dev/a1_recap_segment_085.yaml with features.names: [xyz, intensity, rgb]. Q3
  9. Model routing (per your policy): Grok 4.6 medium for red/green/refactor cycles; Opus 5 high for the E57+join unit and if Grok stalls; Opus 5 + Sol parallel review, then one Fable review pass; no Fable inside the workflow.

Phases

Phase 0 — Pull the data off Google Drive small

One-off, PowerShell (N: is stream-only and invisible to WSL). Land it in the tier-0 inbox, mirroring the lane layout normalize_sources.py expects:

$src = 'N:\02_Modelling\05_Rink\02_AI 3D modeling\03_Working folder\260803_recap_annotation_Abschnitt_1\pre_annotation\260812_full_sweep'
$dst = '\\wsl.localhost\Ubuntu\home\miro\.t3\worktrees\3dai.iolabs.pointcloud.mlsegmentation\t3code-0a2985e1\data\00_external\a1_recap_v1\raw_drop'
robocopy "$src\branch_000\segment_085\reCap export_classfied_v2" "$dst\labels\segment_085\recap_export_v2" *.e57 /R:3 /W:5
robocopy "$src\branch_000\segment_085" "$dst\geometry\segment_085" segment_085_seg3d.las /R:3 /W:5
robocopy "$src" "$dst\provenance" README.md las_validation.json /R:3 /W:5
  • Script it as scripts/dev/fetch_recap_segment.ps1 -Branch 000 -Segment 085 -ExportName "reCap export_classfied_v2" so segments 083/014/050/087 follow with one line. Verify sizes + a SHA-256 list into provenance/fetch_manifest.json.
  • raw_drop/ is never DVC-tracked; no commit of data.
  • Also drop desktop.ini; the importer must ignore non-point files anyway.
Phase 1 — Class-map contract small

configs/contracts/recap_export_map_v1.yaml + src/contracts/recap_export_map.py (load_recap_export_map(path) → RecapExportMap, frozen dataclass, sha256).

schema_version: 1
name: recap_export_map_v1
filename_pattern: '^(?P<code>\d{1,3})\s+(?P<name>.+)$'   # "66 guardrail"
entries:
  2:   {recap_name: Terén,            seg3d_code: 71, note: ASPRS ground}
  3:   {recap_name: Nízká vegetace,   seg3d_code: 71}
  4:   {recap_name: Střední vegetace, seg3d_code: 5}
  5:   {recap_name: Tree,             seg3d_code: 5}
  10:  {recap_name: Rezervováno,      seg3d_code: 1}
  11:  {recap_name: Asphalt,          seg3d_code: 2}
  64:  {recap_name: solid_line,       seg3d_code: 64}
  65:  {recap_name: dashed_line,      seg3d_code: 65}
  66:  {recap_name: guardrail,        seg3d_code: 66}
  70:  {recap_name: delineator,       seg3d_code: 70}
  100: {recap_name: bordel,           seg3d_code: 1}
  101: {recap_name: car noise,        seg3d_code: 1}

Red: tests/test_recap_export_map.py — loads the shipped file; every seg3d_codeBY_CODE; unknown top-level/entry key → RecapExportMapError; duplicate code → error; resolve("66 guardrail.e57") → 66; resolve("desktop.ini") → None-and-ignored vs resolve("foo.e57") → error naming the file; name mismatch vs recap_name → error (guards a re-export where the annotator renumbered, e.g. v1's 72 Car noise).

Green: minimal loader. Refactor: share the strict-YAML helpers already used by ontology.py.

Phase 2 — E57 reader medium

src/dataset/e57.py: read_e57_world_xyz(path) → NDArray[float64] (N,3). Applies each scan's pose (rotation quaternion + translation) via pye57.E57.read_scan(i, transform=True, ignore_missing_fields=True), concatenates scans, checks the returned count against the header recordCount, raises E57ReadError naming the file if pye57 is missing or the file has no cartesian fields.

Red: tests/test_e57.py writes a 5-point E57 with pye57 (write_scan_raw with an explicit translation) → reader returns translated float64 coordinates within 1e-4 m; empty scan list → error; missing file → error.

Green/Refactor: implement; keep it a single-purpose module (~60 lines). Add pye57>=0.4.19,<0.5 to [project.dependencies], uv lock.

Phase 3 — Join + report medium · Opus 5

src/dataset/recap_export.py:

  • load_las_segment(path) → LasSegment — reuses iter_las_chunks (coord f64, intensity u16, color u16, scan_angle i8, las_classification u8) into preallocated arrays; records point count + sha256.
  • join_exports(segment_coord, exports: Sequence[ResolvedExport], *, tolerance_m, max_unmatched_fraction) → RecapJoinResult — per file: surface_keys + match_keys, then cKDTree on the LAS coords for misses within tolerance_m; assigns seg3d_code; tracks n_exact / n_nearest / n_unmatched; conflicts (same LAS row, different code) → RecapExportError with file pair + count; rows with no hit → code 1; guards on exported-unmatched fraction and on unlabeled LAS fraction.
  • RecapJoinResult: classification u8, matched bool, files: tuple[FileReport], changes {old→new: n} against the LAS pre-annotation, counts_before/after, unmatched_rate — same field names as upstream ImportReport so the existing report gate reads it unchanged.

Red: tests/test_recap_export.py with a synthetic 200-point LAS (PDRF 7, RGB, intensity, classes) written by laspy into tmp_path, exports as in-memory arrays (no E57 dependency here): exact hits; a 0.3 mm-perturbed export point resolves through NN; a 2 cm-perturbed one stays unmatched; conflict → error; unmatched-fraction gate trips at 0.02; unlabeled-LAS gate; deterministic output across two runs.

Green/Refactor: vectorise; no Python loops over points; keep memory ≤ 3× the LAS arrays.

Phase 4 — Writers + CLI small
  • write_recap_artifacts(out_dir, segment_id, segment, join, *, class_map, provenance) → dict[str, Path]: NPZ (compressed; keys as decided), _report.json, _stats.json (las_geoshift: null, instances: [], source: {las_path, las_sha256, export_dir, export_sha256s, class_map_sha256, tool_version, git_commit}), review LAZ (LAS copy with the new classification, RGB/intensity untouched). Atomic writes; refuse to overwrite unless --force.
  • scripts/import_recap_export.py --las … --exports … --class-map configs/contracts/recap_export_map_v1.yaml --out-dir … --segment-id 085 [--tolerance 0.01] [--max-unmatched 0.02] [--force]; exit 2 on any domain error, prints the manifest path; Google-style docstrings; fully typed.

Red: CLI test on the synthetic LAS + a pye57-written E57 export dir: artifacts exist, NPZ round-trips through select_preannotation + load_preannotation + labels_for_training_geometry (i.e. the existing ingest path accepts it, incl. the report gate), intensity/RGB equal the LAS values row-for-row, class_codes/class_names equal legend(), second run without --force refuses.

Phase 5 — Run on segment_085 + QC small
uv run python scripts/import_recap_export.py \
  --las data/00_external/a1_recap_v1/raw_drop/geometry/segment_085/segment_085_seg3d.las \
  --exports data/00_external/a1_recap_v1/raw_drop/labels/segment_085/recap_export_v2 \
  --class-map configs/contracts/recap_export_map_v1.yaml \
  --out-dir data/00_external/a1_recap_v1/raw_drop/labels/segment_085 --segment-id 085

Acceptance: unmatched exported ≤ 0.5 % (expect ≈ 0 exact misses + a few % NN), unlabeled LAS rows ≈ 67, no conflicts, per-class counts equal the E57 record counts above. Report the old→new correction table (this is the first quantitative view of how much the annotator changed vs the seg3d pre-annotation — worth a line in the ticket). Eyeball the review LAZ in CloudCompare if any class count looks off.

Phase 6 — Make it "ready for training" medium
  1. uv run python scripts/normalize_sources.py --dataset a1_recap_v1 → LAS→LAZ into geometry/, labels copied, dvc add both lanes (no push).
  2. configs/contracts/corridor_splits_a1_v1.yaml: corridor a1_b000_segment_085, tier train, EPSG 25832, geoshift_m: null, scanner/campaign from the sweep README, placeholder: false, class counts from the report.
  3. configs/dev/a1_recap_segment_085.yaml: copy of the A1 Pointcept smoke config with data.root/canonical_root/processed_root on a1_recap_v1, globs labels/segment_*/…, source_geometry_glob: "geometry/*/segment_*_seg3d.laz", features.names: [xyz, intensity, rgb], normalization_manifest: …/normalization_xyz_intensity_rgb.json. Config test asserts it loads.
  4. Smoke: ingest_preannotations.py --config … --split trainprepare_dataset.py --config … --split train --emit canonical --emit pointcept. Check the canonical manifest: label mode exact_1mm_join, unmatched fraction ≈ 67/1.87 M, feature columns x,y,z,intensity,red,green,blue, tile count > 0.
  5. Add the two DVC stages for the new dataset (or parametrise the existing ones) only if step 4 passes; keep cache: false on emissions.
  6. Docs: docs/data/recap_import.md (workflow, class map rationale, fetch script, limitations: no instances, one segment, no validation tier yet); README cross-link; register the new contract in the spec's config table.

Out of scope here: actually launching a training run (needs a validation corridor — segments 083/014/050/087 are annotated on N: too and become the follow-up).

Phase 7 — Review + commit small
  • uv run pytest, uv run ruff check ., git diff --check green; CPU suite still Torch-free.
  • Opus 5 + Sol review in parallel (correctness of join/precision, fail-closed config parsing, style guide), then one Fable pass on code quality; fix, re-run.
  • Commits under AI3D-371: one per phase (contract, e57, join, cli, dataset wiring, docs) on t3code/0a2985e1. No push, no data commits.
  • File the upstream follow-up: recap_import._read_e57 ignores pose; class_from_filename rejects ReCap's own naming.

Execution as a dynamic workflow (red/green/refactor)

One Workflow run per phase 1–4, each a pipeline() over the phase's units:

  1. red — agent writes only the failing test(s) from the spec above; must show pytest failing for the right reason (Grok 4.6, medium).
  2. green — agent implements the minimum to pass; forbidden from editing tests (Grok 4.6 medium; Opus 5 high for Phase 3, or on a stall).
  3. refactor — agent cleans up under green tests + ruff, style guide, docstrings (Grok 4.6 low/medium).
  4. verify — independent agent re-runs the suite and refutes "done" claims (Opus 5, low effort).

Fable does not appear inside the workflow; it orchestrates between phases (reads results, adjusts prompts) and does the final review. Phases 0, 5, 6 are run inline (they are commands, not code generation).

Risks

Open questions

  1. Vegetation mapping for ReCap 3 (low) and 4 (medium)?

    default 3 → 71 ground (terrain, grass verge), 4 → 5 tree (vegetation_trees, bushes).

    alt Both → 5 tree, i.e. all vegetation is one class.

    alt Both → 2 asphalt (other_background) to keep terrain/vegetation strictly the ASPRS "2" and "5" files.

  2. Where do 101 car noise, 100 bordel, 10 Rezervováno go?

    default All → 1 unclassified → void (excluded from the loss; annotator marked them as garbage).

    alt car noise → 2 asphalt (other_background) so the model learns cars as a trained negative; bordel/reserved stay void.

  3. Dataset / corridor naming and split file?

    default Dataset a1_recap_v1, corridor a1_b000_segment_085 (tier train), new corridor_splits_a1_v1.yaml; existing placeholder splits untouched.

    alt Replace the placeholders inside corridor_splits_v1.yaml now.

  4. pye57 as core dependency or optional extra?

    default Core dependency (needed for the CPU preparation path; wheel is small).

    alt Optional extra recap; E57 tests skip when absent.

  5. Should the review LAZ also be written, and where?

    default Yes, segment_085_recap_classified.laz next to the NPZ (also usable as the "annotated" deliverable for the N: annotated/ folder).

    alt Skip; NPZ + report only.

  6. Phase 6 scope — wire the dataset config and run the canonical/Pointcept emission smoke in this ticket?

    default Yes — that is what makes it "ready for training" and proves the artifacts through ingest → prepare.

    alt Stop after Phase 5 (artifacts + report) and wire the dataset when the second segment lands.