AI3D-371 · ReCap-annotated segment → training data
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
| File | Records | ReCap meaning | Proposed seg3d code → train id |
|---|---|---|---|
2 Terén.e57 | 772,100 | ASPRS 2 Ground (cs) | 71 ground → 6 terrain |
3 Nízká vegetace.e57 | 143,597 | ASPRS 3 Low vegetation | 71 ground → 6 terrain Q1 |
4 Střední vegetace.e57 | 178,198 | ASPRS 4 Medium vegetation | 5 tree → 1 vegetation_trees Q1 |
5 Tree.e57 | 206,969 | ASPRS 5 High veg (renamed) | 5 tree → 1 vegetation_trees |
10 Rezervováno.e57 | 508 | ASPRS 10 Reserved | 1 unclassified → 9 void Q2 |
11 Asphalt.e57 | 392,827 | ASPRS 11 Road surface | 2 asphalt → 8 other_background |
64 solid_line.e57 | 41,353 | seg3d user code | 64 → 8 other_background |
65 dashed_line.e57 | 5,382 | seg3d user code | 65 → 8 other_background |
66 guardrail.e57 | 114,365 | seg3d user code | 66 → 0 guardrail |
70 delineator.e57 | 1,055 | seg3d user code | 70 → 3 pole |
100 bordel.e57 | 35 | custom "junk" | 1 unclassified → 9 void Q2 |
101 car noise.e57 | 16,486 | custom (was 72 in v1) | 1 unclassified → 9 void Q2 |
- Σ records = 1,872,875; source LAS has 1,872,942 points → 67 points have no export (deleted/unclassified in ReCap). No decimation happened.
- E57 prototype:
cartesianX/Y/Zfloat32, normals,intensity0–255,colorRed/Green/Blue0–255, no classification/row index. All 12 share onepose.translation= (696847.155, 5387618.0505, 479.0875) = bbox centre of the LAS → raw coordinates are local ±70 m; float32 ulp ≈ 8 µm, so the 1 mm hash mostly survives; boundary-crossers fall to the NN pass. - E57 colour/intensity are 8-bit downscaled → discarded; RGB (uint16) and intensity (uint16) come from the LAS. The LAS
classificationfield carries the seg3d pre-annotation, so the report can list human corrections asold→newcounts. - No
segment_085_seg3d.npz/_stats.jsonon N:; onlysegment_085_seg3d.las(67 MB) +las_split/per-instance LAS (42 files, instance ids 1–41 for lines/guardrails/delineators/tree) + ReCap project. SweepREADME.mdconfirms LAS 1.4 / PDRF 7 / EPSG:25832 / genuine uint16 RGB. reCap export_classfied_v1(older, 10 files, car noise coded 72) is superseded and must not be mixed in — the importer takes exactly one export directory.- Env: WSL
uvvenv has laspy 2.7 +iolabs.common.point_hash;pye57is absent but has manylinux wheels for 3.11/3.12 (0.4.19).
Key decisions
- 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).
- Harness-side importer (
src/dataset/recap_export.py+src/dataset/e57.py+ CLI) instead of the Nexusseg3d-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). - Two-stage class mapping. New contract
configs/contracts/recap_export_map_v1.yamlmaps ReCap export code → seg3d LAS code; the frozenontology_v1.yamlthen 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 andmap_las_labelsstay untouched. - Outputs are drop-ins for the existing lanes so nothing downstream changes:
labels/segment_085/segment_085_seg3d_recap.npz(keyspoints, intensity, red, green, blue, scan_angle, classification, instance_id, class_codes, class_names, pipeline frame = world frame),segment_085_seg3d_recap_report.json(carriesunmatched_ratesoselect_preannotation's QC gate applies),segment_085_stats.json(las_geoshift: null,instances: [], provenance block), andsegment_085_recap_classified.lazfor review in CloudCompare/ReCap. - 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-unmatchedon exported points (default 0.02) and on unlabeled LAS rows. - Instance ids: written as 0 (ReCap drops them; the
las_splitinstances are pre-annotation and would be stale after relabelling). Recorded as a limitation in the report. pye57becomes a core dependency (small wheel, no CUDA); tests write a tiny E57 with pye57 intotmp_path— no downloads, CPU suite stays Torch-free. Q4- Dataset lane
data/00_external/a1_recap_v1/(tier-0 inboxraw_drop/→normalize_sources.pyconverts LAS→LAZ,dvc add). Corridor ida1_b000_segment_085, tiertrain, in a newconfigs/contracts/corridor_splits_a1_v1.yaml; configconfigs/dev/a1_recap_segment_085.yamlwithfeatures.names: [xyz, intensity, rgb]. Q3 - 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 intoprovenance/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_code ∈ BY_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— reusesiter_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 withintolerance_m; assignsseg3d_code; tracksn_exact / n_nearest / n_unmatched; conflicts (same LAS row, different code) →RecapExportErrorwith 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 upstreamImportReportso 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
uv run python scripts/normalize_sources.py --dataset a1_recap_v1→ LAS→LAZ intogeometry/, labels copied,dvc addboth lanes (no push).configs/contracts/corridor_splits_a1_v1.yaml: corridora1_b000_segment_085, tier train, EPSG 25832,geoshift_m: null, scanner/campaign from the sweep README,placeholder: false, class counts from the report.configs/dev/a1_recap_segment_085.yaml: copy of the A1 Pointcept smoke config withdata.root/canonical_root/processed_rootona1_recap_v1, globslabels/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.- Smoke:
ingest_preannotations.py --config … --split train→prepare_dataset.py --config … --split train --emit canonical --emit pointcept. Check the canonical manifest: label modeexact_1mm_join, unmatched fraction ≈ 67/1.87 M, feature columnsx,y,z,intensity,red,green,blue, tile count > 0. - Add the two DVC stages for the new dataset (or parametrise the existing ones) only if step 4 passes; keep
cache: falseon emissions. - 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 --checkgreen; 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) ont3code/0a2985e1. No push, no data commits. - File the upstream follow-up:
recap_import._read_e57ignores pose;class_from_filenamerejects 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:
- red — agent writes only the failing test(s) from the spec above; must show
pytestfailing for the right reason (Grok 4.6, medium). - 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).
- refactor — agent cleans up under green tests +
ruff, style guide, docstrings (Grok 4.6 low/medium). - 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
- high Class semantics. Whether "2 Terén" is ASPRS ground (terrain) vs the seg3d code 2 (asphalt) is inferred from ReCap's localized default names and the separate "11 Asphalt" file; low/medium vegetation, "car noise", "bordel" and "Rezervováno" have no seg3d equivalent. Wrong choices silently poison labels → the map is a versioned contract and the report prints per-class counts for a sanity check. See Q1/Q2.
- med E57 frame. If ReCap applied a rotation or re-centred per file, the exact hash misses everything and the NN pass would guard-fail. Mitigation: pose applied with
transform=True; Phase 5 prints exact/NN split per file; a pose-less file is an error. - med float32 precision. ±70 m local coords → ulp 8 µm; expect ≈1–3 % of points to cross a 1 mm bucket and need the ≤1 cm NN pass (point spacing ≈ 6.7 cm, so unambiguous). Tolerance stays configurable; report shows the split.
- med pye57 wheels on Azure/Linux images. manylinux wheels exist for 3.11/3.12; if the training image lacks them the import is not needed there (artifacts are produced locally), but the core-dependency decision would make
uv syncfail. Fallback = optional extra (Q4). - med Split manifest churn.
corridor_splits_v1is a placeholder; adding a real corridor in a new file avoids touching frozen E1/E2 configs, but the tier assignment (train only) will need rework once more segments arrive. - low Instance ids lost. Instances written as 0; the ontology's code-69 disambiguation path needs none here (no gates/signs in this segment).
- low The 67 orphan points. They become void; if a later export shows thousands, the unlabeled-LAS gate fails closed.
Open questions
- 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.
- 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.
- Dataset / corridor naming and split file?
default Dataset
a1_recap_v1, corridora1_b000_segment_085(tier train), newcorridor_splits_a1_v1.yaml; existing placeholder splits untouched.alt Replace the placeholders inside
corridor_splits_v1.yamlnow. pye57as 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.- Should the review LAZ also be written, and where?
default Yes,
segment_085_recap_classified.laznext to the NPZ (also usable as the "annotated" deliverable for the N:annotated/folder).alt Skip; NPZ + report only.
- 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.