Simam3D-GPU / evaluate_pointcloud.py
junaid-simamdigital's picture
eval: add point cloud quality metrics
baef0f8 verified
Raw History Blame Contribute Delete
3.78 kB
"""Evaluate exported Simam3D points against a reference point cloud."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
def load_points(path: str | Path) -> np.ndarray:
"""Load Nx3 points from NumPy `.npy` or ASCII PLY files."""
path = Path(path)
if path.suffix.lower() == ".npy":
points = np.load(path)
elif path.suffix.lower() == ".ply":
lines = path.read_text(encoding="utf-8").splitlines()
try:
end = lines.index("end_header")
count_line = next(line for line in lines[:end] if line.startswith("element vertex "))
count = int(count_line.split()[-1])
if not any(line.startswith("format ascii") for line in lines[:end]):
raise ValueError("only ASCII PLY is supported")
points = np.asarray([[float(value) for value in line.split()[:3]] for line in lines[end + 1:end + 1 + count]], dtype=np.float64)
except (StopIteration, ValueError, IndexError) as exc:
raise ValueError(f"invalid ASCII PLY point cloud: {path}") from exc
else:
raise ValueError("point cloud must be .npy or ASCII .ply")
points = np.asarray(points, dtype=np.float64)
if points.ndim != 2 or points.shape[1] < 3:
raise ValueError("point cloud must have shape Nx3")
points = points[:, :3]
points = points[np.isfinite(points).all(axis=1)]
if not len(points):
raise ValueError("point cloud has no finite points")
return points
def _nearest_distances(source: np.ndarray, target: np.ndarray, chunk_size: int = 4096) -> np.ndarray:
distances = []
for start in range(0, len(source), chunk_size):
chunk = source[start:start + chunk_size]
squared = np.sum((chunk[:, None, :] - target[None, :, :]) ** 2, axis=2)
distances.append(np.sqrt(np.min(squared, axis=1)))
return np.concatenate(distances)
def pointcloud_metrics(prediction: np.ndarray, reference: np.ndarray, threshold: float = 0.05) -> dict[str, float | int]:
"""Return symmetric Chamfer, precision, recall, and F-score."""
if threshold <= 0:
raise ValueError("threshold must be positive")
prediction = np.asarray(prediction, dtype=np.float64)
reference = np.asarray(reference, dtype=np.float64)
if prediction.ndim != 2 or reference.ndim != 2 or prediction.shape[1] != 3 or reference.shape[1] != 3:
raise ValueError("prediction and reference must have shape Nx3")
pred_to_ref = _nearest_distances(prediction, reference)
ref_to_pred = _nearest_distances(reference, prediction)
precision = float(np.mean(pred_to_ref <= threshold))
recall = float(np.mean(ref_to_pred <= threshold))
fscore = 2.0 * precision * recall / max(precision + recall, 1e-12)
return {
"prediction_point_count": int(len(prediction)),
"reference_point_count": int(len(reference)),
"threshold": float(threshold),
"chamfer_l1": float((pred_to_ref.mean() + ref_to_pred.mean()) / 2.0),
"prediction_to_reference_mean": float(pred_to_ref.mean()),
"reference_to_prediction_mean": float(ref_to_pred.mean()),
"precision": precision,
"recall": recall,
"fscore": float(fscore),
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("prediction", type=Path)
parser.add_argument("reference", type=Path)
parser.add_argument("--threshold", type=float, default=0.05)
args = parser.parse_args(argv)
print(json.dumps(pointcloud_metrics(load_points(args.prediction), load_points(args.reference), args.threshold), indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())