Surface Mesh Redesign — edge-bounded pavement mesh

2026-07-24 · repo 3dai.iolabs.pointcloud.filteringsurfaceiolabs-point-cloud-surface-mesh · implementation plan · approved by Miro 2026-07-24; next: implementation Phases 0–6 on branch worktree-surface-mesh-redesign

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

DecisionChoiceWhy
Deliverables(1) Mesh (PLY per carriageway per segment); (2) diagnostic control PDF + CSV sidecars per segmentMiro: 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.
InputsFiles: segment_NNN_edges.npz (asphaltedge) + *_run3_points.npzFile-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.
CarriagewaysOne mesh per carriageway: outer edge ↔ inner edge; median unmeshedDivided 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 verticesAll edge polyline points are mesh verticesMiro: “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 extensionExtrapolate both edges to the segment station range endsDetected 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 zPer-cell least-squares plane fit, evaluated at lattice verticesSame 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 assignment2-D lattice of vertical cutting planes, batched in PyTorch on GPUGeneralisation 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.
Triangulationscipy.spatial.Delaunay on XY + centroid-in-polygon clipSame idiom as today (pipeline.py:73, QhullError guard). Clip with matplotlib.path.Path.contains_points on triangle centroids — avoids adding shapely.
Old codeDelete params, pipeline, planar_patches, lattice, mesh_ops, points_queries; adapt _config, road_surface_finder; keep _log_props, _pdf_ioThe old driver only exists on lanefinder's legacy-steps-456-nexus-pinned branch — master no longer calls this repo, so deletion breaks nothing live.
DependenciesDrop open3d, scikit-image, iolabs-geometry-visualization; keep numpy, scipy, torch, matplotlib, logstash/common, iolabs-geometry-geometryOpen3D 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 codeCross-repo duplicates move to iolabs-common / iolabs-geometry-geometry as the single source of truthMiro: common code between this repo, segmentationtrajectory and asphaltedge belongs in the shared packages. polyline_hygiene already set the precedent. See the SSOT section.
NamingPackage 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_namingRepo 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).
ConsumerRevit 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/)

ModuleRole
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

Testing

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).

AbstractionToday (duplicated)SSOT homeAction
Road axis: resampled centerline, frame_at(station), station_offset(points)asphaltedge axis.py; this repo needs the same for centerline rows and lateral fractionsiolabs_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 maskssegmentationtrajectory segment_mapper.py:173-249 (_vertical_plane_from_two_points, build_longitudinal_limit_planes, points_inside_longitudinal_limits); overlaps geometry_tools.points_between_planesiolabs_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 defaultsNear-identical _config.py in all three reposiolabs.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 blacklistthis repo load_segment_inputs/filter_segment_files; asphaltedge io.load_segment; segmentationtrajectory writer; lanefinder rasterizeriolabs.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 fitsnew code in this reporepo-local gpu/ subpackage now; optionally a new shared package iolabs-geometry-torch laterSanctioned 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.5 from origin/master (last release 32d68cd, 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's legacy-steps-456-nexus-pinned, which pins the old driver).
  • PR to 3dai.iolabs.geometry: add axis.py (moved from asphaltedge) and cutting_planes.py (generalised from segmentationtrajectory) with tests; publish iolabs-geometry-geometry minor bump to Nexus.
  • PR to 3dai.common: add segment_points_io.py; publish minor bump.
  • Follow-up shim/migration PRs in asphaltedge (axis.py → re-export) and segmentationtrajectory (consume cutting_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, old params.py, road_surface_finder.py (after harvesting the reusable loaders), and their tests. visualization.py is trimmed, not deleted: the CSV-sidecar/PDF helpers and histogram rendering survive into diagnostics.py (Phase 5); OBB/separator/Open3D pages go.
  • Prune pyproject.toml: drop open3d, scikit-image, iolabs-geometry-visualization, tqdm; keep numpy, scipy, torch, matplotlib, iolabs-logstash, iolabs-common. uv sync + make the residual test suite green.
  • Update _log_props.py to pipeline_step: "surface_mesh".
Phase 2 — Config & params low risk
  • New SurfaceMeshParameters dataclass; regenerate whitelists in _config.py; new surface_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 an EXTRAPOLATED flag; 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 shared iolabs-geometry-torch package (sanctioned option) as a pure file move.
  • gpu/cell_grid.py: centerline from edge midpoints, row stations every row_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_ids ride 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_data sidecar pattern (mesh_ops.py:112-129), and visualization.py's histogram/sidecar helpers; keep + adapt test_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
  • SurfaceMeshBuilder driver + rewritten __init__.py exports; save_version_json(..., "surface_mesh_builder").
  • Package rename to iolabs-point-cloud-surface-mesh: pyproject name + src dir iolabs_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)

Risks

Resolved questions (Miro, 2026-07-24)