Files
nbrightandClaude Opus 5 609d9a6972 Add SUM Parts reproduction and Seosan Myeongcheon application pipeline
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>
2026-08-21 10:29:25 +09:00

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