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>
132 lines
4.8 KiB
Python
132 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared SUM Parts class table and a memory-safe colour decoder.
|
|
|
|
main.py writes predictions as palette colours rather than labels, so recovering
|
|
the class means matching RGB back to the palette. The obvious way to do that,
|
|
|
|
d = ((rgb[:, None, :] - COLOR_MAP[None, :, :]) ** 2).sum(axis=2)
|
|
|
|
builds an (N, 13, 3) float64 temporary. At 800k points that is ~250 MB per
|
|
intermediate and several exist at once - enough to run a machine out of memory
|
|
on a full tile, and the failure looks like an unrelated crash.
|
|
|
|
Since main.py writes exact palette entries, an exact lookup handles nearly
|
|
every point in one pass with no large temporary. Only the leftovers fall back
|
|
to a distance search, and that runs in chunks.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
CLASSES = ['unclassified', 'terrain', 'high_vegetation', 'facade_surface',
|
|
'water', 'car', 'boat', 'roof_surface', 'chimney', 'dormer',
|
|
'balcony', 'roof_installation', 'wall']
|
|
|
|
COLOR_MAP = np.array([
|
|
(0, 0, 0), (170, 85, 0), (0, 255, 0), (255, 255, 0),
|
|
(0, 255, 255), (255, 0, 255), (0, 0, 153), (85, 85, 127),
|
|
(255, 50, 50), (85, 0, 127), (50, 125, 150), (50, 0, 50),
|
|
(215, 160, 140),
|
|
], dtype=np.float64)
|
|
|
|
# what the project actually needs, folded from the 13
|
|
#
|
|
# water sits in `unseen`, not `ground`: the Seosan site has essentially no
|
|
# water, so every point called water is a misread. Filing it as ground would
|
|
# bake that error into the terrain surface.
|
|
GROUPS: dict[str, list[int]] = {
|
|
"ground": [1],
|
|
"building": [3, 7, 8, 9, 10, 11, 12],
|
|
"tree": [2],
|
|
"vehicle": [5, 6],
|
|
"unseen": [0, 4],
|
|
}
|
|
|
|
|
|
def _pack(rgb_u8: np.ndarray) -> np.ndarray:
|
|
"""RGB triples -> one int32 key each, for hashing."""
|
|
a = rgb_u8.astype(np.int32)
|
|
return (a[:, 0] << 16) | (a[:, 1] << 8) | a[:, 2]
|
|
|
|
|
|
def decode_palette(rgb: np.ndarray, chunk: int = 200_000) -> np.ndarray:
|
|
"""Class index per point, without allocating an (N, 13, 3) temporary.
|
|
|
|
Exact palette hits resolve through a dict; anything else (a resampled or
|
|
recompressed file) falls back to a chunked nearest-colour search.
|
|
"""
|
|
rgb = np.asarray(rgb)
|
|
if rgb.dtype != np.uint8:
|
|
scaled = rgb.astype(np.float64)
|
|
if scaled.max() <= 1.0:
|
|
scaled = scaled * 255.0
|
|
rgb = np.clip(scaled, 0, 255).astype(np.uint8)
|
|
|
|
lut = {int(k): i for i, k in enumerate(_pack(COLOR_MAP.astype(np.uint8)))}
|
|
keys = _pack(rgb)
|
|
|
|
out = np.full(len(rgb), -1, dtype=np.int64)
|
|
uniq, inverse = np.unique(keys, return_inverse=True)
|
|
resolved = np.array([lut.get(int(k), -1) for k in uniq], dtype=np.int64)
|
|
out = resolved[inverse]
|
|
|
|
missing = out < 0
|
|
if missing.any():
|
|
idx = np.flatnonzero(missing)
|
|
for s in range(0, len(idx), chunk):
|
|
part = idx[s:s + chunk]
|
|
block = rgb[part].astype(np.float64)
|
|
d = ((block[:, None, :] - COLOR_MAP[None, :, :]) ** 2).sum(axis=2)
|
|
out[part] = d.argmin(axis=1)
|
|
|
|
return out
|
|
|
|
|
|
def read_point_classes(path) -> tuple[np.ndarray, np.ndarray]:
|
|
"""(xyz float32, class index) from a prediction or ground-truth ply."""
|
|
from plyfile import PlyData
|
|
|
|
v = PlyData.read(str(path))["vertex"]
|
|
props = [p.name for p in v.properties]
|
|
|
|
xyz = np.stack([np.asarray(v["x"], dtype=np.float32),
|
|
np.asarray(v["y"], dtype=np.float32),
|
|
np.asarray(v["z"], dtype=np.float32)], axis=1)
|
|
|
|
if "label" in props:
|
|
lab = np.asarray(v["label"]).astype(np.int64)
|
|
# a placeholder label (all -1, or a single constant on an unlabelled
|
|
# tile) carries no information; fall through to the colours
|
|
if lab.min() >= 0 and len(np.unique(lab)) > 1:
|
|
return xyz, lab
|
|
if lab.min() >= 0 and "red" not in props and "r" not in props:
|
|
return xyz, lab
|
|
|
|
rgb_set = next((s for s in (("red", "green", "blue"), ("r", "g", "b"))
|
|
if all(c in props for c in s)), None)
|
|
if rgb_set is None:
|
|
raise SystemExit(f"{path}: no usable label field and no colour to decode")
|
|
|
|
rgb = np.stack([np.asarray(v[c]) for c in rgb_set], axis=1)
|
|
return xyz, decode_palette(rgb)
|
|
|
|
|
|
def read_colours(path, n: int) -> np.ndarray | None:
|
|
"""uint8 RGB from a ply, or None if it does not line up point-for-point."""
|
|
from plyfile import PlyData
|
|
|
|
v = PlyData.read(str(path))["vertex"]
|
|
props = [p.name for p in v.properties]
|
|
rgb_set = next((s for s in (("red", "green", "blue"), ("r", "g", "b"))
|
|
if all(c in props for c in s)), None)
|
|
if rgb_set is None or len(v) != n:
|
|
return None
|
|
|
|
rgb = np.stack([np.asarray(v[c]) for c in rgb_set], axis=1)
|
|
if rgb.dtype != np.uint8:
|
|
rgb = rgb.astype(np.float64)
|
|
if rgb.max() <= 1.0:
|
|
rgb = rgb * 255.0
|
|
rgb = np.clip(rgb, 0, 255).astype(np.uint8)
|
|
return rgb
|