| """Voxel-downsample a point cloud while preserving its geometry, and show the comparison. |
| |
| Usage |
| ----- |
| python voxel_downsample.py # defaults: bigpointcloud_001.ply |
| python voxel_downsample.py --input my.ply --voxel 0.15 # force a voxel size |
| python voxel_downsample.py --target-points 3000 # pick voxel size by point budget |
| |
| What it does |
| ------------ |
| 1. Sweeps a range of voxel sizes and, for each, measures how far the surface moved: |
| one-sided distances original -> downsampled (mean / RMS / p95 / max = Hausdorff), |
| normal deviation, and bounding-box shrinkage. |
| 2. Auto-selects the *largest* voxel (= fewest points) whose p95 surface error still |
| stays under one nearest-neighbour spacing of the original cloud, i.e. the geometry |
| is perturbed no more than the original sampling noise already does. |
| 3. Compares against random subsampling at the *same* point count, which is the usual |
| way geometry gets destroyed (uneven density, holes, outliers kept). |
| 4. Writes the downsampled .ply, a sweep .csv, side-by-side PNG renders and an |
| interactive .html (fig.show() is useless on a headless node). |
| """ |
|
|
| import argparse |
| import csv |
| import ctypes |
| import glob |
| import os |
| import sys |
|
|
| import numpy as np |
|
|
|
|
| def _import_open3d(): |
| """Import open3d, preloading a libGL if the system has none (headless cluster).""" |
| try: |
| import open3d as o3d |
| return o3d |
| except OSError as err: |
| candidates = [] |
| for prefix in (sys.prefix, os.path.dirname(os.path.dirname(sys.prefix))): |
| candidates += glob.glob(os.path.join(prefix, "lib", "libGL.so.1")) |
| candidates += glob.glob(os.path.join(prefix, "envs", "*", "lib", "libGL.so.1")) |
| for lib in candidates: |
| try: |
| ctypes.CDLL(lib, mode=ctypes.RTLD_GLOBAL) |
| import open3d as o3d |
| return o3d |
| except OSError: |
| continue |
| raise err |
|
|
|
|
| o3d = _import_open3d() |
|
|
| import matplotlib |
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
|
|
|
|
| |
| def mean_nn_spacing(pcd, sample=20000, seed=0): |
| """Average distance from a point to its nearest neighbour: the cloud's native resolution.""" |
| pts = np.asarray(pcd.points) |
| rng = np.random.default_rng(seed) |
| idx = rng.choice(len(pts), size=min(sample, len(pts)), replace=False) |
| tree = o3d.geometry.KDTreeFlann(pcd) |
| d = [] |
| for i in idx: |
| _, _, sq = tree.search_knn_vector_3d(pts[i], 2) |
| d.append(np.sqrt(sq[1])) |
| return float(np.mean(d)) |
|
|
|
|
| def geometry_error(original, reduced, return_per_point=False): |
| """How far the surface moved, plus how well normals and extent survived.""" |
| d_fwd = np.asarray(original.compute_point_cloud_distance(reduced)) |
| d_bwd = np.asarray(reduced.compute_point_cloud_distance(original)) |
|
|
| out = { |
| "n_points": len(reduced.points), |
| "mean_err": float(d_fwd.mean()), |
| "rms_err": float(np.sqrt((d_fwd ** 2).mean())), |
| "p95_err": float(np.percentile(d_fwd, 95)), |
| "hausdorff": float(max(d_fwd.max(), d_bwd.max())), |
| } |
|
|
| |
| if original.has_normals() and reduced.has_normals(): |
| n_o = np.asarray(original.normals) |
| n_r = np.asarray(reduced.normals) |
| n_r = n_r / np.clip(np.linalg.norm(n_r, axis=1, keepdims=True), 1e-12, None) |
| tree = o3d.geometry.KDTreeFlann(reduced) |
| ang = [] |
| for p, n in zip(np.asarray(original.points), n_o): |
| _, j, _ = tree.search_knn_vector_3d(p, 1) |
| ang.append(np.degrees(np.arccos(np.clip(abs(float(n @ n_r[j[0]])), -1.0, 1.0)))) |
| out["mean_normal_deg"] = float(np.mean(ang)) |
| else: |
| out["mean_normal_deg"] = float("nan") |
|
|
| ext_o = original.get_axis_aligned_bounding_box().get_extent() |
| ext_r = reduced.get_axis_aligned_bounding_box().get_extent() |
| out["bbox_shrink_pct"] = float(100.0 * np.max(1.0 - ext_r / ext_o)) |
| return (out, d_fwd) if return_per_point else out |
|
|
|
|
| def random_subsample(pcd, n, seed=0): |
| rng = np.random.default_rng(seed) |
| idx = rng.choice(len(pcd.points), size=min(n, len(pcd.points)), replace=False) |
| return pcd.select_by_index(np.sort(idx).tolist()) |
|
|
|
|
| |
| VIEWS = [("iso", 24, -62), ("top-down", 88, -90)] |
|
|
|
|
| def scatter(ax, pts, colors, title, size, extent, cmap=None, vmax=None): |
| art = ax.scatter(pts[:, 0], pts[:, 1], pts[:, 2], c=colors, s=size, linewidths=0, |
| cmap=cmap, vmin=0 if cmap else None, vmax=vmax) |
| ax.set_box_aspect(extent, zoom=1.45) |
| ax.set_title(title, fontsize=10) |
| ax.set_axis_off() |
| return art |
|
|
|
|
| def cloud_colors(pcd): |
| if pcd.has_colors(): |
| return np.clip(np.asarray(pcd.colors), 0, 1) |
| return np.tile([0.20, 0.40, 0.75], (len(pcd.points), 1)) |
|
|
|
|
| def render_comparison(clouds, path, point_size=1.0): |
| """clouds: list of (label, pcd). One row per viewpoint.""" |
| extent = clouds[0][1].get_axis_aligned_bounding_box().get_extent() |
| fig = plt.figure(figsize=(4.6 * len(clouds), 4.0 * len(VIEWS))) |
| for r, (vname, elev, azim) in enumerate(VIEWS): |
| for c, (label, pcd) in enumerate(clouds): |
| ax = fig.add_subplot(len(VIEWS), len(clouds), r * len(clouds) + c + 1, projection="3d") |
| scatter(ax, np.asarray(pcd.points), cloud_colors(pcd), f"{label}\n[{vname}]", |
| point_size, extent) |
| ax.view_init(elev=elev, azim=azim) |
| fig.subplots_adjust(wspace=0.02, hspace=0.02) |
| fig.savefig(path, dpi=150, bbox_inches="tight") |
| plt.close(fig) |
|
|
|
|
| def render_error_maps(original, entries, path, point_size=1.0): |
| """Original points coloured by distance to each reduced cloud (shared scale).""" |
| pts = np.asarray(original.points) |
| extent = original.get_axis_aligned_bounding_box().get_extent() |
| vmax = max(float(np.percentile(d, 99)) for _, d in entries) |
| fig = plt.figure(figsize=(4.6 * len(entries), 4.0 * len(VIEWS))) |
| art = None |
| for r, (vname, elev, azim) in enumerate(VIEWS): |
| for c, (label, d) in enumerate(entries): |
| ax = fig.add_subplot(len(VIEWS), len(entries), r * len(entries) + c + 1, |
| projection="3d") |
| art = scatter(ax, pts, d, f"{label}\n[{vname}]", point_size, extent, |
| cmap="inferno", vmax=vmax) |
| ax.view_init(elev=elev, azim=azim) |
| fig.subplots_adjust(wspace=0.02, hspace=0.02, right=0.90) |
| cax = fig.add_axes([0.92, 0.25, 0.012, 0.5]) |
| fig.colorbar(art, cax=cax).set_label("distance from original point to kept surface", fontsize=9) |
| fig.savefig(path, dpi=150, bbox_inches="tight") |
| plt.close(fig) |
|
|
|
|
| def render_sweep(rows, chosen, nn, path): |
| v = [r["voxel"] for r in rows] |
| fig, axes = plt.subplots(1, 3, figsize=(15, 4.2)) |
|
|
| axes[0].plot(v, [r["n_points"] for r in rows], "o-", color="#2c7fb8") |
| axes[0].set_yscale("log") |
| axes[0].set_ylabel("points kept") |
|
|
| axes[1].plot(v, [r["mean_err"] for r in rows], "o-", label="mean", color="#2c7fb8") |
| axes[1].plot(v, [r["p95_err"] for r in rows], "s-", label="p95", color="#e6844a") |
| axes[1].plot(v, [r["hausdorff"] for r in rows], "^-", label="Hausdorff", color="#c0392b") |
| axes[1].axhline(nn, ls="--", c="gray", lw=1, label=f"orig. NN spacing = {nn:.3f}") |
| axes[1].set_ylabel("surface error (units)") |
| axes[1].legend(fontsize=8) |
|
|
| axes[2].plot(v, [r["mean_normal_deg"] for r in rows], "o-", color="#2c7fb8") |
| axes[2].set_ylabel("mean normal deviation (deg)") |
|
|
| for ax in axes: |
| ax.set_xlabel("voxel size") |
| ax.axvline(chosen, ls=":", c="green", lw=1.5) |
| ax.grid(alpha=0.3) |
| fig.suptitle(f"voxel-size sweep (green = selected {chosen:g})", fontsize=11) |
| fig.tight_layout() |
| fig.savefig(path, dpi=150, bbox_inches="tight") |
| plt.close(fig) |
|
|
|
|
| def render_interactive(orig, down, path): |
| try: |
| import plotly.graph_objects as go |
| from plotly.subplots import make_subplots |
| except ImportError: |
| return None |
|
|
| def trace(pcd): |
| p = np.asarray(pcd.points) |
| c = np.asarray(pcd.colors) if pcd.has_colors() else None |
| marker = dict(size=1.4) |
| if c is not None: |
| marker["color"] = [f"rgb({int(r*255)},{int(g*255)},{int(b*255)})" for r, g, b in c] |
| return go.Scatter3d(x=p[:, 0], y=p[:, 1], z=p[:, 2], mode="markers", marker=marker) |
|
|
| fig = make_subplots(rows=1, cols=2, specs=[[{"type": "scene"}, {"type": "scene"}]], |
| subplot_titles=(f"original ({len(orig.points)} pts)", |
| f"voxel downsampled ({len(down.points)} pts)")) |
| fig.add_trace(trace(orig), row=1, col=1) |
| fig.add_trace(trace(down), row=1, col=2) |
| hidden = dict(xaxis=dict(visible=False), yaxis=dict(visible=False), zaxis=dict(visible=False), |
| aspectmode="data") |
| fig.update_layout(scene=hidden, scene2=hidden, showlegend=False, |
| margin=dict(l=0, r=0, t=30, b=0)) |
| fig.write_html(path) |
| return path |
|
|
|
|
| |
| def main(): |
| here = os.path.dirname(os.path.abspath(__file__)) |
| ap = argparse.ArgumentParser(description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| ap.add_argument("--input", default=os.path.join(here, "bigpointcloud_001.ply")) |
| ap.add_argument("--outdir", default=os.path.join(here, "voxel_downsample_out")) |
| ap.add_argument("--voxel", type=float, default=None, |
| help="force this voxel size instead of auto-selecting") |
| ap.add_argument("--target-points", type=int, default=None, |
| help="pick the sweep voxel whose point count is closest to this") |
| ap.add_argument("--err-tol", type=float, default=1.0, |
| help="safe pick: accept a voxel while p95 surface error <= err-tol * NN spacing") |
| ap.add_argument("--aggressive-tol", type=float, default=1.0, |
| help="aggressive pick: accept while MEAN surface error <= tol * NN spacing") |
| args = ap.parse_args() |
| os.makedirs(args.outdir, exist_ok=True) |
|
|
| pcd = o3d.io.read_point_cloud(args.input) |
| if len(pcd.points) == 0: |
| sys.exit(f"no points read from {args.input}") |
| ext = pcd.get_axis_aligned_bounding_box().get_extent() |
| diag = float(np.linalg.norm(ext)) |
| nn = mean_nn_spacing(pcd) |
|
|
| print(f"input : {args.input}") |
| print(f"points : {len(pcd.points)} normals={pcd.has_normals()} colors={pcd.has_colors()}") |
| print(f"bbox extent : {ext} (diagonal {diag:.3f})") |
| print(f"mean NN spacing : {nn:.4f} <- native resolution of the cloud\n") |
|
|
| |
| voxels = [round(m * nn, 6) for m in (0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0, 12.0)] |
| rows = [] |
| for v in voxels: |
| d = pcd.voxel_down_sample(voxel_size=v) |
| if len(d.points) < 10: |
| continue |
| m = geometry_error(pcd, d) |
| m["voxel"] = v |
| m["keep_pct"] = 100.0 * m["n_points"] / len(pcd.points) |
| rows.append(m) |
|
|
| hdr = f"{'voxel':>8} {'points':>8} {'keep%':>7} {'mean':>8} {'rms':>8} {'p95':>8} {'hausdorff':>10} {'normal°':>8} {'bbox↓%':>7}" |
| print("voxel-size sweep (errors in cloud units; 'mean' = avg distance from an original point to the kept surface)") |
| print(hdr) |
| print("-" * len(hdr)) |
| for r in rows: |
| print(f"{r['voxel']:8.3f} {r['n_points']:8d} {r['keep_pct']:7.1f} {r['mean_err']:8.4f} " |
| f"{r['rms_err']:8.4f} {r['p95_err']:8.4f} {r['hausdorff']:10.4f} " |
| f"{r['mean_normal_deg']:8.2f} {r['bbox_shrink_pct']:7.2f}") |
|
|
| |
| def largest_where(pred): |
| ok = [r for r in rows if pred(r)] |
| return (max(ok, key=lambda r: r["voxel"]) if ok else rows[0])["voxel"] |
|
|
| if args.voxel is not None: |
| voxel, why, tag = args.voxel, "user-specified", "chosen" |
| elif args.target_points is not None: |
| voxel = min(rows, key=lambda r: abs(r["n_points"] - args.target_points))["voxel"] |
| why, tag = f"closest to target of {args.target_points} points", "chosen" |
| else: |
| voxel = largest_where(lambda r: r["p95_err"] <= args.err_tol * nn) |
| why = f"largest voxel with p95 error <= {args.err_tol:g} x NN spacing" |
| tag = "safe" |
| |
| voxel_aggr = largest_where(lambda r: r["mean_err"] <= args.aggressive_tol * nn) |
|
|
| print(f"\n{tag} voxel size : {voxel:.4f} ({why})") |
| print(f"aggressive voxel size: {voxel_aggr:.4f} (largest voxel with mean error " |
| f"<= {args.aggressive_tol:g} x NN spacing)") |
|
|
| down = pcd.voxel_down_sample(voxel_size=voxel) |
| down_m, d_down = geometry_error(pcd, down, return_per_point=True) |
| aggr = pcd.voxel_down_sample(voxel_size=voxel_aggr) |
| aggr_m, d_aggr = geometry_error(pcd, aggr, return_per_point=True) |
| rnd = random_subsample(pcd, len(aggr.points)) |
| rnd_m, d_rnd = geometry_error(pcd, rnd, return_per_point=True) |
|
|
| print(f"\nvoxel vs random subsampling at the same budget ({len(aggr.points)} points):") |
| print(f"{'':>20}{'mean':>9}{'rms':>9}{'p95':>9}{'hausdorff':>11}{'normal°':>9}") |
| for name, m in ((f"voxel {voxel_aggr:.3f}", aggr_m), ("random (baseline)", rnd_m)): |
| print(f"{name:>20}{m['mean_err']:9.4f}{m['rms_err']:9.4f}{m['p95_err']:9.4f}" |
| f"{m['hausdorff']:11.4f}{m['mean_normal_deg']:9.2f}") |
|
|
| |
| stem = os.path.splitext(os.path.basename(args.input))[0] |
| outs = [] |
| for t, v, c in ((tag, voxel, down), ("aggressive", voxel_aggr, aggr)): |
| p = os.path.join(args.outdir, f"{stem}_voxel{v:.3f}_{t}.ply") |
| o3d.io.write_point_cloud(p, c) |
| outs.append(p) |
|
|
| csv_out = os.path.join(args.outdir, "sweep.csv") |
| with open(csv_out, "w", newline="") as fh: |
| w = csv.DictWriter(fh, fieldnames=["voxel", "n_points", "keep_pct", "mean_err", "rms_err", |
| "p95_err", "hausdorff", "mean_normal_deg", |
| "bbox_shrink_pct"]) |
| w.writeheader() |
| w.writerows(rows) |
| outs.append(csv_out) |
|
|
| def pct(c): |
| return f"{100 * len(c.points) / len(pcd.points):.0f}%" |
|
|
| cmp_png = os.path.join(args.outdir, "comparison.png") |
| render_comparison([(f"original — {len(pcd.points)} pts", pcd), |
| (f"voxel {voxel:.3f} ({tag}) — {len(down.points)} pts, {pct(down)}", down), |
| (f"voxel {voxel_aggr:.3f} (aggressive) — {len(aggr.points)} pts, " |
| f"{pct(aggr)}", aggr), |
| (f"random {len(rnd.points)} pts (baseline)", rnd)], cmp_png) |
| outs.append(cmp_png) |
|
|
| err_png = os.path.join(args.outdir, "error_map.png") |
| render_error_maps(pcd, [(f"voxel {voxel:.3f} ({tag})", d_down), |
| (f"voxel {voxel_aggr:.3f} (aggressive)", d_aggr), |
| (f"random {len(rnd.points)} pts (baseline)", d_rnd)], err_png) |
| outs.append(err_png) |
|
|
| sweep_png = os.path.join(args.outdir, "sweep.png") |
| render_sweep(rows, voxel, nn, sweep_png) |
| outs.append(sweep_png) |
|
|
| outs.append(render_interactive(pcd, aggr, os.path.join(args.outdir, "comparison.html"))) |
|
|
| print("\nwrote:") |
| for p in outs: |
| if p: |
| print(f" {p}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|