Adds STATUS.md as the handoff document: benchmark numbers, the bare-earth
metrics that actually matter for this project, the Korean-data domain gap that
retraining will not fix, and what to do on the 24 GB machine.
The memory problem was in how a prediction's colours were turned back into
class indices. Every consumer built an (N, 13, 3) float64 temporary:
d = ((rgb[:, None, :] - COLOR_MAP[None, :, :]) ** 2).sum(axis=2)
That is ~250 MB of intermediates per 800k-point tile, several live at once, and
a full 4.7M-point block pushes it into gigabytes. main.py writes exact palette
entries, so an exact hash lookup resolves nearly every point with no large
temporary; only leftovers fall back to a chunked distance search. Peak RSS on a
470k-point tile drops to 61 MB. Extracted to sumparts_palette.py and shared by
coarse_eval.py and split_by_class.py.
Also from this round:
- patch_cm_mutation.sh: ConfusionMatrix.update() rewrote the caller's pred
tensor in place, folding every ignore_index point into class num_classes-1.
test() saves its visualization from that same tensor afterwards, so an
unlabelled tile came out 100% wall and the model looked degenerate when it
was not.
- patch_class_mask.sh: SUMPARTS_MASK_CLASSES drops known-absent classes from
the argmax. Measured on Seosan and it does not help - the runner-up for
"water" is "wall", not "terrain" - but the experiment is worth keeping.
- split_by_class.py now writes .ply alongside .obj. A vertex-only OBJ has zero
faces and most viewers render nothing, which is why the first export looked
broken.
- verify_outputs.sh reads exported files back with a parser, so "here are your
files" can be checked rather than asserted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
125 lines
4.7 KiB
Python
125 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Split a predicted point cloud into one OBJ per target class.
|
|
|
|
The prediction carries one of SUM's 13 fine classes per point. This folds them
|
|
into the four the project cares about, plus a bucket for everything the model
|
|
could not place:
|
|
|
|
ground terrain
|
|
building facade_surface, roof_surface, chimney, dormer, balcony,
|
|
roof_installation, wall
|
|
tree high_vegetation
|
|
vehicle car, boat
|
|
unseen unclassified, water
|
|
|
|
water sits in `unseen` deliberately. The Seosan site has essentially no water,
|
|
so every point the model calls water is a misread, not a class we can trust.
|
|
Filing it as ground would bake that error into the terrain; filing it as its
|
|
own bucket keeps it visible.
|
|
|
|
OBJ carries no per-point class, so each file is written as vertex-only geometry
|
|
(`v x y z r g b`) with one file per class. Viewers that read vertex colour show
|
|
the photo texture; the rest show the points.
|
|
|
|
Usage:
|
|
python split_by_class.py pred.ply OUTDIR [--source rgb.ply]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from sumparts_palette import ( # noqa: E402
|
|
CLASSES as FINE, GROUPS, read_colours, read_point_classes,
|
|
)
|
|
|
|
def write_obj(path: Path, xyz: np.ndarray, rgb: np.ndarray | None) -> None:
|
|
"""OBJ, vertex-only.
|
|
|
|
Kept because it was asked for, but be aware: an OBJ with no `f` lines is a
|
|
mesh with zero faces, and most viewers render exactly that - nothing. Use
|
|
the .ply next to it to actually look at the points.
|
|
"""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("w", encoding="utf-8") as f:
|
|
f.write(f"# {path.stem}: {len(xyz):,} points\n")
|
|
f.write("# vertex-only (no faces) -- most viewers show nothing.\n")
|
|
f.write("# open the .ply beside this file instead.\n")
|
|
if rgb is None:
|
|
for p in xyz:
|
|
f.write(f"v {p[0]:.4f} {p[1]:.4f} {p[2]:.4f}\n")
|
|
else:
|
|
for p, c in zip(xyz, rgb):
|
|
f.write(f"v {p[0]:.4f} {p[1]:.4f} {p[2]:.4f} "
|
|
f"{c[0]/255:.4f} {c[1]/255:.4f} {c[2]/255:.4f}\n")
|
|
|
|
|
|
def write_ply(path: Path, xyz: np.ndarray, rgb: np.ndarray | None) -> None:
|
|
"""Binary PLY - the format point-cloud viewers actually open.
|
|
|
|
float32 coordinates and uint8 red/green/blue, which is the spelling
|
|
CloudCompare, MeshLab and Mapple all read without coaxing.
|
|
"""
|
|
from plyfile import PlyData, PlyElement
|
|
|
|
dtype = [("x", "f4"), ("y", "f4"), ("z", "f4")]
|
|
if rgb is not None:
|
|
dtype += [("red", "u1"), ("green", "u1"), ("blue", "u1")]
|
|
|
|
arr = np.empty(len(xyz), dtype=dtype)
|
|
arr["x"], arr["y"], arr["z"] = xyz[:, 0], xyz[:, 1], xyz[:, 2]
|
|
if rgb is not None:
|
|
arr["red"], arr["green"], arr["blue"] = rgb[:, 0], rgb[:, 1], rgb[:, 2]
|
|
|
|
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("pred", type=Path, help="prediction ply")
|
|
ap.add_argument("outdir", type=Path, help="root directory for the class folders")
|
|
ap.add_argument("--source", type=Path, default=None,
|
|
help="pre-inference ply, to carry photo colour into the OBJs")
|
|
ap.add_argument("--name", default="seosan_BlockYBA_tile0",
|
|
help="base filename inside each class folder")
|
|
args = ap.parse_args()
|
|
|
|
if not args.pred.exists():
|
|
raise SystemExit(f"{args.pred}: not found")
|
|
|
|
xyz, cls = read_point_classes(args.pred)
|
|
print(f"prediction : {args.pred.name} {len(xyz):,} points")
|
|
|
|
rgb = None
|
|
if args.source and args.source.exists():
|
|
rgb = read_colours(args.source, len(xyz))
|
|
print(f"colour : {'from ' + args.source.name if rgb is not None else 'unavailable (count mismatch)'}")
|
|
|
|
print()
|
|
print(f" {'folder':<10} {'points':>10} {'share':>8} from")
|
|
total = 0
|
|
for group, ids in GROUPS.items():
|
|
mask = np.isin(cls, ids)
|
|
n = int(mask.sum())
|
|
total += n
|
|
members = ", ".join(FINE[i] for i in ids)
|
|
sub_xyz = xyz[mask]
|
|
sub_rgb = rgb[mask] if rgb is not None else None
|
|
write_ply(args.outdir / group / f"{args.name}.ply", sub_xyz, sub_rgb)
|
|
write_obj(args.outdir / group / f"{args.name}.obj", sub_xyz, sub_rgb)
|
|
print(f" {group:<10} {n:>10,} {100*n/len(xyz):>7.2f}% {members}")
|
|
|
|
print()
|
|
print(f" total {total:>10,} ({'all points accounted for' if total == len(xyz) else 'MISMATCH'})")
|
|
print(f" written to {args.outdir}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|