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:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
@@ -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" \
|
||||
|
||||
@@ -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"
|
||||
@@ -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)"
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user