Download tools/compress_point_cloud.py from topabaem/CC-4DGS: direct link, hf CLI and curl.
- Browser
- Download file 12.6 kB
-
https://huggingface.co/spaces/topabaem/CC-4DGS/resolve/main/tools/compress_point_cloud.py
- Command line
-
hf download hf://spaces/topabaem/CC-4DGS/tools/compress_point_cloud.py
-
curl -L -o compress_point_cloud.py https://huggingface.co/spaces/topabaem/CC-4DGS/resolve/main/tools/compress_point_cloud.py
12.6 kB
| #!/usr/bin/env python3 | |
| """ | |
| Utility to pack Gaussian point clouds (PLY) into a quantised NPZ archive | |
| and optionally restore them back. The compression pipeline is used by the | |
| CC-4DGS post-training CCA tooling. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import math | |
| from pathlib import Path | |
| from typing import Dict, Iterable, List, Tuple | |
| import numpy as np | |
| from plyfile import PlyData, PlyElement | |
| def _load_ply(path: Path) -> Tuple[Dict[str, np.ndarray], List[str]]: | |
| ply = PlyData.read(path) | |
| vertex = ply["vertex"].data | |
| names = list(vertex.dtype.names) | |
| arrays = {name: vertex[name].astype(np.float32).copy() for name in names} | |
| return arrays, names | |
| def _write_ply(path: Path, arrays: Dict[str, np.ndarray], order: Iterable[str]) -> None: | |
| dtype = [(name, "<f4") for name in order] | |
| count = len(next(iter(arrays.values()))) | |
| structured = np.empty(count, dtype=dtype) | |
| for name in order: | |
| structured[name] = arrays[name].astype(np.float32) | |
| vertex = PlyElement.describe(structured, "vertex") | |
| ply = PlyData([vertex], text=False) | |
| ply.write(str(path)) | |
| def _select_uint_dtype(bits: int) -> np.dtype: | |
| """Return the smallest unsigned dtype that can hold `bits` levels.""" | |
| if bits <= 8: | |
| return np.uint8 | |
| if bits <= 16: | |
| return np.uint16 | |
| if bits <= 32: | |
| return np.uint32 | |
| raise ValueError(f"Quantisation with {bits} bits is not supported (max 32).") | |
| def _quantise_linear(values: np.ndarray, bits: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: | |
| """Linearly quantise along the last axis.""" | |
| mins = values.min(axis=0) | |
| maxs = values.max(axis=0) | |
| spans = np.where(maxs > mins, maxs - mins, 1.0) | |
| levels = (1 << bits) - 1 | |
| scaled = np.round((values - mins) / spans * levels) | |
| q_dtype = _select_uint_dtype(bits) | |
| quantised = scaled.clip(0, levels).astype(q_dtype) | |
| return quantised, mins.astype(np.float32), maxs.astype(np.float32) | |
| def _dequantise_linear(quantised: np.ndarray, mins: np.ndarray, maxs: np.ndarray, bits: int) -> np.ndarray: | |
| levels = (1 << bits) - 1 | |
| spans = np.where(maxs > mins, maxs - mins, 1.0) | |
| values = mins + (quantised.astype(np.float32) / levels) * spans | |
| return values | |
| def compress_point_cloud( | |
| input_path: Path, | |
| output_path: Path, | |
| *, | |
| pos_bits: int = 16, | |
| feature_bits: int = 8, | |
| coeff_bits: int = 12, | |
| pca_components: int = 16, | |
| drop_normals: bool = True, | |
| keep_percent: float = 1.0, | |
| top_k: int | None = None, | |
| sort_by: str = "opacity", | |
| min_field_value: float | None = None, | |
| ) -> None: | |
| arrays, original_names = _load_ply(input_path) | |
| count = len(next(iter(arrays.values()))) | |
| if sort_by not in arrays: | |
| raise KeyError(f"Cannot sort by '{sort_by}' because the field is missing.") | |
| # Select subset to keep, default is keep all. | |
| keep_mask = np.ones(count, dtype=bool) | |
| if min_field_value is not None: | |
| keep_mask &= arrays[sort_by] >= min_field_value | |
| if top_k is not None and top_k < count: | |
| top_indices = np.argsort(arrays[sort_by])[::-1][:top_k] | |
| keep_mask = np.zeros(count, dtype=bool) | |
| keep_mask[top_indices] = True | |
| elif keep_percent < 1.0: | |
| keep_n = int(math.ceil(count * keep_percent)) | |
| if keep_n <= 0: | |
| raise ValueError("keep_percent too small; nothing would be kept.") | |
| sorted_idx = np.argsort(arrays[sort_by])[::-1][:keep_n] | |
| keep_mask = np.zeros(count, dtype=bool) | |
| keep_mask[sorted_idx] = True | |
| if not keep_mask.all(): | |
| for key in arrays: | |
| arrays[key] = arrays[key][keep_mask] | |
| pos_names = ["x", "y", "z"] | |
| missing = [name for name in pos_names if name not in arrays] | |
| if missing: | |
| raise KeyError(f"The point cloud is missing mandatory coordinate(s): {missing}") | |
| normal_names = [name for name in ("nx", "ny", "nz") if name in arrays] | |
| dropped_normals = normal_names.copy() | |
| rest_names = sorted(name for name in arrays if name.startswith("f_rest")) | |
| dynamic_names = [name for name in arrays if name == "dynamic"] | |
| other_names = [ | |
| name | |
| for name in arrays | |
| if name not in pos_names + normal_names + rest_names + dynamic_names | |
| ] | |
| if drop_normals: | |
| for name in normal_names: | |
| arrays.pop(name, None) | |
| normal_names = [] | |
| pos = np.stack([arrays[name] for name in pos_names], axis=1) | |
| rest = np.stack([arrays[name] for name in rest_names], axis=1) if rest_names else None | |
| others = np.stack([arrays[name] for name in other_names], axis=1) if other_names else None | |
| dynamic = ( | |
| np.stack([arrays[name] for name in dynamic_names], axis=1) | |
| if dynamic_names | |
| else None | |
| ) | |
| compressed: Dict[str, np.ndarray] = {} | |
| meta: Dict[str, object] = { | |
| "version": 1, | |
| "pos_names": pos_names, | |
| "rest_names": rest_names, | |
| "other_names": other_names, | |
| "normal_names": dropped_normals if drop_normals else normal_names, | |
| "original_names": original_names, | |
| "pos_bits": pos_bits, | |
| "feature_bits": feature_bits, | |
| "coeff_bits": coeff_bits, | |
| "pca_components": pca_components if rest is not None else 0, | |
| "sorted_by": sort_by, | |
| "kept_points": int(pos.shape[0]), | |
| "original_points": count, | |
| "keep_percent": keep_percent, | |
| "top_k": top_k, | |
| "min_field_value": min_field_value, | |
| "dynamic_names": dynamic_names, | |
| } | |
| pos_q, pos_min, pos_max = _quantise_linear(pos, pos_bits) | |
| compressed["pos"] = pos_q | |
| compressed["pos_min"] = pos_min | |
| compressed["pos_max"] = pos_max | |
| if others is not None: | |
| features_q, features_min, features_max = _quantise_linear(others, feature_bits) | |
| compressed["features"] = features_q | |
| compressed["features_min"] = features_min | |
| compressed["features_max"] = features_max | |
| else: | |
| compressed["features"] = np.empty((pos.shape[0], 0), dtype=np.uint8) | |
| compressed["features_min"] = np.empty(0, dtype=np.float32) | |
| compressed["features_max"] = np.empty(0, dtype=np.float32) | |
| rest_mode = "none" | |
| if rest is not None and pca_components > 0: | |
| rest_mode = "pca" | |
| mean = rest.mean(axis=0) | |
| centered = rest - mean | |
| u, s, vt = np.linalg.svd(centered, full_matrices=False) | |
| k = min(pca_components, vt.shape[0]) | |
| coeff = u[:, :k] * s[:k] | |
| coeff_q, coeff_min, coeff_max = _quantise_linear(coeff, coeff_bits) | |
| compressed["coeff"] = coeff_q | |
| compressed["coeff_min"] = coeff_min | |
| compressed["coeff_max"] = coeff_max | |
| compressed["rest_mean"] = mean.astype(np.float32) | |
| compressed["rest_basis"] = vt[:k].astype(np.float32) | |
| elif rest is not None: | |
| rest_mode = "direct" | |
| meta["pca_components"] = 0 | |
| rest_q, rest_min, rest_max = _quantise_linear(rest, coeff_bits) | |
| compressed["rest_direct"] = rest_q | |
| compressed["rest_direct_min"] = rest_min | |
| compressed["rest_direct_max"] = rest_max | |
| compressed["coeff"] = np.empty((pos.shape[0], 0), dtype=np.uint8) | |
| compressed["coeff_min"] = np.empty(0, dtype=np.float32) | |
| compressed["coeff_max"] = np.empty(0, dtype=np.float32) | |
| compressed["rest_mean"] = np.empty(0, dtype=np.float32) | |
| compressed["rest_basis"] = np.empty((0, 0), dtype=np.float32) | |
| else: | |
| meta["pca_components"] = 0 | |
| compressed["coeff"] = np.empty((pos.shape[0], 0), dtype=np.uint8) | |
| compressed["coeff_min"] = np.empty(0, dtype=np.float32) | |
| compressed["coeff_max"] = np.empty(0, dtype=np.float32) | |
| compressed["rest_mean"] = np.empty(0, dtype=np.float32) | |
| compressed["rest_basis"] = np.empty((0, 0), dtype=np.float32) | |
| meta["rest_mode"] = rest_mode | |
| meta["rest_bits"] = coeff_bits | |
| if dynamic is not None: | |
| compressed["dynamic"] = dynamic.astype(np.float32) | |
| compressed["meta"] = np.array(json.dumps(meta)) | |
| np.savez_compressed(output_path, **compressed) | |
| def decompress_point_cloud(input_path: Path, output_path: Path) -> None: | |
| packed = np.load(input_path, allow_pickle=False) | |
| meta = json.loads(str(packed["meta"])) | |
| pos_bits = int(meta["pos_bits"]) | |
| feature_bits = int(meta["feature_bits"]) | |
| coeff_bits = int(meta["coeff_bits"]) | |
| pos = _dequantise_linear(packed["pos"], packed["pos_min"], packed["pos_max"], pos_bits) | |
| features = _dequantise_linear(packed["features"], packed["features_min"], packed["features_max"], feature_bits) | |
| coeff = packed["coeff"] | |
| if coeff.size: | |
| coeff = _dequantise_linear(coeff, packed["coeff_min"], packed["coeff_max"], coeff_bits) | |
| else: | |
| coeff = np.empty((pos.shape[0], 0), dtype=np.float32) | |
| rest_names: List[str] = meta["rest_names"] | |
| rest_mode = meta.get("rest_mode", "pca" if int(meta.get("pca_components", 0)) > 0 else "none") | |
| rest_bits = int(meta.get("rest_bits", coeff_bits)) | |
| rest = None | |
| if rest_names and rest_mode == "pca": | |
| basis = packed["rest_basis"] | |
| mean = packed["rest_mean"] | |
| if coeff.size == 0: | |
| raise RuntimeError("Compressed file lacks PCA coefficients required to reconstruct f_rest.") | |
| rest = coeff @ basis + mean | |
| elif rest_names and rest_mode == "direct": | |
| rest = _dequantise_linear( | |
| packed["rest_direct"], | |
| packed["rest_direct_min"], | |
| packed["rest_direct_max"], | |
| rest_bits, | |
| ) | |
| arrays: Dict[str, np.ndarray] = {} | |
| pos_names: List[str] = meta["pos_names"] | |
| for idx, name in enumerate(pos_names): | |
| arrays[name] = pos[:, idx] | |
| offset = 0 | |
| other_names: List[str] = meta["other_names"] | |
| for name in other_names: | |
| arrays[name] = features[:, offset] | |
| offset += 1 | |
| if rest is not None: | |
| for idx, name in enumerate(rest_names): | |
| arrays[name] = rest[:, idx] | |
| dynamic_names: List[str] = meta["dynamic_names"] | |
| if dynamic_names: | |
| dynamic_arr = packed["dynamic"] | |
| for idx, name in enumerate(dynamic_names): | |
| arrays[name] = dynamic_arr[:, idx] | |
| normal_names: List[str] = meta["normal_names"] | |
| for name in normal_names: | |
| arrays[name] = np.zeros(pos.shape[0], dtype=np.float32) | |
| original_names: List[str] = meta["original_names"] | |
| missing = [name for name in original_names if name not in arrays] | |
| if missing: | |
| raise KeyError(f"Failed to rebuild fields {missing}; metadata may be incompatible.") | |
| _write_ply(output_path, arrays, original_names) | |
| def build_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser(description="Compress or decompress CC-4DGS point clouds.") | |
| subparsers = parser.add_subparsers(dest="command", required=True) | |
| comp = subparsers.add_parser("compress", help="Compress a binary PLY into a quantised NPZ archive.") | |
| comp.add_argument("input", type=Path) | |
| comp.add_argument("output", type=Path) | |
| comp.add_argument("--pos-bits", type=int, default=16) | |
| comp.add_argument("--feature-bits", type=int, default=8) | |
| comp.add_argument("--coeff-bits", type=int, default=12) | |
| comp.add_argument("--pca-components", type=int, default=16) | |
| comp.add_argument("--keep-percent", type=float, default=1.0) | |
| comp.add_argument("--top-k", type=int, default=None) | |
| comp.add_argument("--sort-by", type=str, default="opacity") | |
| comp.add_argument("--min-field-value", type=float, default=None) | |
| comp.add_argument("--keep-normals", action="store_false", dest="drop_normals") | |
| decomp = subparsers.add_parser("decompress", help="Rebuild a binary PLY from a compressed archive.") | |
| decomp.add_argument("input", type=Path) | |
| decomp.add_argument("output", type=Path) | |
| return parser | |
| def main() -> None: | |
| parser = build_parser() | |
| args = parser.parse_args() | |
| if args.command == "compress": | |
| compress_point_cloud( | |
| args.input, | |
| args.output, | |
| pos_bits=args.pos_bits, | |
| feature_bits=args.feature_bits, | |
| coeff_bits=args.coeff_bits, | |
| pca_components=args.pca_components, | |
| drop_normals=args.drop_normals, | |
| keep_percent=args.keep_percent, | |
| top_k=args.top_k, | |
| sort_by=args.sort_by, | |
| min_field_value=args.min_field_value, | |
| ) | |
| elif args.command == "decompress": | |
| decompress_point_cloud(args.input, args.output) | |
| else: | |
| parser.error(f"Unsupported command: {args.command}") | |
| if __name__ == "__main__": | |
| main() | |