"""Prepare a bounded RGB/XYZ PLY preview for manual point-cloud annotation.
|
|
This deliberately does not create raster, vector, mesh, or semantic outputs.
|
Those products require a spatial cell size and are unrelated to a manual
|
annotation source.
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
import hashlib
|
import json
|
import time
|
from datetime import UTC, datetime
|
from pathlib import Path
|
|
import laspy
|
import numpy as np
|
import open3d as o3d
|
|
|
SUPPORTED_SUFFIXES = {".ply", ".pcd", ".xyz", ".xyzn", ".xyzrgb", ".las", ".laz"}
|
|
|
def file_sha256(path: Path) -> str:
|
digest = hashlib.sha256()
|
with path.open("rb") as stream:
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
digest.update(chunk)
|
return digest.hexdigest()
|
|
|
def read_cloud(path: Path) -> tuple[np.ndarray, np.ndarray, bool]:
|
if path.suffix.lower() in {".las", ".laz"}:
|
source = laspy.read(path)
|
dimensions = set(source.point_format.dimension_names)
|
xyz = np.column_stack((source.x, source.y, source.z)).astype(np.float64)
|
has_rgb = {"red", "green", "blue"}.issubset(dimensions)
|
if has_rgb:
|
raw_rgb = np.column_stack((source.red, source.green, source.blue)).astype(np.float64)
|
divisor = 65_535.0 if float(np.nanpercentile(raw_rgb, 99.5)) > 255.0 else 255.0
|
rgb = np.clip(raw_rgb / divisor, 0.0, 1.0)
|
else:
|
rgb = np.full((len(xyz), 3), 0.72, dtype=np.float64)
|
else:
|
cloud = o3d.io.read_point_cloud(str(path))
|
if cloud.is_empty():
|
raise ValueError("Point cloud has no readable XYZ vertices.")
|
xyz = np.asarray(cloud.points, dtype=np.float64)
|
has_rgb = cloud.has_colors()
|
rgb = np.asarray(cloud.colors, dtype=np.float64) if has_rgb else np.full((len(xyz), 3), 0.72, dtype=np.float64)
|
valid = np.isfinite(xyz).all(axis=1) & np.isfinite(rgb).all(axis=1)
|
xyz, rgb = xyz[valid], np.clip(rgb[valid], 0.0, 1.0)
|
if len(xyz) < 50:
|
raise ValueError("Point cloud needs at least 50 finite RGB/XYZ points.")
|
return xyz, rgb, has_rgb
|
|
|
def main() -> int:
|
parser = argparse.ArgumentParser(description=__doc__)
|
parser.add_argument("--input", type=Path, required=True)
|
parser.add_argument("--output", type=Path, required=True)
|
args = parser.parse_args()
|
if args.input.suffix.lower() not in SUPPORTED_SUFFIXES:
|
raise SystemExit(f"Unsupported point-cloud type: {args.input.suffix or '(none)'}.")
|
if not args.input.is_file():
|
raise SystemExit("Input point-cloud file is unavailable.")
|
if args.output.exists() and any(args.output.iterdir()):
|
raise SystemExit("Output directory is not empty; use a new annotation-source run directory.")
|
args.output.mkdir(parents=True, exist_ok=True)
|
|
started = time.perf_counter()
|
xyz, rgb, has_rgb = read_cloud(args.input)
|
preview = o3d.geometry.PointCloud()
|
preview.points = o3d.utility.Vector3dVector(xyz)
|
preview.colors = o3d.utility.Vector3dVector(rgb)
|
preview_name = f"{args.input.stem}.annotation-source.ply"
|
preview_path = args.output / preview_name
|
if not o3d.io.write_point_cloud(str(preview_path), preview, write_ascii=False):
|
raise SystemExit("Could not write annotation preview PLY.")
|
metadata = {
|
"capability": "05-3d-pointcloud",
|
"classification": "B",
|
"created_at": datetime.now(UTC).isoformat(),
|
"annotation_source_job": True,
|
"annotation_source": {
|
"schema_version": 1,
|
"file": preview_name,
|
"point_count": int(len(xyz)),
|
"sha256": file_sha256(preview_path),
|
"kind": "complete readable RGB/XYZ point cloud; no raster, vector, mesh, or semantic labels",
|
},
|
"input": {"file": args.input.name, "points": int(len(xyz)), "has_rgb": has_rgb},
|
"method": "Open3D/Laspy complete RGB-XYZ point-cloud read without display sampling",
|
"device": "CPU",
|
"elapsed_seconds": round(time.perf_counter() - started, 3),
|
"limitations": [
|
"This output is a manual annotation preview only; it is not a DSM, DEM, mesh, vector layer, or semantic classification.",
|
"The output retains every readable finite source point. Large point clouds require correspondingly more browser memory and GPU resources during direct rendering.",
|
] + ([] if has_rgb else ["The source has no readable vertex RGB values. The neutral preview is viewable and labelable for geometry review, but it cannot be used for the RGB semantic-model trainer."]),
|
}
|
(args.output / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
|
print(json.dumps(metadata, ensure_ascii=False))
|
return 0
|
|
|
if __name__ == "__main__":
|
raise SystemExit(main())
|