Record results so far and fix the memory blowup in the palette decode

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>
This commit is contained in:
nbright
2026-08-24 09:07:31 +09:00
co-authored by Claude Opus 5
parent 3fdd7ab3f0
commit f39b093106
19 changed files with 1144 additions and 46 deletions
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# SUM Parts - wait for training to finish, then run every evaluation we care about
#
# Runs unattended so nobody has to sit watching for the last epoch:
# 1. wait for the trainer to exit
# 2. coarse evaluation on SUM val -> building / vegetation / vehicle / ground
# 3. inference on the Seosan tile -> does our own data work with this model
set -uo pipefail
SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT="$HOME/sum-parts/runs/after_train"
LOG="$OUT/after_train.log"
mkdir -p "$OUT"
exec > >(tee -a "$LOG") 2>&1
say() { echo "[$(date '+%F %T')] $*"; }
say "waiting for training to finish"
waited=0
while pgrep -f 'main.py --cfg' > /dev/null; do
sleep 30
waited=$((waited + 30))
[ $((waited % 300)) -eq 0 ] && say " still running (${waited}s)"
[ "$waited" -gt 7200 ] && { say " timed out after 2h"; break; }
done
say "trainer no longer running"
# also wait out the watchdog, so it does not relaunch under us
pkill -f train_watchdog.sh 2>/dev/null && say "stopped the watchdog"
sleep 5
echo
say "=== best checkpoint ==="
CKPT=$(find "$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation/log/sumv2_triangle" \
-name '*pointvector*_ckpt_best.pth' -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -1 | cut -d' ' -f2-)
say "$CKPT"
grep -ahE 'Best ckpt @E' "$HOME/sum-parts/runs/pointvector-xl_"*/train.log 2>/dev/null | tail -2
echo
say "=== 1/2 coarse evaluation on SUM val (4 classes) ==="
TEST_VOXEL_MAX=24000 bash "$SCRIPTS/eval_coarse.sh" || say "eval_coarse returned non-zero"
echo
say "=== 2/2 inference on the Seosan tile ==="
bash "$SCRIPTS/poc_infer.sh" || say "poc_infer returned non-zero"
bash "$SCRIPTS/poc_check_pred.sh" || say "poc_check_pred returned non-zero"
echo
say "AFTER TRAIN DONE -- log: $LOG"
+64 -29
View File
@@ -37,7 +37,9 @@ import sys
from pathlib import Path
import numpy as np
from plyfile import PlyData
sys.path.insert(0, str(Path(__file__).resolve().parent))
from sumparts_palette import read_point_classes # noqa: E402
FINE = ['unclassified', 'terrain', 'high_vegetation', 'facade_surface',
'water', 'car', 'boat', 'roof_surface', 'chimney', 'dormer',
@@ -73,28 +75,14 @@ COLOR_MAP = np.array([
def load_labels(path: Path) -> np.ndarray:
"""Fine class per point: from a `label` field, else decoded from colour."""
v = PlyData.read(str(path))["vertex"]
props = [p.name for p in v.properties]
"""Fine class per point.
if "label" in props:
lab = np.asarray(v["label"]).astype(np.int64).ravel()
# a prediction ply may carry a placeholder label; fall through if so
if lab.max() >= 0 and not (lab == lab[0]).all():
return lab
if lab.max() >= 0 and lab[0] >= 0:
return 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 label field and no colour to decode")
rgb = np.stack([np.asarray(v[c], dtype=np.float64) for c in rgb_set], axis=1)
if rgb.max() <= 1.0:
rgb *= 255.0
d = ((rgb[:, None, :] - COLOR_MAP[None, :, :]) ** 2).sum(axis=2)
return d.argmin(axis=1).astype(np.int64)
Delegates to sumparts_palette, whose colour decode avoids building an
(N, 13, 3) temporary -- that pattern needs ~250 MB of intermediates per
800k-point tile and takes the machine down on a full block.
"""
_, cls = read_point_classes(path)
return cls.astype(np.int64)
def confusion(pred: np.ndarray, true: np.ndarray, n: int) -> np.ndarray:
@@ -104,19 +92,24 @@ def confusion(pred: np.ndarray, true: np.ndarray, n: int) -> np.ndarray:
def report(cm: np.ndarray, names: list[str], skip: set[int]) -> None:
tp = np.diag(cm).astype(np.float64)
actual = cm.sum(axis=1).astype(np.float64)
predicted = cm.sum(axis=0).astype(np.float64)
actual = cm.sum(axis=1).astype(np.float64) # ground truth per class
predicted = cm.sum(axis=0).astype(np.float64) # predictions per class
union = actual + predicted - tp
print(f" {'class':<12} {'IoU':>7} {'recall':>8} {'points':>12}")
# precision matters as much as recall here, and for some jobs more.
# Stripping structures off a terrain model is precision-first: a hole where
# ground was missed can be interpolated, but a wall left sitting in the
# surface is a fake landform.
print(f" {'class':<12} {'IoU':>7} {'prec':>8} {'recall':>8} {'points':>12}")
ious = []
for i, name in enumerate(names):
if i in skip:
continue
iou = 100.0 * tp[i] / union[i] if union[i] > 0 else 0.0
rec = 100.0 * tp[i] / actual[i] if actual[i] > 0 else 0.0
iou = 100.0 * tp[i] / union[i] if union[i] > 0 else 0.0
prec = 100.0 * tp[i] / predicted[i] if predicted[i] > 0 else 0.0
rec = 100.0 * tp[i] / actual[i] if actual[i] > 0 else 0.0
ious.append(iou)
print(f" {name:<12} {iou:>6.2f}% {rec:>7.2f}% {int(actual[i]):>12,}")
print(f" {name:<12} {iou:>6.2f}% {prec:>7.2f}% {rec:>7.2f}% {int(actual[i]):>12,}")
scored = [i for i in range(len(names)) if i not in skip]
oa_tp = tp[scored].sum()
@@ -126,6 +119,44 @@ def report(cm: np.ndarray, names: list[str], skip: set[int]) -> None:
print(f" OA : {100.0 * oa_tp / oa_n if oa_n else 0:.2f}%")
def report_bare_earth(cm_fine: np.ndarray) -> None:
"""Ground vs everything else, the way a terrain model is actually judged.
Reported separately from the 4-class view because the failure modes are not
symmetric. Ground missed -> a hole, which interpolation fills. Non-ground
kept -> a retaining wall or a roof baked into the terrain, which nothing
downstream can tell from a real landform.
"""
ground = {1} # terrain
n = cm_fine.shape[0]
rest = [i for i in range(n) if i not in ground and i != 0]
tp = cm_fine[1, 1]
fp = cm_fine[np.ix_(rest, [1])].sum() # non-ground predicted as ground
fn = cm_fine[np.ix_([1], rest)].sum() # ground predicted as something else
prec = 100.0 * tp / (tp + fp) if tp + fp else 0.0
rec = 100.0 * tp / (tp + fn) if tp + fn else 0.0
iou = 100.0 * tp / (tp + fp + fn) if tp + fp + fn else 0.0
f1 = 2 * prec * rec / (prec + rec) if prec + rec else 0.0
print(f" ground kept correctly {int(tp):>12,}")
print(f" non-ground leaked in {int(fp):>12,} <- contaminates the surface")
print(f" ground missed {int(fn):>12,} <- holes, interpolable")
print()
print(f" precision : {prec:6.2f}% (of what we call ground, how much is)")
print(f" recall : {rec:6.2f}% (of real ground, how much we caught)")
print(f" IoU : {iou:6.2f}%")
print(f" F1 : {f1:6.2f}%")
print()
if fp:
print(" where the contamination comes from:")
leaks = [(int(cm_fine[i, 1]), FINE[i]) for i in rest if cm_fine[i, 1] > 0]
for cnt, name in sorted(leaks, reverse=True)[:8]:
print(f" {name:<20} {cnt:>10,} {100.0*cnt/fp:>6.2f}% of leak")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -171,9 +202,13 @@ def main() -> None:
report(cm_fine, FINE, skip={0})
print()
print("=== coarse (what this project needs) ===")
print("=== coarse (building / vegetation / vehicle / ground) ===")
report(cm_coarse, COARSE, skip={0})
print()
print("=== bare earth (ground vs everything else) ===")
report_bare_earth(cm_fine)
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Dump what a prediction PLY actually contains.
check_pred.py recovers the class by matching RGB against the palette. If that
reports one class for every point, the question is whether the model really
collapsed or whether the colour decode is wrong — so read the raw fields
instead of interpreting them.
Usage:
python dump_pred.py pred.ply
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
from plyfile import PlyData
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.),
])
def main() -> None:
if len(sys.argv) != 2:
print(__doc__)
raise SystemExit(2)
path = Path(sys.argv[1])
v = PlyData.read(str(path))["vertex"]
props = [p.name for p in v.properties]
print(f"file : {path.name}")
print(f"points: {len(v):,}")
print(f"props : {props}")
print()
if "label" in props:
lab = np.asarray(v["label"])
u, c = np.unique(lab, return_counts=True)
print(f"label field: dtype={lab.dtype}")
for k, n in sorted(zip(u.tolist(), c.tolist()), key=lambda t: -t[1]):
name = CLASSES[k] if 0 <= k < len(CLASSES) else f"?{k}"
print(f" {k:>3} {name:<20} {n:>10,} {100*n/len(lab):>6.2f}%")
print()
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:
rgb = np.stack([np.asarray(v[c]) for c in rgb_set], axis=1)
uniq, cnt = np.unique(rgb.reshape(-1, 3), axis=0, return_counts=True)
print(f"colour field {rgb_set}: dtype={rgb.dtype}, {len(uniq)} distinct")
order = np.argsort(-cnt)
for i in order[:15]:
col = uniq[i]
d = ((COLOR_MAP - col.astype(np.float64)) ** 2).sum(axis=1)
k = int(d.argmin())
exact = "exact" if d[k] < 1 else f"nearest (dist {np.sqrt(d[k]):.0f})"
name = CLASSES[k] if k < len(CLASSES) else f"?{k}"
print(f" {tuple(int(x) for x in col)!s:<20} {cnt[i]:>10,} "
f"{100*cnt[i]/len(rgb):>6.2f}% -> {name} ({exact})")
print()
if "label" in props and rgb_set:
lab = np.asarray(v["label"])
rgb = np.stack([np.asarray(v[c]) for c in rgb_set], axis=1)
d = ((rgb[:, None, :].astype(np.float64) - COLOR_MAP[None, :, :]) ** 2).sum(axis=2)
from_colour = d.argmin(axis=1)
agree = int((from_colour == lab).sum())
print(f"label vs colour agreement: {agree:,}/{len(lab):,} "
f"({100*agree/len(lab):.2f}%)")
if agree < len(lab):
print(" -> the two disagree; the label field is authoritative")
if __name__ == "__main__":
main()
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# SUM Parts - dump the raw contents of the newest Seosan prediction
set -uo pipefail
SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOGROOT="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation/log"
source "$HOME/miniconda3/etc/profile.d/conda.sh"
conda activate sumparts
PRED=$(find "$LOGROOT" -name 'seosan*_pred.ply' -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -1 | cut -d' ' -f2-)
if [ -z "$PRED" ]; then
echo "no seosan prediction found under $LOGROOT"
exit 1
fi
echo "path: $PRED"
echo
python "$SCRIPTS/dump_pred.py" "$PRED"
+5 -4
View File
@@ -20,9 +20,10 @@ export PYTORCH_CUDA_ALLOC_CONF="garbage_collection_threshold:0.7,max_split_size_
mkdir -p "$OUT"
cd "$SEG"
CKPT=$(find "$SEG/log/sumv2_triangle" -name '*_ckpt_best.pth' -printf '%T@ %p\n' \
| sort -rn | head -1 | cut -d' ' -f2-)
echo "checkpoint: $(basename "$CKPT")"
# The cfg has to match the architecture that wrote the checkpoint. Hardcoding
# pointnet.yaml here meant a PointVector checkpoint loaded into the wrong model:
# RuntimeError: Error(s) in loading state_dict for BaseSeg
source "$SCRIPTS/resolve_ckpt.sh"
# test() writes prediction plys and slides over whole tiles; point it at val so
# there is ground truth to score against.
@@ -40,7 +41,7 @@ VM_ARG=()
echo "=== inference over val split (sliding window) ==="
echo " test voxel_max: ${TEST_VOXEL_MAX:-null (cfg default)}"
python -u main.py \
--cfg ../../cfgs/sumv2_triangle/pointnet.yaml \
--cfg "../../cfgs/sumv2_triangle/${CKPT_CFG}.yaml" \
mode=test \
--pretrained_path "$CKPT" \
dataset.common.data_root="$DATA" \
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# SUM Parts - put the Seosan prediction somewhere it can be opened and looked at
#
# Two files, because they answer different questions:
# *_pred_viewer.ply class colours -> where did each class land
# *_rgb_viewer.ply photo texture -> what is actually there
#
# Open both, flip between them. That is how you find out whether the 21% the
# model calls water is asphalt, shadow, or something else entirely.
set -uo pipefail
SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOGROOT="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation/log/sumv2_triangle"
DEST="/mnt/d/MYCLAUDE_PROJECT/sum-parts-test/output"
source "$HOME/miniconda3/etc/profile.d/conda.sh"
conda activate sumparts
mkdir -p "$DEST"
PRED=$(find "$LOGROOT" -name 'seosan*_pred.ply' -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -1 | cut -d' ' -f2-)
[ -n "$PRED" ] || { echo "no seosan prediction found"; exit 1; }
echo "prediction : $PRED"
cp "$PRED" "$DEST/seosan_pred_viewer.ply"
echo " -> $DEST/seosan_pred_viewer.ply"
SRC="$HOME/sum-parts/data/korea_poc/seosan_BlockYBA_tile0.ply"
if [ -f "$SRC" ]; then
python "$SCRIPTS/ply_for_viewer.py" "$SRC" "$DEST/seosan_rgb_viewer.ply"
fi
echo
echo "=== class colour legend ==="
python - <<'PY'
CLASSES = ['unclassified', 'terrain', 'high_vegetation', 'facade_surface',
'water', 'car', 'boat', 'roof_surface', 'chimney', 'dormer',
'balcony', 'roof_installation', 'wall']
COLORS = [(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)]
for i, (n, c) in enumerate(zip(CLASSES, COLORS)):
print(f" {i:>2} {n:<20} RGB {c}")
PY
echo
ls -lh "$DEST"/seosan_*viewer.ply
+5 -5
View File
@@ -31,11 +31,11 @@ export PYTORCH_CUDA_ALLOC_CONF="garbage_collection_threshold:0.7,max_split_size_
mkdir -p "$OUT"
CKPT=$(find "$SEG/log/sumv2_triangle" -name '*_ckpt_best.pth' -printf '%T@ %p\n' \
| sort -rn | head -1 | cut -d' ' -f2-)
[ -n "$CKPT" ] || { echo "error: no checkpoint found" >&2; exit 1; }
# The cfg has to match the architecture that wrote the checkpoint; hardcoding
# one here loads the weights into the wrong model and torch raises on the
# state_dict.
source "$SCRIPTS/resolve_ckpt.sh"
echo "checkpoint: $CKPT"
echo "data : $DATA"
echo
@@ -46,7 +46,7 @@ run_mode() {
echo "=== mode=$mode ==="
set +e
python -u main.py \
--cfg ../../cfgs/sumv2_triangle/pointnet.yaml \
--cfg "../../cfgs/sumv2_triangle/${CKPT_CFG}.yaml" \
mode="$mode" \
--pretrained_path "$CKPT" \
dataset.common.data_root="$DATA" \
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# SUM Parts - measure what masking absent classes does to the Seosan prediction
#
# Runs inference twice on the same tile and the same checkpoint: once as-is,
# once with water and boat masked out of the argmax. The interesting number is
# where those 26% of points land when they can no longer be called water.
set -uo pipefail
SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT="$HOME/sum-parts/runs/mask_experiment"
mkdir -p "$OUT"
source "$HOME/miniconda3/etc/profile.d/conda.sh"
conda activate sumparts
# 4 = water, 6 = boat (openpoints/dataset/sumv2_triangle/sumv2_triangle.py)
MASK="${MASK:-4,6}"
run_and_dump() {
local tag="$1" maskval="$2"
echo
echo "════ $tag ════"
SUMPARTS_MASK_CLASSES="$maskval" bash "$SCRIPTS/poc_infer.sh" \
> "$OUT/infer_${tag}.log" 2>&1
local rc=$?
if [ $rc -ne 0 ]; then
echo " inference FAILED rc=$rc"
tail -12 "$OUT/infer_${tag}.log"
return 1
fi
grep -a 'masking classes' "$OUT/infer_${tag}.log" | head -1
bash "$SCRIPTS/dump_seosan_pred.sh" 2>&1 | tee "$OUT/dump_${tag}.txt" \
| sed -n '/label field/,/^$/p'
}
run_and_dump baseline ""
run_and_dump masked "$MASK"
echo
echo "════ comparison ════"
python - "$OUT/dump_baseline.txt" "$OUT/dump_masked.txt" <<'PY'
import re
import sys
from pathlib import Path
def parse(path):
out = {}
for line in Path(path).read_text(encoding="utf-8", errors="replace").splitlines():
m = re.match(r"\s+(\d+)\s+(\S+)\s+([\d,]+)\s+([\d.]+)%", line)
if m:
out[m.group(2)] = (int(m.group(3).replace(",", "")), float(m.group(4)))
return out
a, b = parse(sys.argv[1]), parse(sys.argv[2])
names = sorted(set(a) | set(b), key=lambda n: -max(a.get(n, (0,))[0], b.get(n, (0,))[0]))
print(f" {'class':<20} {'baseline':>12} {'masked':>12} {'change':>12}")
for n in names:
an, ap = a.get(n, (0, 0.0))
bn, bp = b.get(n, (0, 0.0))
d = bn - an
arrow = "" if d == 0 else (" <- absorbed" if d > 0 else "")
print(f" {n:<20} {ap:>10.2f}% {bp:>10.2f}% {d:>+11,}{arrow}")
PY
echo
echo "logs in $OUT"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# SUM Parts - let inference rule out classes we know are absent
#
# The network is a closed-set classifier: 13 logits, softmax, argmax. There is
# no "none of these". A point whose true class was never in the training
# vocabulary still gets a label - whichever learned concept sits closest in
# feature space.
#
# On the Seosan road tiles that produces 21% water and 5% boat. Neither exists
# there. What the model learned as "water" in Helsinki - dark, flat, smooth,
# horizontal - describes asphalt exactly, and boats are what sits on water, so
# the hallucination is internally consistent.
#
# We know those classes are absent. The model does not. Masking their logits
# before the argmax hands each of those points to its runner-up class instead.
#
# This is not a fix for the domain gap; it is telling the model something we
# already know. Whether it helps depends entirely on what the runner-up is,
# which is why it is worth measuring rather than assuming.
#
# Usage:
# bash scripts/patch_class_mask.sh
# SUMPARTS_MASK_CLASSES=4,6 <run inference> # 4=water, 6=boat
#
# Idempotent.
set -euo pipefail
source "$HOME/miniconda3/etc/profile.d/conda.sh"
conda activate sumparts
REPO="${1:-$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle}"
MAIN="$REPO/examples/segmentation/main.py"
[ -f "$MAIN" ] || { echo "error: $MAIN not found" >&2; exit 1; }
if grep -q 'SUMPARTS-CLASS-MASK' "$MAIN"; then
echo "already patched"
exit 0
fi
cp -n "$MAIN" "$MAIN.orig" 2>/dev/null || true
python - "$MAIN" <<'PY'
import sys
from pathlib import Path
p = Path(sys.argv[1])
src = p.read_text(encoding="utf-8")
old = """ pred = all_logits.argmax(dim=1)
if label is not None:
cm.update(pred, label)"""
new = """ # SUMPARTS-CLASS-MASK: drop classes we know cannot occur in this scene
# before taking the argmax, so their points fall through to the
# runner-up instead. Set SUMPARTS_MASK_CLASSES to a comma-separated
# list of class indices, e.g. "4,6" for water and boat.
_mask = os.environ.get('SUMPARTS_MASK_CLASSES', '').strip()
if _mask:
_idx = [int(x) for x in _mask.split(',') if x.strip() != '']
if cloud_idx == 0:
logging.info(f' masking classes {_idx} out of the argmax')
all_logits[:, _idx] = float('-inf')
pred = all_logits.argmax(dim=1)
if label is not None:
cm.update(pred, label)"""
if old not in src:
print("PATTERN NOT FOUND -- main.py differs from what this patch expects",
file=sys.stderr)
raise SystemExit(1)
p.write_text(src.replace(old, new), encoding="utf-8")
print("patched:", p)
PY
python -c "import ast,sys; ast.parse(open(sys.argv[1], encoding='utf-8').read())" "$MAIN" \
&& echo "syntax OK"
echo "PATCH DONE (original kept at $MAIN.orig)"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# SUM Parts - stop ConfusionMatrix.update from overwriting its caller's predictions
#
# openpoints/utils/metrics.py:
#
# if (true == self.ignore_index).sum() > 0:
# pred[true == self.ignore_index] = self.virtual_num_classes - 1
# true[true == self.ignore_index] = self.virtual_num_classes - 1
#
# Folding ignored points into the last bucket is fine for scoring, but it is
# done in place on the tensors the caller passed in. main.py's test() calls
# cm.update(pred, label) and *then* writes the visualization from that same
# pred, so the file on disk is the mutated copy, not what the model predicted.
#
# On a tile labelled entirely with the ignore class - which is exactly what an
# unlabelled tile of our own data looks like, label=0 with ignore_index=0 -
# every single point gets rewritten to class num_classes-1 (wall). The
# prediction file then reads 100% wall no matter what the network actually
# said, and the model looks degenerate when it is not.
#
# Fix: clone before masking. Scoring is unchanged; the caller's tensors survive.
#
# Idempotent.
set -euo pipefail
source "$HOME/miniconda3/etc/profile.d/conda.sh"
conda activate sumparts
REPO="${1:-$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle}"
METRICS="$REPO/openpoints/utils/metrics.py"
[ -f "$METRICS" ] || { echo "error: $METRICS not found" >&2; exit 1; }
if grep -q 'SUMPARTS-NO-MUTATE' "$METRICS"; then
echo "already patched"
exit 0
fi
cp -n "$METRICS" "$METRICS.orig" 2>/dev/null || true
python - "$METRICS" <<'PY'
import sys
from pathlib import Path
p = Path(sys.argv[1])
src = p.read_text(encoding="utf-8")
old = """ true = true.flatten()
pred = pred.flatten()
if self.ignore_index is not None:
if (true == self.ignore_index).sum() > 0:
pred[true == self.ignore_index] = self.virtual_num_classes -1
true[true == self.ignore_index] = self.virtual_num_classes -1"""
new = """ # SUMPARTS-NO-MUTATE: clone before masking. These used to be written
# in place, which silently rewrote the caller's prediction tensor --
# main.py's test() saves its visualization from the same `pred` right
# after calling this, so the file on disk showed the folded values
# rather than the model's output. On a tile labelled entirely with the
# ignore class every point came out as num_classes-1.
true = true.flatten().clone()
pred = pred.flatten().clone()
if self.ignore_index is not None:
if (true == self.ignore_index).sum() > 0:
pred[true == self.ignore_index] = self.virtual_num_classes -1
true[true == self.ignore_index] = self.virtual_num_classes -1"""
if old not in src:
print("PATTERN NOT FOUND -- metrics.py differs from what this patch expects",
file=sys.stderr)
raise SystemExit(1)
p.write_text(src.replace(old, new), encoding="utf-8")
print("patched:", p)
PY
python -c "import ast,sys; ast.parse(open(sys.argv[1], encoding='utf-8').read())" "$METRICS" \
&& echo "syntax OK"
echo "PATCH DONE (original kept at $METRICS.orig)"
+4 -1
View File
@@ -10,7 +10,10 @@ SCRIPTS="/mnt/d/MYCLAUDE_PROJECT/sum-parts-test/scripts"
source "$CONDA_ROOT/etc/profile.d/conda.sh"
conda activate "$ENV_NAME"
PRED=$(find "$LOGROOT" -name '*_pred.ply' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2-)
# Match the Seosan tile by name. Taking simply the newest *_pred.ply picks up
# whatever the last SUM evaluation wrote instead.
PATTERN="${PRED_PATTERN:-*seosan*_pred.ply}"
PRED=$(find "$LOGROOT" -name "$PATTERN" -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2-)
if [ -z "$PRED" ]; then
echo "error: no *_pred.ply found under $LOGROOT" >&2
exit 1
+5 -5
View File
@@ -32,16 +32,16 @@ for split in train val test; do
done
rm -rf "$TRACK/processed"
CKPT=$(find "$SEG/log/sumv2_triangle" -name '*_ckpt_best.pth' -printf '%T@ %p\n' \
| sort -rn | head -1 | cut -d' ' -f2-)
[ -n "$CKPT" ] || { echo "error: no checkpoint found -- run smoke_train.sh first" >&2; exit 1; }
echo "checkpoint: $CKPT"
# The cfg has to match the architecture that wrote the checkpoint; hardcoding
# one here loads the weights into the wrong model and torch raises on the
# state_dict.
source "/mnt/d/MYCLAUDE_PROJECT/sum-parts-test/scripts/resolve_ckpt.sh"
cd "$SEG"
set +e
python -u main.py \
--cfg ../../cfgs/sumv2_triangle/pointnet.yaml \
--cfg "../../cfgs/sumv2_triangle/${CKPT_CFG}.yaml" \
mode=test \
--pretrained_path "$CKPT" \
dataset.common.data_root="$TRACK" \
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# SUM Parts - re-score existing predictions without re-running inference
#
# The prediction plys are already on disk; only the scoring changed. No GPU,
# takes seconds.
set -uo pipefail
SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DATA="$HOME/sum-parts/data/face_labeling/texsp_pcl"
LOGROOT="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation/log/sumv2_triangle"
OUT="$HOME/sum-parts/runs/coarse_eval"
source "$HOME/miniconda3/etc/profile.d/conda.sh"
conda activate sumparts
mkdir -p "$OUT"
VIS=$(find "$LOGROOT" -type d -name visualization -printf '%T@ %p\n' \
| sort -rn | head -1 | cut -d' ' -f2-)
[ -n "$VIS" ] || { echo "no visualization directory found"; exit 1; }
echo "predictions: $VIS"
echo "ground truth: $DATA/val"
echo
python "$SCRIPTS/coarse_eval.py" --pred-dir "$VIS" --gt-dir "$DATA/val" \
| tee "$OUT/coarse.txt"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# SUM Parts - find the newest checkpoint and the cfg that matches it
#
# The eval scripts used to hardcode pointnet.yaml. Point them at a PointVector
# checkpoint and the weights load into the wrong architecture:
# RuntimeError: Error(s) in loading state_dict for BaseSeg
#
# main.py bakes the model name into the run directory, so the cfg can be read
# back out of the checkpoint path instead of guessed.
#
# Source it, don't run it:
# source scripts/resolve_ckpt.sh # newest checkpoint of any model
# CKPT_MATCH=pointvector source scripts/resolve_ckpt.sh
#
# Sets: CKPT, CKPT_CFG, CKPT_NAME
LOGROOT="$HOME/sum-parts/semantic_segmentation/PointNeXt_bundle/examples/segmentation/log/sumv2_triangle"
CKPT_MATCH="${CKPT_MATCH:-}"
if [ -n "$CKPT_MATCH" ]; then
CKPT=$(find "$LOGROOT" -name "*${CKPT_MATCH}*_ckpt_best.pth" -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -1 | cut -d' ' -f2-)
else
CKPT=$(find "$LOGROOT" -name '*_ckpt_best.pth' -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -1 | cut -d' ' -f2-)
fi
if [ -z "$CKPT" ]; then
echo "resolve_ckpt: no checkpoint found under $LOGROOT" >&2
return 1 2>/dev/null || exit 1
fi
CKPT_NAME=$(basename "$CKPT")
# run names look like:
# sumv2_triangle-train-<model>-ngpus1-<stamp>-<uuid>_ckpt_best.pth
# longest names first so pointnet++msg wins over pointnet
CKPT_CFG=""
for m in pointvector-xl pointnext-xl "pointnet++msg" pointnet; do
case "$CKPT_NAME" in
*"-${m}-"*) CKPT_CFG="$m"; break ;;
esac
done
if [ -z "$CKPT_CFG" ]; then
echo "resolve_ckpt: cannot tell which model wrote $CKPT_NAME" >&2
return 1 2>/dev/null || exit 1
fi
export CKPT CKPT_CFG CKPT_NAME
echo "checkpoint: $CKPT_NAME"
echo "cfg : ${CKPT_CFG}.yaml"
+124
View File
@@ -0,0 +1,124 @@
#!/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()
+131
View File
@@ -0,0 +1,131 @@
#!/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
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# SUM Parts - prove the exported files are actually readable
#
# "I made you some files" is worth nothing if they do not open. This reads every
# one back with a parser and reports what a viewer will find inside.
set -uo pipefail
ROOT="${1:-/mnt/d/AI_Test/sum-part}"
source "$HOME/miniconda3/etc/profile.d/conda.sh"
conda activate sumparts
python - "$ROOT" <<'PY'
import sys
from pathlib import Path
import numpy as np
from plyfile import PlyData
root = Path(sys.argv[1])
print(f"root: {root}\n")
print(f" {'folder':<10} {'file':<6} {'points':>10} {'properties':<34} {'extent (m)'}")
ok = True
for d in sorted(p for p in root.iterdir() if p.is_dir()):
for f in sorted(d.glob("*.ply")):
try:
v = PlyData.read(str(f))["vertex"]
props = ",".join(p.name for p in v.properties)
xyz = np.stack([v["x"], v["y"], v["z"]], axis=1)
ext = np.round(xyz.max(0) - xyz.min(0), 1)
print(f" {d.name:<10} {'ply':<6} {len(v):>10,} {props:<34} {ext.tolist()}")
except Exception as e:
ok = False
print(f" {d.name:<10} {'ply':<6} UNREADABLE: {type(e).__name__}: {e}")
for f in sorted(d.glob("*.obj")):
n = sum(1 for line in f.open(encoding="utf-8") if line.startswith("v "))
faces = sum(1 for line in f.open(encoding="utf-8") if line.startswith("f "))
note = "" if faces else " <- no faces; most viewers show nothing"
print(f" {d.name:<10} {'obj':<6} {n:>10,} {'v x y z r g b':<34} {faces} faces{note}")
print()
print("ALL PLY READABLE" if ok else "SOME FILES FAILED TO PARSE")
PY