Surface Mesh Redesign — edge-bounded pavement mesh
Summary
Repurpose this repo completely: delete the RANSAC / planar-patch road-surface pipeline and replace it with a small, fast builder that produces a triangle mesh of the pavement surface per carriageway — the mesh is the deliverable; no filtered point cloud is emitted anymore. The per-cell control plots survive as a second deliverable: a diagnostic PDF (+ CSV sidecars) with the cell-rejection map, per-cell residual histograms, and per-source-file residual distributions that expose a misaligned input LAS.
Inputs per segment: the asphalt-edge NPZ written by 3dai.iolabs.pointcloud.asphaltedge (two edges per carriageway as world-frame polylines) plus the existing *_run3_points.npz clouds. Every edge polyline point becomes a mesh vertex (fine triangles at the edges); edges are extrapolated to the segment ends. Interior vertices come from a coarse 2-D lattice between the edges (~5 cells across the carriageway, one row every ~3 m), each cell's z from a least-squares plane fit of the points inside it. Cell assignment and plane fits run batched in PyTorch on GPU, generalising the cutting-plane trick of segmentationtrajectory from 1-D to a 2-D lattice. Delaunay on XY, then triangles outside the edge polygon are deleted.
Timing note: the old pipeline spends minutes per segment in RANSAC + per-cell loops; the new one is a handful of batched tensor ops plus one Delaunay call — expected to run in seconds per segment.
Key decisions
| Decision | Choice | Why |
|---|---|---|
| Deliverables | (1) Mesh (PLY per carriageway per segment); (2) diagnostic control PDF + CSV sidecars per segment | Miro: the deliverable is the mesh, not a point cloud (raycasting point filter deleted) — but the per-cell control plots/histograms and a cell-rejection map stay as a second deliverable, so a misaligned source LAS is still catchable. |
| Inputs | Files: segment_NNN_edges.npz (asphaltedge) + *_run3_points.npz | File-based contract chosen over in-process or library-import coupling. Both are in the same run3 geoshifted frame (asphaltedge io.py:99-116 applies no transform), so no re-projection is needed. |
| Carriageways | One mesh per carriageway: outer edge ↔ inner edge; median unmeshed | Divided highway has 4 edges (left, inner_left, inner_right, right). Fallback: if inner edges are absent or all-gap (undivided road), build one mesh between the two outer edges. |
| Edge vertices | All edge polyline points are mesh vertices | Miro: “use all of the edge points for precision” — triangulation is naturally denser near the edges (edge stations every 0.25 m vs interior rows every 3 m). |
| Edge extension | Extrapolate both edges to the segment station range ends | Detected edges stop short of segment ends (hygiene demotes end stubs; grid has 1 m margins). Linear extrapolation from the end of the last measured run, tagged with a distinct flag. |
| Interior z | Per-cell least-squares plane fit, evaluated at lattice vertices | Same math as step-4 plane_stats (z = ax + by + c, torch.linalg.lstsq idiom at plane_stats.py:40), now batched over all cells at once. |
| Cell assignment | 2-D lattice of vertical cutting planes, batched in PyTorch on GPU | Generalisation of segmentationtrajectory's 1-D division (segment_mapper.py:190-249): transverse row planes every ~3 m along the centerline plus column boundaries at fixed fractions between the edges. Implemented as tensor ops (no per-cell loops); device from config with CPU fallback. |
| Triangulation | scipy.spatial.Delaunay on XY + centroid-in-polygon clip | Same idiom as today (pipeline.py:73, QhullError guard). Clip with matplotlib.path.Path.contains_points on triangle centroids — avoids adding shapely. |
| Old code | Delete params, pipeline, planar_patches, lattice, mesh_ops, points_queries; adapt _config, road_surface_finder; keep _log_props, _pdf_io | The old driver only exists on lanefinder's legacy-steps-456-nexus-pinned branch — master no longer calls this repo, so deletion breaks nothing live. |
| Dependencies | Drop open3d, scikit-image, iolabs-geometry-visualization; keep numpy, scipy, torch, matplotlib, logstash/common, iolabs-geometry-geometry | Open3D was only used by the deleted pipeline; PLY output gets a small self-contained binary writer. Torch stays for the GPU lattice + fits. geometry-geometry stays as the home of the shared axis/cutting-plane code (see SSOT). |
| Shared code | Cross-repo duplicates move to iolabs-common / iolabs-geometry-geometry as the single source of truth | Miro: common code between this repo, segmentationtrajectory and asphaltedge belongs in the shared packages. polyline_hygiene already set the precedent. See the SSOT section. |
| Naming | Package renamed to iolabs-point-cloud-surface-mesh; pipeline_step: "surface_mesh", outputs segment_NNN_surface_mesh_<cw>.ply, surface_mesh_versions.json; all suffixes in file_naming | Repo no longer filters points, so the name follows the purpose. Run-number identity dropped in-file; lanefinder wires it as run_9 (after a run_8 asphalt-edge finder step). |
| Consumer | Revit highway model (via downstream import) | PLY is the canonical artifact; Revit import path (DirectShape/IFC/DXF) validated early, OBJ writer added only if needed. |
Architecture
New module layout (src/iolabs_point_cloud_filtering_surface/)
| Module | Role |
|---|---|
params.py (rewritten) | SurfaceMeshParameters dataclass: cells_across=5, row_spacing_m=3.0, device="CUDA:0", min_points_per_cell, z_trim_mad_factor, edge selection thresholds, extrapolation limits, visualization flags. |
edges_io.py (new) | Load segment_NNN_edges.npz; select usable stations (flags != FLAG_GAP, NaN-safe); reconstruct per-station edge points; extrapolate to segment ends; pair edges into carriageways with the undivided-road fallback. Probes optional keys via "inner_left_offsets_raw" in npz.files — inner keys are conditionally absent. |
gpu/cell_grid.py (new, torch) | Build centerline (midline between the carriageway's two edges), row stations every 3 m, column boundaries at fractions k/5. Per-point (row, col) in batched tensor ops: project all points onto centerline segments at once → station → row; lateral fraction between the two edge offsets at that station → column. |
gpu/cell_fit.py (new, torch) | Batched per-cell LS plane fits: index_add_ scatter of the moment sums (x, y, z, x², xy, y², xz, yz, count) per cell → batched 3×3 torch.linalg.solve → one MAD-trim iteration → refit. Vertex z = count-weighted average of adjacent cells' planes evaluated at the vertex XY; empty-cell fallback interpolates z between the nearest edge points of the same row. |
surface_mesh.py (new) | Assemble vertices (all edge points + interior lattice vertices), Delaunay on XY with QhullError guard, drop triangles whose centroid is outside the closed edge polygon (left edge + reversed right edge), emit vertices/triangles arrays; small self-contained binary PLY writer. |
diagnostics.py (adapts visualization.py) | Per-segment multi-page control PDF + CSV sidecars (via iolabs.common.diagnostic_data + _pdf_io.save_pdf_with_retry, both kept): cell map — lattice in station/column space colored by cell status (fitted / too-few-points / heavy-trim / fallback-z), the successor of today's rejection map; point-count heatmap per cell; residual histograms — aggregate + worst-N cells (z-diff to fitted plane, successor of plot_z_diff_histogram); per-source-file residual stats — distribution of point-to-plane residuals split by originating LAS NPZ (adapts the existing per-file LS accumulator) — a vertically offset or misaligned file shows up as a shifted distribution. |
surface_mesh_builder.py (replaces road_surface_finder.py) | SurfaceMeshBuilder: per-segment driver — load + blacklist-filter run3 NPZs (reusing load_segment_inputs / filter_segment_files patterns, minus Open3D tensors), call the stages per carriageway, write PLYs + surface_mesh_versions.json, return a summary dict. Structured logging via get_props_logger with pipeline_step: "surface_mesh". |
_config.py (adapted) | Delegates generic plumbing to iolabs.common.config_loader (ConfigError, deep_merge_dicts, validate_allowed_keys, load_packaged_json — see SSOT); keeps only the dataclass-derived key whitelists and segment blacklist normalizers; new default JSON surface_mesh.default.json force-included in the wheel. |
Data flow per segment
segment_NNN_edges.npz ──► edges_io ──► carriageway pairs (edge points, extrapolated)
*_run3_points.npz ──────► merged XYZ tensor (GPU)
│
cell_grid: (row, col) per point ◄─ centerline + cutting-plane lattice
│
cell_fit: batched plane fits ──► interior lattice vertex z
│
surface_mesh: Delaunay(XY) → clip outside edges → PLY per carriageway
│
surface_mesh_versions.json + summary dict
Input contract (asphaltedge edges NPZ)
Written by scripts/run_segments.py::_save_edges_npz (run_segments.py:126-148) as <out>/segment_NNN/segment_NNN_edges.npz. Keys consumed here: stations (0.25 m grid, 0 = segment start − 1 m margin), <side>_offsets_clean (NaN at gaps), <side>_flags (0 measured / 1 interpolated / 2 gap), <side>_polyline_points (N×3, z included), for sides left, right and optionally inner_left, inner_right. Positive offset = left. Gutter keys are ignored.
Error handling
- Edges NPZ missing or a carriageway's edges all-gap → log warning with props, skip that carriageway (or the segment), record in summary; never abort the whole run.
- QhullError (degenerate geometry) → warning + skip carriageway, mirroring today's behavior at
pipeline.py:74-79. - CUDA failure → retry the torch stages on CPU (existing repo convention).
- Cells with <
min_points_per_cellor unstable fits → neighbor/edge-interpolated z, counted in the summary.
Testing
- Synthetic fixtures: straight and curved dual-carriageway edge NPZs + point clouds sampled from known planes (crown/banking) with noise and outliers.
- Assertions: interior vertex z within tolerance of ground truth; no triangle centroid outside the edge polygon; all edge points appear as mesh vertices; edges extrapolated to segment ends; undivided-road fallback; conditionally-absent inner keys handled; CPU/GPU parity (CI runs CPU).
- Adapt config tests to the new whitelists; delete tests tied to deleted modules; keep
_pdf_io/diagnostic-sidecar tests.
Shared-code SSOT
Code needed by two or more of the three repos (this repo, segmentationtrajectory, asphaltedge) moves to the shared packages and is consumed from there — local copies are deleted, mirroring how polyline_hygiene was already extracted into iolabs-geometry-geometry (asphaltedge's polyline.py is a thin re-export shim).
| Abstraction | Today (duplicated) | SSOT home | Action |
|---|---|---|---|
Road axis: resampled centerline, frame_at(station), station_offset(points) | asphaltedge axis.py; this repo needs the same for centerline rows and lateral fractions | iolabs_geometry_geometry.axis (new module) | Move Axis verbatim; asphaltedge keeps a re-export shim; this repo imports the shared one. |
| Vertical cutting planes and slab tests: plane through two points forced vertical, plane families along a polyline, batched signed-distance / between-planes masks | segmentationtrajectory segment_mapper.py:173-249 (_vertical_plane_from_two_points, build_longitudinal_limit_planes, points_inside_longitudinal_limits); overlaps geometry_tools.points_between_planes | iolabs_geometry_geometry.cutting_planes (new module, numpy, builds on geometry_tools.Plane) | Extract and generalise (1-D plane family along a polyline with configurable spacing + vectorised masks); segmentationtrajectory refactors to consume; this repo derives its 2-D lattice (rows × columns) from the same primitives. |
| Config plumbing: error type, deep merge, key whitelists, packaged defaults | Near-identical _config.py in all three repos | iolabs.common.config_loader (already exists: ConfigError, deep_merge_dicts, validate_allowed_keys, load_packaged_json) | This repo's rewritten _config.py delegates to it and keeps only its dataclass-derived whitelists + blacklist normalizers; the other repos migrate in follow-up PRs. |
Segment points NPZ contract: points/red/green/blue/intensity/scan_angle load + multi-file merge, geoshift JSON, per-segment fnmatch blacklist | this repo load_segment_inputs/filter_segment_files; asphaltedge io.load_segment; segmentationtrajectory writer; lanefinder rasterizer | iolabs.common.segment_points_io (new module) | Extract schema constants, loader/merger, blacklist filter, geoshift loader; consumers switch over (this repo immediately, others in follow-ups). |
| Batched torch cell assignment + plane fits | new code in this repo | repo-local gpu/ subpackage now; optionally a new shared package iolabs-geometry-torch later | Sanctioned option (per Miro): a torch-native GPU geometry package (Open3D allowed there) in the 3dai.iolabs.geometry workspace. Deferred while there is a single consumer: the gpu/ subpackage is written torch-only with zero domain imports and its own tests, so promotion is a file move + shim when a second consumer appears. |
Mechanics: geometry additions land as a PR in 3dai.iolabs.geometry (uv workspace, packages/iolabs-geometry-geometry), common additions in 3dai.common; both publish to Nexus with a minor version bump. During development this repo may pin editable path sources (the commented pattern already in asphaltedge's pyproject.toml); before release it pins the published versions.
Phases
Phase 0 — Tombstone branch + shared-code SSOT extraction cross-repo
- Pre-implementation gate: create a tombstone branch preserving the old package state — branch
tombstone-filtering-surface-v0.7.5fromorigin/master(last release32d68cd, v0.7.5) and push it before any demolition lands. The complete RANSAC filtering-surface implementation stays reachable there after the rewrite and rename (alongside lanefinder'slegacy-steps-456-nexus-pinned, which pins the old driver). - PR to
3dai.iolabs.geometry: addaxis.py(moved from asphaltedge) andcutting_planes.py(generalised from segmentationtrajectory) with tests; publishiolabs-geometry-geometryminor bump to Nexus. - PR to
3dai.common: addsegment_points_io.py; publish minor bump. - Follow-up shim/migration PRs in asphaltedge (
axis.py→ re-export) and segmentationtrajectory (consumecutting_planes) — can trail this repo's work; local copies are deleted there once merged. - Runs in parallel with Phase 1; Phases 2–4 consume the shared modules (editable path pins during dev).
Phase 1 — Demolition low risk
- Delete
planar_patches.py,pipeline.py,lattice.py,mesh_ops.py,points_queries.py, oldparams.py,road_surface_finder.py(after harvesting the reusable loaders), and their tests.visualization.pyis trimmed, not deleted: the CSV-sidecar/PDF helpers and histogram rendering survive intodiagnostics.py(Phase 5); OBB/separator/Open3D pages go. - Prune
pyproject.toml: dropopen3d,scikit-image,iolabs-geometry-visualization,tqdm; keepnumpy,scipy,torch,matplotlib,iolabs-logstash,iolabs-common.uv sync+ make the residual test suite green. - Update
_log_props.pytopipeline_step: "surface_mesh".
Phase 2 — Config & params low risk
- New
SurfaceMeshParametersdataclass; regenerate whitelists in_config.py; newsurface_mesh.default.json(wheel force-include updated); adapt config tests. file_naming:segment_points_suffix(unchanged_run3_points),edges_npz_name,surface_mesh_stem,versions_json_name.
Phase 3 — Edge loading & extension medium
edges_io.py: NPZ reader tolerant of absent inner keys; gap masking from flags; carriageway pairing (outer↔inner, fallback outer↔outer); linear extrapolation of each edge to the segment's station range ends with anEXTRAPOLATEDflag; per-station edge point reconstruction so both edges share the station grid.- Edge-quality tolerance: consume clean offsets + flags/conf only; optional configurable smoothing pass over the edge polylines; per-segment edge-quality metrics (measured/interpolated/extrapolated fractions, jitter) exported to the summary and Phase 5 diagnostics — edges are weak today and will improve, so this module is the only place that should ever change.
- TDD with synthetic NPZ fixtures covering gaps, stubs, missing inners.
Phase 4 — GPU lattice & plane fits hardest
gpu/subpackage — torch-only, zero domain imports, own tests, so it can later be promoted to a sharediolabs-geometry-torchpackage (sanctioned option) as a pure file move.gpu/cell_grid.py: centerline from edge midpoints, row stations everyrow_spacing_m, batched point→(row, col) assignment (nearest-segment projection + lateral fraction; equivalently signed distances to the two plane families).gpu/cell_fit.py: scatter-moment accumulation, batched 3×3 solves, MAD trim + refit, vertex z evaluation with empty-cell fallback.point_file_idsride along through assignment and fits so per-cell residuals can be split by source LAS NPZ (feeds the Phase 5 misalignment diagnostics).- Numerical parity test CPU vs CUDA; accuracy test against analytic planes.
Phase 5 — Mesh assembly, PLY & diagnostics medium
surface_mesh.py: vertex pool (all edge points + interior lattice), Delaunay, centroid-in-polygon clip, binary-little-endian PLY writer (no Open3D).diagnostics.py: control PDF + CSV sidecars — cell-status map (new rejection map), point-count heatmap, aggregate + worst-N per-cell residual histograms, per-source-file residual distributions (misaligned-LAS detector). Reuses_pdf_io.save_pdf_with_retry,diagnostic_datasidecar pattern (mesh_ops.py:112-129), andvisualization.py's histogram/sidecar helpers; keep + adapttest_diagnostic_csv_sidecars,test_pdf_io,test_per_file_ls_abs_dist_accumulator.- Property test: every triangle centroid inside polygon; mesh is edge-tight at both ends.
Phase 6 — Driver, docs, release low risk
SurfaceMeshBuilderdriver + rewritten__init__.pyexports;save_version_json(..., "surface_mesh_builder").- Package rename to
iolabs-point-cloud-surface-mesh: pyproject name + src diriolabs_point_cloud_surface_mesh, wheel force-include path, publish under the new name (old name stays frozen on Nexus for the legacy branch); Bitbucket repo rename is Miro-side. - Validate the Revit import path for the PLY meshes (DirectShape/IFC/DXF route); add an OBJ writer only if that check demands it.
- Rewrite README + CLAUDE.md for the new purpose (also fixes the stale “Step 4” claims); full test suite; version bump per repo convention.
- Document the lanefinder wiring contract (new runner slots after run_7; see Open questions) without touching lanefinder itself.
Execution strategy (workflows & models)
- Fable orchestrates, reviews diffs, owns the plan and integration decisions.
- Grok (cursor delegate; cheap, effectively unlimited right now) — primary implementer for Phases 0–3, 5, 6 and test authoring, including the geometry/common extraction PRs.
- Sol — Phase 4 (batched torch lattice + fits) and adversarial review of the numerical code.
- Sonnet — mechanical sweeps (dead-reference cleanup, doc consistency checks).
- Per-phase workflow: implement → tests green → independent review pass; phases land as separate commits on the
worktree-surface-mesh-redesignbranch, draft PR at the end.
Risks
- input quality Pavement edges are not very good yet (Miro) and will improve over time. Consequences baked into the design:
edges_io.pyis the single isolation layer (better edges drop in with zero changes downstream); the builder consumes clean offsets and honours flags/confidence rather than trusting raw detections; an optional extra smoothing pass over edge polylines is a config knob; diagnostics report per-segment edge-quality metrics (measured vs interpolated vs extrapolated station fractions, edge jitter) so bad-edge segments are visible in the control PDF instead of silently producing wavy meshes. - contract The edges NPZ is written by asphaltedge's dev runner (
run_segments.py), not yet a wired pipeline step — and a sibling script (run_zrange_edges.py) dumps JSON instead. This plan pins the NPZ schema as the contract; if asphaltedge productionizes differently, onlyedges_io.pychanges. - geometry Sharp curvature could make transverse row planes cross (rows overlap) if radius < half carriageway width — negligible on highways, but the row assignment uses nearest-projection, which degrades gracefully rather than double-assigning.
- extrapolation Linear extension to segment ends can misplace the edge where the last measured run is short or noisy; capped by config (
max_extrapolation_m) and flagged in the output summary. - data quality All-gap inner edges on divided segments (width sanity-check demotions) silently trigger the undivided fallback and would mesh across the median; mitigate by requiring a minimum measured-station fraction before accepting the fallback, and reporting the choice per segment.
- cross-repo The SSOT extraction couples this rewrite to Nexus releases of
iolabs-geometry-geometryandiolabs-common; editable path pins keep development unblocked, but the release phase must wait for both publishes, and asphaltedge/segmentationtrajectory migrations need their own test runs. - downstream Nothing on lanefinder master calls this repo today (old driver is legacy-branch-only), so the breaking rewrite has no live consumers; asphaltedge still reads legacy
*_run4_road_surface.npzfiles from disk, which remain untouched.
Resolved questions (Miro, 2026-07-24)
- Package rename: yes —
iolabs-point-cloud-surface-mesh(moduleiolabs_point_cloud_surface_mesh). Done as part of Phase 6 (pyproject name, src dir, wheel force-include, Nexus publish under the new name; Bitbucket repo rename is a Miro-side action). - Lanefinder step numbering: two-step split approved — run_8 asphalt-edge finder, run_9 surface-mesh builder; wiring is lanefinder-side follow-up work.
- Mesh consumer: a 3-D model of the highway in Revit. No extra per-cell stats sidecar needed (diagnostics PDF/CSV covers QA). note Revit does not import PLY natively (DirectShape via Dynamo/plugin, or IFC/DXF/SAT routes) — PLY stays the canonical artifact; validate the Revit import path early and, if needed, add a cheap OBJ writer or a downstream converter.
- Interior density: 5 cells across / 3 m rows confirmed as config defaults.
- GPU geometry package: do not create
iolabs-geometry-torchwhile this repo is the only consumer —gpu/subpackage stays repo-local, liftable later.