diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f2d632..4d63331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,15 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`). ### Added +- **Epic 148A/148B point-cloud conformance harness**: a versioned point-cloud + benchmark manifest (`spatialrust.pointcloud-benchmark-manifest.v1`) and a + stdlib-only report contract (`spatialrust.pointcloud-comparison.v1`) with + seven contract tests, plus a PDAL comparison runner + (`bench/pdal_comparison/`). `bench_ops` now also times `translate_xyz` + behind `transform-ops` to match PDAL's `filters.transformation`. A dated, + honest SpatialRust-vs-PCL receipt (`bench/pcl_comparison/receipt-2026-08-07.json`) + records voxel 1.70×, normals 4.56×, SOR 4.91×, and radius-outlier 6.62× on + the identical public 460,400-point cloud. - **Epic 147F Python 3D Tiles bindings**: `export_tiles3d` converts a `PointCloud` into a tileset directory and `export_copc_tiles3d` converts a `.copc.laz` file into a bounded tileset, both returning a diff --git a/bench/pcl_comparison/README.md b/bench/pcl_comparison/README.md index 02a65a4..7f9dba9 100644 --- a/bench/pcl_comparison/README.md +++ b/bench/pcl_comparison/README.md @@ -37,16 +37,17 @@ that fallback also needs NumPy and the SpatialRust Python extension. ## Indicative results -Measured on one Windows machine (PCL 1.15.1 via vcpkg, MSYS2 g++ 16.1.0, -release builds, 460,400-point public PCL `table_scene_lms400.pcd`). Throughput -varies by CPU; run it yourself for numbers on your hardware. +Measured on one Linux machine (PCL 1.14.x via libpcl-dev, g++ 16, release +Rust build, 460,400-point public PCL `table_scene_lms400.pcd`, single-threaded +where that is the default). Throughput varies by CPU and PCL build; run it +yourself for numbers on your hardware. Dated receipt: `receipt-2026-08-07.json`. | Operation | SpatialRust | PCL | Speedup | -| --- | ---: | ---: | ---: | -| Radius Outlier Removal | 0.0899 s | 1.8784 s | **20.89× faster** | -| Statistical Outlier Removal | 0.1664 s | 2.0933 s | **12.58× faster** | -| Normal estimation | 0.1461 s | 1.9750 s | **13.52× faster** | -| Voxel downsample | 0.0104 s | 0.0181 s | **1.74× faster** | +| --- | ---: | ---: | :--- | +| Voxel downsample | 0.0104 s | 0.0177 s | **1.70× faster** | +| Normal estimation | 0.2171 s | 0.9893 s | **4.56× faster** | +| Statistical Outlier Removal | 0.2297 s | 1.1272 s | **4.91× faster** | +| Radius Outlier Removal | 0.1088 s | 0.7200 s | **6.62× faster** | SpatialRust is faster on neighborhood-statistics and density operations (radius outlier removal uses an early-exit density test; normals and SOR win too; voxel diff --git a/bench/pcl_comparison/manifest.json b/bench/pcl_comparison/manifest.json new file mode 100644 index 0000000..cd71656 --- /dev/null +++ b/bench/pcl_comparison/manifest.json @@ -0,0 +1,37 @@ +{ + "schema_version": "spatialrust.pointcloud-benchmark-manifest.v1", + "profiles": { + "small": { "points": 460400, "label": "public PCL table_scene_lms400" }, + "full": { "points": 2000000, "label": "synthetic room scene" } + }, + "required_statistics": [ + "mean", + "median", + "p95", + "min", + "max", + "stdev", + "coefficient_of_variation", + "median_absolute_deviation", + "batch_size", + "samples" + ], + "publication_receipt": [ + "platform", + "machine", + "logical_cpu_count", + "python_version", + "pcl_version", + "pdal_version", + "open3d_version", + "spatialrust_version" + ], + "workloads": [ + { "id": "voxel_downsample", "domain": "filtering", "libraries": ["pcl", "pdal", "open3d"] }, + { "id": "normal_estimation", "domain": "features", "libraries": ["pcl", "open3d"] }, + { "id": "statistical_outlier_removal", "domain": "filtering", "libraries": ["pcl", "open3d"] }, + { "id": "radius_outlier_removal", "domain": "filtering", "libraries": ["pcl", "open3d"] }, + { "id": "translate_xyz", "domain": "io-transform", "libraries": ["pdal"] }, + { "id": "reprojection", "domain": "io-transform", "libraries": ["pdal"] } + ] +} diff --git a/bench/pcl_comparison/receipt-2026-08-07.json b/bench/pcl_comparison/receipt-2026-08-07.json new file mode 100644 index 0000000..0802313 --- /dev/null +++ b/bench/pcl_comparison/receipt-2026-08-07.json @@ -0,0 +1,61 @@ +{ + "environment": { + "logical_cpu_count": 8, + "machine": "x86_64", + "open3d_version": null, + "pcl_version": "1.14.0 (libpcl-dev)", + "pdal_version": null, + "platform": "Linux-7.0.0-28-generic-x86_64-with-glibc2.39", + "processor": "x86_64", + "python_implementation": "CPython", + "python_version": "3.12.3", + "spatialrust_version": "1.2.0" + }, + "generated_at": "2026-08-07T08:11:24.924875+00:00", + "kind": "performance", + "results": { + "input": { + "name": "table_scene_lms400.pcd", + "points": 460400, + "source": "https://github.com/PointCloudLibrary/data/blob/master/tutorials/table_scene_lms400.pcd" + }, + "note": "honest single-run numbers; rerun on target hardware before portability claims", + "operations": [ + { + "id": "voxel_downsample", + "output_points_pcl": 2215, + "output_points_sr": 2194, + "pcl_seconds": 0.0177, + "spatialrust_seconds": 0.0104, + "speedup": 1.7 + }, + { + "id": "normal_estimation", + "output_points_pcl": 460400, + "output_points_sr": 460400, + "pcl_seconds": 0.9893, + "spatialrust_seconds": 0.2171, + "speedup": 4.56 + }, + { + "id": "statistical_outlier_removal", + "output_points_pcl": 451983, + "output_points_sr": 451983, + "pcl_seconds": 1.1272, + "spatialrust_seconds": 0.2297, + "speedup": 4.91 + }, + { + "id": "radius_outlier_removal", + "output_points_pcl": 460379, + "output_points_sr": 460379, + "pcl_seconds": 0.72, + "spatialrust_seconds": 0.1088, + "speedup": 6.62 + } + ] + }, + "schema_version": "spatialrust.pointcloud-comparison.v1", + "status": "pass", + "suite": "pcl_comparison" +} diff --git a/bench/pcl_comparison/report.py b/bench/pcl_comparison/report.py new file mode 100644 index 0000000..13ad2fb --- /dev/null +++ b/bench/pcl_comparison/report.py @@ -0,0 +1,209 @@ +"""Machine-readable report contract for point-cloud comparison harnesses. + +This module deliberately depends only on the Python standard library so its +contract tests can run without building SpatialRust or installing PCL / PDAL / +Open3D. It mirrors the OpenCV comparison contract but names point-cloud +libraries and workloads, and records output point counts for correctness +equivalence. +""" + +from __future__ import annotations + +import gc +import json +import math +import os +import platform +import statistics +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, Iterable, TypeVar + + +SCHEMA_VERSION = "spatialrust.pointcloud-comparison.v1" +T = TypeVar("T") + + +def environment( + *, + pcl_version: str | None, + pdal_version: str | None, + open3d_version: str | None, + spatialrust_version: str, +) -> dict[str, object]: + """Return the minimum environment receipt required for publication.""" + + return { + "python_version": platform.python_version(), + "python_implementation": platform.python_implementation(), + "platform": platform.platform(), + "machine": platform.machine(), + "processor": platform.processor() or None, + "logical_cpu_count": os.cpu_count(), + "pcl_version": pcl_version, + "pdal_version": pdal_version, + "open3d_version": open3d_version, + "spatialrust_version": spatialrust_version, + } + + +def percentile(values: Iterable[float], quantile: float) -> float: + samples = sorted(values) + if not samples: + raise ValueError("at least one timing sample is required") + if not 0.0 <= quantile <= 1.0: + raise ValueError("quantile must be in [0, 1]") + index = (len(samples) - 1) * quantile + lower = int(index) + upper = min(lower + 1, len(samples) - 1) + fraction = index - lower + return samples[lower] * (1.0 - fraction) + samples[upper] * fraction + + +def timed(call: Callable[[], T], *, warmup: int, repeats: int) -> tuple[T, dict[str, object]]: + """Benchmark a callable and return its last result plus timing statistics.""" + + if warmup < 0 or repeats < 1: + raise ValueError("warmup must be non-negative and repeats must be positive") + for _ in range(warmup): + call() + samples_ms: list[float] = [] + result: T | None = None + gc_enabled = gc.isenabled() + gc.disable() + try: + for _ in range(repeats): + start = time.perf_counter_ns() + result = call() + samples_ms.append((time.perf_counter_ns() - start) / 1_000_000.0) + finally: + if gc_enabled: + gc.enable() + return result, timing_statistics(samples_ms, warmup=warmup) + + +def timing_statistics( + samples_ms: list[float], *, warmup: int, batch_size: int = 1 +) -> dict[str, object]: + """Summarize raw millisecond samples with robust dispersion statistics.""" + + if not samples_ms: + raise ValueError("at least one timing sample is required") + median = statistics.median(samples_ms) + deviations = [abs(value - median) for value in samples_ms] + mean = statistics.fmean(samples_ms) + stdev = statistics.stdev(samples_ms) if len(samples_ms) > 1 else 0.0 + return { + "unit": "ms", + "warmup": warmup, + "repeats": len(samples_ms), + "batch_size": batch_size, + "mean": mean, + "median": median, + "p95": percentile(samples_ms, 0.95), + "min": min(samples_ms), + "max": max(samples_ms), + "stdev": stdev, + "coefficient_of_variation": stdev / mean if mean > 0.0 else 0.0, + "median_absolute_deviation": statistics.median(deviations), + "samples": samples_ms, + } + + +def make_report( + *, + suite: str, + kind: str, + status: str, + environment_receipt: dict[str, object], + results: object, +) -> dict[str, object]: + if kind not in {"correctness", "performance", "aggregate"}: + raise ValueError(f"unsupported report kind: {kind}") + if status not in {"pass", "fail"}: + raise ValueError(f"unsupported report status: {status}") + return { + "schema_version": SCHEMA_VERSION, + "suite": suite, + "kind": kind, + "status": status, + "generated_at": datetime.now(timezone.utc).isoformat(), + "environment": environment_receipt, + "results": results, + } + + +def emit_report(report: dict[str, object], output: Path | None = None) -> None: + errors = validate_report(report) + if errors: + raise ValueError(f"invalid report: {'; '.join(errors)}") + encoded = json.dumps(report, indent=2, sort_keys=True, allow_nan=False) + if output is not None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(encoded + "\n", encoding="utf-8") + print(encoded) + + +def validate_report(report: object) -> list[str]: + """Validate the stable structural contract without third-party packages.""" + + errors: list[str] = [] + if not isinstance(report, dict): + return ["report must be a JSON object"] + + def find_non_finite(value: object, path: str) -> None: + if isinstance(value, float) and not math.isfinite(value): + errors.append(f"{path} must be finite") + elif isinstance(value, dict): + for key, child in value.items(): + find_non_finite(child, f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + find_non_finite(child, f"{path}[{index}]") + + find_non_finite(report, "report") + required = { + "schema_version", + "suite", + "kind", + "status", + "generated_at", + "environment", + "results", + } + missing = sorted(required - report.keys()) + if missing: + errors.append(f"missing keys: {', '.join(missing)}") + if report.get("schema_version") != SCHEMA_VERSION: + errors.append(f"schema_version must be {SCHEMA_VERSION}") + if report.get("kind") not in {"correctness", "performance", "aggregate"}: + errors.append("kind must be correctness, performance, or aggregate") + if report.get("status") not in {"pass", "fail"}: + errors.append("status must be pass or fail") + environment_value = report.get("environment") + if not isinstance(environment_value, dict): + errors.append("environment must be an object") + else: + for key in ( + "platform", + "machine", + "logical_cpu_count", + "python_version", + "pcl_version", + "pdal_version", + "open3d_version", + "spatialrust_version", + ): + if key not in environment_value: + errors.append(f"environment missing {key}") + return errors + + +def load_report(path: Path) -> dict[str, object]: + with path.open("r", encoding="utf-8") as handle: + value = json.load(handle) + errors = validate_report(value) + if errors: + raise ValueError(f"invalid report {path}: {'; '.join(errors)}") + return value diff --git a/bench/pcl_comparison/test_report.py b/bench/pcl_comparison/test_report.py new file mode 100755 index 0000000..182eebb --- /dev/null +++ b/bench/pcl_comparison/test_report.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Contract tests for the point-cloud comparison report schema. + +Deliberately stdlib-only so CI can gate the report contract without PCL/PDAL. +Run with: + python bench/pcl_comparison/test_report.py +""" + +from __future__ import annotations + +import json +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).parent +sys.path.insert(0, str(HERE)) + +from report import ( # noqa: E402 + SCHEMA_VERSION, + emit_report, + environment, + load_report, + make_report, + percentile, + timing_statistics, + validate_report, +) + + +def test_schema_version_is_canonical(): + manifest = json.loads((HERE / "manifest.json").read_text(encoding="utf-8")) + assert manifest["schema_version"] == "spatialrust.pointcloud-benchmark-manifest.v1" + assert SCHEMA_VERSION == "spatialrust.pointcloud-comparison.v1" + + +def test_manifest_has_canonical_profiles_and_workloads(): + manifest = json.loads((HERE / "manifest.json").read_text(encoding="utf-8")) + assert {"small", "full"} <= set(manifest["profiles"]) + ids = [w["id"] for w in manifest["workloads"]] + assert "voxel_downsample" in ids + assert "normal_estimation" in ids + assert "statistical_outlier_removal" in ids + assert "radius_outlier_removal" in ids + assert "translate_xyz" in ids + assert "reprojection" in ids + + +def test_valid_report_passes(): + report = make_report( + suite="pcl_comparison", + kind="performance", + status="pass", + environment_receipt=environment( + pcl_version="1.15.1", + pdal_version=None, + open3d_version=None, + spatialrust_version="1.2.0", + ), + results={"workloads": []}, + ) + assert validate_report(report) == [] + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "receipt.json" + emit_report(report, output=path) + assert load_report(path)["suite"] == "pcl_comparison" + + +def test_missing_environment_key_fails(): + report = make_report( + suite="pdal_comparison", + kind="performance", + status="pass", + environment_receipt=environment( + pcl_version=None, + pdal_version="2.6", + open3d_version=None, + spatialrust_version="1.2.0", + ), + results={}, + ) + del report["environment"]["pcl_version"] + errors = validate_report(report) + assert any("missing" in error for error in errors) + assert any("pcl_version" in error for error in errors) + + +def test_wrong_schema_version_fails(): + report = make_report( + suite="pcl_comparison", + kind="performance", + status="pass", + environment_receipt=environment( + pcl_version="1.15.1", + pdal_version=None, + open3d_version=None, + spatialrust_version="1.2.0", + ), + results={}, + ) + report["schema_version"] = "spatialrust.opencv-comparison.v1" + assert any("schema_version" in error for error in validate_report(report)) + + +def test_non_finite_values_fail(): + report = make_report( + suite="pcl_comparison", + kind="performance", + status="pass", + environment_receipt=environment( + pcl_version="1.15.1", + pdal_version=None, + open3d_version=None, + spatialrust_version="1.2.0", + ), + results={"mean": float("inf")}, + ) + assert any("must be finite" in error for error in validate_report(report)) + + +def test_timing_statistics_and_percentile(): + stats = timing_statistics([10.0, 11.0, 12.0, 13.0, 14.0], warmup=2) + assert stats["median"] == 12.0 + assert stats["mean"] == 12.0 + assert abs(stats["p95"] - 13.8) < 1e-9 + assert stats["samples"] == [10.0, 11.0, 12.0, 13.0, 14.0] + assert abs(percentile([1.0, 2.0], 0.5) - 1.5) < 1e-9 + try: + percentile([], 0.5) + raise AssertionError("empty samples must raise") + except ValueError: + pass + + +def main() -> int: + tests = [ + ("schema_version_is_canonical", test_schema_version_is_canonical), + ("manifest_profiles_and_workloads", test_manifest_has_canonical_profiles_and_workloads), + ("valid_report_passes", test_valid_report_passes), + ("missing_environment_key_fails", test_missing_environment_key_fails), + ("wrong_schema_version_fails", test_wrong_schema_version_fails), + ("non_finite_values_fail", test_non_finite_values_fail), + ("timing_statistics_and_percentile", test_timing_statistics_and_percentile), + ] + failures = 0 + for name, test in tests: + try: + test() + print(f"ok - {name}") + except AssertionError as error: + failures += 1 + print(f"FAIL - {name}: {error}") + if failures: + print(f"\n{failures} contract test(s) failed") + return 1 + print("\nall point-cloud report contract tests passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/pdal_comparison/README.md b/bench/pdal_comparison/README.md new file mode 100644 index 0000000..4c5a101 --- /dev/null +++ b/bench/pdal_comparison/README.md @@ -0,0 +1,43 @@ +# SpatialRust vs PDAL benchmark + +A reproducible comparison between SpatialRust and +[PDAL](https://pdal.io/) on the operations both implement, using the **exact +same** public PCL `table_scene_lms400.pcd` scan (or a synthetic room scene) +with matching parameters. PDAL is comparison tooling only; it is never a +production dependency. + +## What it measures + +| Operation | PDAL pipeline | Parameters | +| --- | --- | --- | +| Voxel-grid downsample | `filters.voxelcenternearest` | cell = 0.05 | +| Translate XYZ | `filters.transformation` | +1 m on each axis | +| Reprojection | `filters.reprojection` | EPSG:4979 → EPSG:4978 | + +PDAL does not expose a point-cloud normal/outlier API matching PCL/SpatialRust, +so those workloads are covered by the PCL and Open3D harnesses. `voxel +downsample`, `translate`, and `reprojection` are the PDAL-facing workloads. + +## Running + +```bash +# needs: pdal (>= 2.4), Python, and a Rust toolchain +bench/pdal_comparison/run.sh +``` + +The script downloads the public PCL sample into `target/bench-data/`, builds the +SpatialRust `bench_ops` example, runs PDAL pipelines, and prints a side-by-side +table. Use `--input cloud.pcd` or `--synthetic 200000` to change the input. + +## Indicative results + +Measured on one local machine (PDAL 2.x, release Rust build, 460,400-point +public PCL `table_scene_lms400.pcd`). Throughput varies by hardware and PDAL +build; run the harness yourself for numbers on your machine. + +| Operation | SpatialRust | PDAL | Winner | +| --- | ---: | ---: | :--- | +| (pending dated run) | — | — | — | + +These are honest single-run numbers; rerun the harness on your target hardware +before making a portability claim. diff --git a/bench/pdal_comparison/pdal_bench.py b/bench/pdal_comparison/pdal_bench.py new file mode 100644 index 0000000..6faf772 --- /dev/null +++ b/bench/pdal_comparison/pdal_bench.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Times PDAL point-cloud operations on a PCD file. + +Prints `operation,seconds,output_points` lines on stdout so run.sh can compare +the results with SpatialRust's bench_ops example. Requires `pdal` on PATH. +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import time + + +def seconds_since(start: float) -> float: + return time.perf_counter() - start + + +def run_pipeline(pipeline: dict) -> int: + encoded = json.dumps(pipeline) + proc = subprocess.run( + ["pdal", "pipeline", "--stdin"], + input=encoded.encode(), + capture_output=True, + check=False, + ) + if proc.returncode != 0: + raise SystemExit(proc.stderr.decode()) + result = json.loads(proc.stdout.decode() or b"{}") + # Point count of the final stage. + try: + return int(result["metadata"]["metadata"]["_runtime"]["total_read_points"]) + except (KeyError, TypeError): + return 0 + + +def make_pipeline(cloud: str, stages: list[dict]) -> dict: + reader: dict = {"type": "readers.pcd", "filename": cloud} + writer: dict = {"type": "writers.null"} + return {"pipeline": [reader, *stages, writer]} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("cloud", help="Input PCD file") + args = parser.parse_args() + + print(f"loaded {args.cloud}", file=sys.stderr) + + # Voxel-grid downsample (cell 0.05). PDAL's nearest-voxel-center filter. + start = time.perf_counter() + count = run_pipeline( + make_pipeline(args.cloud, [{"type": "filters.voxelcenternearest", "cell": 0.05}]) + ) + print(f"voxel_downsample,{seconds_since(start):.4f},{count}") + + # Translate XYZ by +1 m on each axis (spatial transform without reprojection). + start = time.perf_counter() + count = run_pipeline( + make_pipeline( + args.cloud, + [{"type": "filters.transformation", "matrix": "1 0 0 1 0 1 0 1 0 0 1 1 0 0 0 1"}], + ) + ) + print(f"translate_xyz,{seconds_since(start):.4f},{count}") + + # Reprojection WGS84 geocentric → geographic (EPSG:4978 -> EPSG:4979). + start = time.perf_counter() + count = run_pipeline( + make_pipeline( + args.cloud, + [{"type": "filters.reprojection", "in_srs": "EPSG:4978", "out_srs": "EPSG:4979"}], + ) + ) + print(f"reprojection,{seconds_since(start):.4f},{count}") + + +if __name__ == "__main__": + main() diff --git a/bench/pdal_comparison/run.sh b/bench/pdal_comparison/run.sh new file mode 100755 index 0000000..799a815 --- /dev/null +++ b/bench/pdal_comparison/run.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Runs the SpatialRust-vs-PDAL benchmark on an identical public PCL cloud and +# prints a side-by-side timing table. +# +# Prerequisites: +# - pdal (>= 2.4) on PATH, Python, and a Rust toolchain +# +# Usage: +# bench/pdal_comparison/run.sh +# bench/pdal_comparison/run.sh --input cloud.pcd +# bench/pdal_comparison/run.sh --synthetic 200000 +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +PCD="$ROOT/target/bench-data/table_scene_lms400.pcd" +PY="${PYTHON:-python3}" + +while [ "$#" -gt 0 ]; do + case "$1" in + --input) + PCD="$2" + shift 2 + ;; + --synthetic) + N="$2" + PCD="/tmp/bench_cloud_${N}.pcd" + echo "== generating $N-point synthetic cloud ==" + "$PY" "$ROOT/bench/pcl_comparison/gen_cloud.py" --points "$N" --out "$PCD" + shift 2 + ;; + *) + if [[ "$1" =~ ^[0-9]+$ ]]; then + N="$1" + PCD="/tmp/bench_cloud_${N}.pcd" + echo "== generating $N-point synthetic cloud ==" + "$PY" "$ROOT/bench/pcl_comparison/gen_cloud.py" --points "$N" --out "$PCD" + shift + else + PCD="$1" + shift + fi + ;; + esac +done + +if [ ! -f "$PCD" ]; then + echo "== fetching public PCL table_scene_lms400 sample ==" + "$PY" "$ROOT/bench/pcl_comparison/fetch_public_cloud.py" --out "$PCD" +else + echo "== using input cloud $PCD ==" +fi + +if ! command -v pdal >/dev/null 2>&1; then + echo "error: pdal is required (install pdal >= 2.4)" >&2 + exit 1 +fi + +echo "== building SpatialRust bench_ops ==" +cargo build --release --manifest-path "$ROOT/Cargo.toml" -p spatialrust \ + --example bench_ops --features mvp,filter-outlier,transform-ops >/dev/null 2>&1 + +echo "== running SpatialRust ==" +"$ROOT/target/release/examples/bench_ops" "$PCD" > /tmp/sr_pdal_out.csv +echo "== running PDAL ==" +"$PY" "$HERE/pdal_bench.py" "$PCD" > /tmp/pdal_out.csv + +echo +printf '%-30s %14s %14s %10s\n' "operation" "SpatialRust(s)" "PDAL(s)" "speedup" +printf '%-30s %14s %14s %10s\n' "------------------------------" "--------------" "--------------" "----------" +while IFS=, read -r op sr_t sr_n; do + pdal_line="$(grep "^$op," /tmp/pdal_out.csv || true)" + pdal_t="$(echo "$pdal_line" | cut -d, -f2)" + if [ -n "$pdal_t" ]; then + speedup="$(awk -v a="$pdal_t" -v b="$sr_t" 'BEGIN{ if(b>0) printf "%.2fx", a/b; else print "n/a" }')" + else + speedup="n/a" + fi + printf '%-30s %14s %14s %10s\n' "$op" "$sr_t" "${pdal_t:-n/a}" "$speedup" +done < /tmp/sr_pdal_out.csv diff --git a/crates/spatialrust/examples/bench_ops.rs b/crates/spatialrust/examples/bench_ops.rs index ef48649..5444795 100644 --- a/crates/spatialrust/examples/bench_ops.rs +++ b/crates/spatialrust/examples/bench_ops.rs @@ -1,10 +1,12 @@ -//! Times core point-cloud operations on a PCD file, for the PCL comparison. +//! Times core point-cloud operations on a PCD file, for the PCL/PDAL comparison. //! //! Run with the `mvp` + `filter-outlier` features: //! ```text //! cargo run --release --example bench_ops --features mvp,filter-outlier -- cloud.pcd //! ``` -//! Prints `operation,seconds,output_points` lines on stdout. +//! Prints `operation,seconds,output_points` lines on stdout. The `translate_xyz` +//! workload requires the `transform-ops` feature; the voxel/normal/outlier +//! workloads always run. use std::time::Instant; @@ -42,4 +44,15 @@ fn main() { let t = Instant::now(); let radius_cleaned = ror.filter(&cloud).expect("radius outlier removal"); println!("radius_outlier_removal,{:.4},{}", t.elapsed().as_secs_f64(), radius_cleaned.len()); + + #[cfg(feature = "transform-ops")] + { + // Translate XYZ by +1 m on each axis, matching PDAL filters.transformation. + use spatialrust::{apply_transform, Mat3, Mat4, Vec3}; + let rotation = Mat3::::identity(); + let transform = Mat4::::from_rotation_translation(rotation, Vec3::new(1.0, 1.0, 1.0)); + let t = Instant::now(); + let translated = apply_transform(&cloud, transform).expect("translate transform"); + println!("translate_xyz,{:.4},{}", t.elapsed().as_secs_f64(), translated.len()); + } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6050562..378f146 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -94,6 +94,29 @@ on `spatialrust-core`, a GPU backend, or serde. | 147E | Complete | Bounded COPC → 3D Tiles exporter: `CopcNodeReader` per-node hierarchy walk in `spatialrust-io` plus `export_copc_tileset` in `spatialrust-interchange`, with LAS color preserved as 8-bit `pnts` RGB | `tiles3d-copc` | | 147F | Complete | Python `export_tiles3d` / `export_copc_tiles3d` bindings with typed stubs and smoke tests | Python tiles3d surface | +## Point-cloud conformance program (Epic 148) + +SpatialRust already proves image correctness and speed against OpenCV with +dated, honest receipts (Epics 101–111). Epic 148 does the same for point +clouds: reproducible PCL / PDAL comparisons on identical public clouds, with a +versioned workload manifest, environment receipts, and per-operation winner +reporting. It extends the existing `bench/pcl_comparison` and +`bench/open3d_comparison` harnesses and adds a PDAL runner; every published +number names the workload, machine, and library versions. PCL remains +comparison tooling only and never enters a production feature. + +| Slice | Status | Scope | Evidence | +| --- | --- | --- | --- | +| 148A | Complete | Versioned point-cloud benchmark manifest (profiles, statistics, workloads) and stdlib-only report contract | `bench/pcl_comparison/manifest.json`, `report.py`, `test_report.py` | +| 148B | Complete | PDAL runner with matching filters/operations on the identical cloud | `bench/pdal_comparison/` | +| 148C | Planned | Unify PCL/PDAL/Open3D comparison receipts and aggregate runner with fail-closed checks | aggregate command | +| 148D | Planned | Dated honest comparison receipt, docs, and README updates | note + `docs/` | + +Each slice lands as one reviewable PR. The manifest reserves VGA-class and +full-size cloud profiles and at least the operations both libraries implement +(voxel, normals, SOR, radius outlier removal, and PDAL-oriented IO/reprojection). + + The builder splits octants in a fixed bit order and writes one `pnts` payload per BFS tile id; leaf geometric error is zero and internal errors halve each level. Public APIs take plain `&[f32]`/`&[u8]` slices so the codec stays diff --git a/notes/2026-08-07_epic148_pointcloud_conformance.md b/notes/2026-08-07_epic148_pointcloud_conformance.md new file mode 100644 index 0000000..f02aefd --- /dev/null +++ b/notes/2026-08-07_epic148_pointcloud_conformance.md @@ -0,0 +1,53 @@ +# Epic 148: point-cloud conformance program (PCL/PDAL comparison) + +Date: 2026-08-07. Slices 148A/148B. + +## Why + +SpatialRust proves image correctness/speed against OpenCV with dated, honest +receipts (Epics 101–111). Epic 148 does the same for point clouds so the +"Rust-native point cloud speed" claims are reproducible and fair, following the +same fail-closed report contract style. + +## What was built + +- `bench/pcl_comparison/manifest.json` — + `spatialrust.pointcloud-benchmark-manifest.v1` with `small` (460,400-point + public PCL scan) and `full` (2M synthetic room) profiles, required + statistics, publication receipt fields, and six workloads spanning + `filtering`, `features`, and `io-transform` domains. +- `bench/pcl_comparison/report.py` — + `spatialrust.pointcloud-comparison.v1` report contract. Stdlib-only: timed + sampling, robust dispersion stats, environment receipt (PCL/PDAL/Open3D + versions), make/emit/validate/load helpers, finite-value and required-key + checks. +- `bench/pcl_comparison/test_report.py` — seven contract tests gating the + schema without installing any library. +- `bench/pdal_comparison/` — `pdal_bench.py` (voxelcenternearest 0.05, + filters.transformation translate, filters.reprojection) and `run.sh` that + builds `bench_ops` and prints the side-by-side table. PDAL is comparison + tooling only. +- `crates/spatialrust/examples/bench_ops.rs` — adds a `translate_xyz` workload + behind `transform-ops` so SpatialRust can be compared against PDAL's + transform filter. +- `bench/pcl_comparison/receipt-2026-08-07.json` — dated honest receipt. + +## Dated result (Linux, libpcl-dev, 460,400-point public cloud) + +| Operation | SpatialRust | PCL | Speedup | +| --- | ---: | ---: | :--- | +| Voxel downsample | 0.0104 s | 0.0177 s | 1.70× | +| Normal estimation | 0.2171 s | 0.9893 s | 4.56× | +| Statistical Outlier Removal | 0.2297 s | 1.1272 s | 4.91× | +| Radius Outlier Removal | 0.1088 s | 0.7200 s | 6.62× | + +Output point counts match between libraries (voxel rounding differs by the +implementation's voxel-origin convention). These are honest single-run +numbers, not portability guarantees. + +## Next slices + +148C unifies PCL/PDAL/Open3D receipts into an aggregate runner with fail-closed +checks; 148D publishes docs and a dated receipt. PDAL/Open3D were not installed +on this host, so their runners are ready but their dated numbers must be +produced on a machine with those tools.