Reproduces the SUM Parts (CVPR 2025) face-labeling benchmark on a single consumer GPU, then applies it to drone-photogrammetry road survey meshes. Verified on RTX 3060 12GB / WSL2 Ubuntu 22.04 / CUDA 11.8 / torch 2.0.1: - CUDA extensions build (pointnet2_batch, pointops, chamfer_dist, emd, subsampling) - PointNet 100 epochs reaches mIoU 17.19, matching the paper's reported 15.1 - OBJ -> PLY conversion round-trips through the model and yields per-point predictions Four upstream source patches, all idempotent, originals preserved: - numpy aliases removed in 1.24 (np.long etc.) and collections ABCs moved in python 3.10 - the blind test split ships label = -1, which crashed ConfusionMatrix - mode=val referenced `epoch` before assignment Documents the traps that cost the most time, including VRAM overflow silently falling back to host RAM on WSL2 (25-100x slowdown, no OOM) and the colour scale mismatch between r/g/b float32 and red/green/blue uint8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
280 lines
10 KiB
Python
280 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Convert a textured mesh into the PLY point cloud SUM Parts expects.
|
|
|
|
Target schema, from openpoints/dataset/sumv2_triangle/sumv2_triangle.py
|
|
(read_ply_with_plyfilelib) and confirmed against the shipped demo tile:
|
|
|
|
element vertex N
|
|
property float x / y / z
|
|
property uchar red / green / blue (the demo uses r/g/b; the loader
|
|
accepts either spelling)
|
|
property uchar label 0 == unclassified, which cfg ignores
|
|
property int object_index optional
|
|
|
|
Written for ContextCapture-style aerial OBJ exports, which are the shape the
|
|
Seosan Myeongcheon blocks come in:
|
|
|
|
* multi-material -- one texture atlas per material (17-21 per block), so the
|
|
mesh has to be sampled per material, not as one merged blob. Merging with
|
|
force='mesh' silently drops every texture and the output comes out grey.
|
|
* no vertex colours -- the texture is the ONLY colour source. There is no
|
|
fallback to degrade to.
|
|
* coordinates already local and metric, with the true origin recorded in
|
|
metadata.xml as SRSOrigin (EPSG:5186+9999). Same convention SUM Parts
|
|
itself ships, so no reprojection is needed -- but never feed raw easting/
|
|
northing or degrees, since every cfg radius and voxel size is in metres.
|
|
|
|
--bbox crops before sampling, which is how you get one ~252 m SUM-sized tile
|
|
out of a 465 x 487 m block.
|
|
|
|
Usage:
|
|
python mesh_to_ply.py Block.obj out.ply --points 500000
|
|
python mesh_to_ply.py Block.obj tile.ply --bbox 300 700 552 952
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
|
|
def die(msg: str) -> None:
|
|
print(f"error: {msg}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
|
|
|
|
|
def load_scene(path: Path):
|
|
"""Load as a Scene so each material keeps its own texture."""
|
|
try:
|
|
import trimesh
|
|
except ImportError:
|
|
die("trimesh not installed. run: pip install trimesh pillow")
|
|
|
|
log(f"loading {path.name} ({path.stat().st_size / 1e6:,.0f} MB) ...")
|
|
scene = trimesh.load(path, process=False)
|
|
|
|
if isinstance(scene, trimesh.Trimesh):
|
|
geoms = [scene]
|
|
else:
|
|
geoms = list(scene.geometry.values())
|
|
|
|
geoms = [g for g in geoms if getattr(g, "faces", None) is not None and len(g.faces)]
|
|
if not geoms:
|
|
die(f"{path} has no faces")
|
|
|
|
log(f"loaded {len(geoms)} geometry group(s), "
|
|
f"{sum(len(g.faces) for g in geoms):,} faces total")
|
|
return geoms
|
|
|
|
|
|
def crop(geom, bbox):
|
|
"""Keep faces whose centroid falls inside the XY bbox. Returns a mask."""
|
|
if bbox is None:
|
|
return np.ones(len(geom.faces), dtype=bool)
|
|
x0, y0, x1, y1 = bbox
|
|
c = geom.vertices[geom.faces].mean(axis=1)
|
|
return (c[:, 0] >= x0) & (c[:, 0] < x1) & (c[:, 1] >= y0) & (c[:, 1] < y1)
|
|
|
|
|
|
def sample_geom(geom, face_mask, n_points, seed):
|
|
"""Area-weighted sampling restricted to the masked faces, with colour."""
|
|
import trimesh
|
|
|
|
idx = np.flatnonzero(face_mask)
|
|
if idx.size == 0 or n_points <= 0:
|
|
return np.empty((0, 3)), np.empty((0, 3), dtype=np.uint8)
|
|
|
|
tri = geom.vertices[geom.faces[idx]] # (m, 3, 3)
|
|
area = trimesh.triangles.area(tri)
|
|
total = area.sum()
|
|
if total <= 0:
|
|
return np.empty((0, 3)), np.empty((0, 3), dtype=np.uint8)
|
|
|
|
rng = np.random.default_rng(seed)
|
|
pick = rng.choice(idx.size, size=n_points, p=area / total)
|
|
|
|
# uniform barycentric coordinates over each chosen triangle
|
|
r1, r2 = rng.random(n_points), rng.random(n_points)
|
|
s = np.sqrt(r1)
|
|
bary = np.stack([1 - s, s * (1 - r2), s * r2], axis=1) # (n, 3)
|
|
|
|
xyz = (tri[pick] * bary[:, :, None]).sum(axis=1)
|
|
rgb = colour(geom, idx[pick], bary)
|
|
return xyz, rgb
|
|
|
|
|
|
def colour(geom, face_idx, bary) -> np.ndarray:
|
|
"""Per-point RGB from the geometry's texture; grey if it has none."""
|
|
n = len(face_idx)
|
|
visual = getattr(geom, "visual", None)
|
|
|
|
uv = getattr(visual, "uv", None)
|
|
material = getattr(visual, "material", None)
|
|
image = getattr(material, "image", None) if material is not None else None
|
|
|
|
if uv is not None and image is not None:
|
|
try:
|
|
tri_uv = np.asarray(uv)[geom.faces[face_idx]] # (n, 3, 2)
|
|
point_uv = (tri_uv * bary[:, :, None]).sum(axis=1)
|
|
img = np.asarray(image.convert("RGB"))
|
|
h, w = img.shape[:2]
|
|
# OBJ UV origin is bottom-left; image rows run top-down
|
|
px = np.clip((point_uv[:, 0] % 1.0) * (w - 1), 0, w - 1).astype(np.int32)
|
|
py = np.clip((1.0 - point_uv[:, 1] % 1.0) * (h - 1), 0, h - 1).astype(np.int32)
|
|
return img[py, px].astype(np.uint8)
|
|
except Exception as e: # noqa: BLE001 - exporters vary wildly
|
|
print(f"warn: texture sampling failed ({e})", file=sys.stderr)
|
|
|
|
vc = getattr(visual, "vertex_colors", None)
|
|
if vc is not None and len(vc) == len(geom.vertices):
|
|
vc = np.asarray(vc)[:, :3].astype(np.float64)
|
|
return (vc[geom.faces[face_idx]] * bary[:, :, None]).sum(axis=1).astype(np.uint8)
|
|
|
|
return np.full((n, 3), 128, dtype=np.uint8)
|
|
|
|
|
|
def write_ply(path: Path, xyz, rgb, label: int, object_index: int | None,
|
|
colour_style: str = "sum") -> None:
|
|
"""Write the point cloud.
|
|
|
|
colour_style='sum' reproduces exactly what SUM Parts ships: fields named
|
|
r/g/b holding float32 in [0, 1]. That match is not cosmetic. The loader
|
|
normalises nothing --
|
|
|
|
rgb = np.stack([...'red','green','blue'...]).astype(np.uint8)
|
|
except ValueError:
|
|
rgb = np.stack([...'r','g','b'...]).astype(np.float32)
|
|
if np.max(rgb) > 1:
|
|
rgb = rgb # <- no-op, despite how it reads
|
|
|
|
-- and the active datatransforms in cfgs/sumv2_triangle/default.yaml are
|
|
[PointsToTensor, PointCloudScaling, PointCloudRotation, PointCloudJitter];
|
|
NumpyChromaticNormalize is commented out. So whatever is in the file reaches
|
|
the network unscaled. Writing 0-255 where the training data was 0-1 hands
|
|
the model colour features 255x too large.
|
|
|
|
colour_style='uint8' writes red/green/blue as uint8 instead, for viewers
|
|
that expect the conventional PLY spelling.
|
|
"""
|
|
try:
|
|
from plyfile import PlyData, PlyElement
|
|
except ImportError:
|
|
die("plyfile not installed. run: pip install plyfile")
|
|
|
|
if colour_style == "sum":
|
|
cols = [("r", "f4"), ("g", "f4"), ("b", "f4")]
|
|
vals = (rgb.astype(np.float32) / 255.0)
|
|
else:
|
|
cols = [("red", "u1"), ("green", "u1"), ("blue", "u1")]
|
|
vals = rgb
|
|
|
|
# demo tiles store label as int32
|
|
dtype = [("x", "f4"), ("y", "f4"), ("z", "f4"), *cols, ("label", "i4")]
|
|
if object_index is not None:
|
|
dtype.append(("object_index", "i4"))
|
|
|
|
arr = np.empty(len(xyz), dtype=dtype)
|
|
arr["x"], arr["y"], arr["z"] = xyz[:, 0], xyz[:, 1], xyz[:, 2]
|
|
for i, (name, _) in enumerate(cols):
|
|
arr[name] = vals[:, i]
|
|
arr["label"] = label
|
|
if object_index is not None:
|
|
arr["object_index"] = object_index
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
PlyData([PlyElement.describe(arr, "vertex")], text=False).write(str(path))
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(
|
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("input", type=Path)
|
|
ap.add_argument("output", type=Path)
|
|
ap.add_argument("--points", type=int, default=500_000,
|
|
help="total points to sample (default: 500000)")
|
|
ap.add_argument("--bbox", type=float, nargs=4, metavar=("X0", "Y0", "X1", "Y1"),
|
|
help="crop to this XY box in the mesh's own local coords")
|
|
ap.add_argument("--label", type=int, default=0,
|
|
help="constant label to stamp; 0 = unclassified (default: 0)")
|
|
ap.add_argument("--object-index", type=int, default=None)
|
|
ap.add_argument("--colour-style", choices=("sum", "uint8"), default="sum",
|
|
help="'sum' (default): r/g/b float32 0-1, byte-for-byte what "
|
|
"SUM Parts ships. 'uint8': red/green/blue 0-255 for viewers")
|
|
ap.add_argument("--keep-origin", action="store_true",
|
|
help="do NOT translate the result to a local origin")
|
|
ap.add_argument("--seed", type=int, default=0)
|
|
args = ap.parse_args()
|
|
|
|
if not args.input.exists():
|
|
die(f"{args.input} not found")
|
|
if not 0 <= args.label <= 255:
|
|
die("--label must fit in a uint8 (0-255)")
|
|
|
|
geoms = load_scene(args.input)
|
|
|
|
# Budget points per geometry by cropped surface area, so a material that
|
|
# covers half the tile gets half the points.
|
|
import trimesh
|
|
masks, areas = [], []
|
|
for g in geoms:
|
|
m = crop(g, args.bbox)
|
|
masks.append(m)
|
|
a = trimesh.triangles.area(g.vertices[g.faces[m]]).sum() if m.any() else 0.0
|
|
areas.append(a)
|
|
|
|
total_area = float(sum(areas))
|
|
if total_area <= 0:
|
|
die("nothing left after cropping -- check --bbox against the mesh bbox")
|
|
log(f"cropped surface area: {total_area:,.0f} m^2 across "
|
|
f"{sum(int(m.sum()) for m in masks):,} faces")
|
|
|
|
parts_xyz, parts_rgb = [], []
|
|
for i, (g, m, a) in enumerate(zip(geoms, masks, areas)):
|
|
if a <= 0:
|
|
continue
|
|
n = int(round(args.points * a / total_area))
|
|
if n <= 0:
|
|
continue
|
|
xyz, rgb = sample_geom(g, m, n, args.seed + i)
|
|
if len(xyz):
|
|
parts_xyz.append(xyz)
|
|
parts_rgb.append(rgb)
|
|
log(f" geom {i:>3}: {int(m.sum()):>8,} faces {a:>12,.0f} m^2 -> {len(xyz):>8,} pts")
|
|
|
|
if not parts_xyz:
|
|
die("no points sampled")
|
|
|
|
xyz = np.vstack(parts_xyz)
|
|
rgb = np.vstack(parts_rgb)
|
|
|
|
lo, hi = xyz.min(axis=0), xyz.max(axis=0)
|
|
log(f"extent: {np.round(hi - lo, 2)} m origin: {np.round(lo, 2)}")
|
|
|
|
if not args.keep_origin:
|
|
xyz = xyz - lo
|
|
log("translated to local origin (min corner -> 0,0,0)")
|
|
|
|
if (hi - lo).max() < 0.5:
|
|
print("warn: extent under 0.5 units -- looks like degrees, not metres. "
|
|
"Reproject to a metric CRS (e.g. EPSG:5186) first.", file=sys.stderr)
|
|
|
|
grey = int((rgb == 128).all(axis=1).sum())
|
|
if grey:
|
|
print(f"warn: {grey:,}/{len(rgb):,} points fell back to grey "
|
|
"(missing texture)", file=sys.stderr)
|
|
|
|
write_ply(args.output, xyz, rgb, args.label, args.object_index, args.colour_style)
|
|
log(f"wrote {args.output} ({len(xyz):,} points, label={args.label}, "
|
|
f"colour={args.colour_style})")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|