Point-Cloud Versioning and Branching Strategies

Tile, hash, and branch dense LiDAR and photogrammetric point clouds so that a re-classification or a new flight line commits megabytes, not gigabytes — part of Choosing Formats & Tools for Spatial Data Versioning.

Point clouds break most of the assumptions that make vector and raster versioning tractable. A .laz file has no stable row identity, no fixed topology, and no natural diff unit: it is a bag of tens of millions of unordered (X, Y, Z, intensity, classification, return) tuples whose byte layout changes completely when a single point is edited. Store one naively in Git LFS or a versioning tool and every commit re-uploads the whole file, because the content hash of the payload flips on any change. The storage bill and the checkout latency both grow linearly with the number of versions, which is exactly the failure mode versioning is supposed to prevent.

This guide lays out a tiling-first strategy that restores a workable change unit. You split the cloud into an octree of spatial tiles, hash each tile canonically, and version a lightweight manifest so that only the tiles that actually changed are re-stored. On top of that substrate you branch by acquisition, flight line, or processing stage, and you diff two versions by classification rather than by byte. It targets GIS teams and data engineers working with PDAL, laspy, and cloud-optimized formats such as COPC and Entwine/EPT.


Prerequisites & Environment Setup

This workflow assumes a Python 3.10+ toolchain with PDAL bound in, plus a content-addressed store for the tile payloads. Confirm the following before you tile anything:

Install the core stack from conda-forge, which resolves the native GDAL/PROJ/laz-perf chain that trips up pip:

mamba create -n pcver -c conda-forge python=3.11 pdal python-pdal laspy lazrs numpy scipy
mamba activate pcver
pdal --version
python -c "import laspy, pdal, scipy; print('point-cloud versioning stack ready')"

Pin the tiling parameters once, in a committed config file, and never mutate them mid-project. A shifted origin re-cuts every tile boundary, which changes every hash and defeats the whole incremental scheme — the same discipline that content-addressed pointer synchronization for raster datasets relies on for tiled rasters.


Core Algorithmic Patterns

Three data structures do the heavy lifting: an octree tiling scheme that defines the change unit, a canonical per-tile hash that makes tiles comparable, and a nearest-neighbour matcher that turns two point sets into a semantic diff.

1. Octree / COPC Tiling — the change unit

An octree recursively subdivides 3D space into eight children per node until each leaf holds a manageable point budget. This is the native structure of cloud-optimized formats: a COPC (Cloud Optimized Point Cloud) file stores exactly this hierarchy inside one .laz, and an Entwine/EPT (Entwine Point Tile) dataset materializes it as a directory tree of nodes. For versioning you do not need the full adaptive octree; a fixed-depth grid of square tiles is easier to reason about and gives stable, reproducible boundaries.

The key property is locality: an edit to points in one region touches only the leaves covering that region. Building the grid is O(n) — a single pass assigning each point to a tile index by integer division of its coordinates. Choose the leaf point budget so a tile holds 1–8 million points; that band keeps per-tile hashing cheap while keeping the manifest small.

2. Spatial Hashing of Tiles — the content address

A tile hash must be invariant to point order, because no two acquisitions and no two exports write points in the same sequence. The pattern is: extract the tile’s coordinate and attribute arrays, sort them into a canonical order (lexicographically by quantized X, Y, Z), then feed the sorted bytes to SHA-256. Sorting costs O(m log m) per tile of m points, which dominates the hash but is still trivial next to I/O. The output is a content address that changes if and only if the tile’s points, positions, or classifications changed — the same content-addressing principle behind understanding pointer files in GeoGit vs DVC, applied at tile granularity.

Hash input Order-invariant? Detects a re-classification? Notes
Raw LAZ bytes No Yes, but re-stores whole file Every edit flips the hash; useless as a change unit
Unsorted XYZ array No No Re-export reshuffles points → false positive
Sorted quantized XYZ Yes No Detects geometry change only
Sorted quantized XYZ + classification Yes Yes Recommended: catches moves and reclassification

3. Classification-Aware Diff — the merge signal

Byte diffs are meaningless here. A correct point-cloud diff matches points between two versions by proximity, then partitions the result into three sets: added points present only in the new version, removed points present only in the old, and reclassified points that matched on position but changed classification, intensity, or return attributes. Matching uses a k-d tree built on one version, queried by the other within a tolerance radius set to roughly 1.5× the nominal point spacing to absorb GNSS drift without merging genuinely distinct points. This is the spatial-diff analogue, for unordered 3D data, of the tolerance-aware residual matching described in delta compression techniques for LiDAR point clouds.

A tight grouping of returns off a thin feature — a power line, a fence, a building edge — should register as a coherent change, not as scattered noise, so the diff reports connected components of added or removed points above a minimum size rather than raw per-point deltas.


Production Workflow Implementation

The end-to-end flow tiles a source .laz, hashes every tile, writes a manifest, commits only the tiles whose hash changed, and lands the whole thing on an acquisition branch. The diagram shows how the octree tiling feeds per-tile hashing and rolls up into the committed manifest.

Octree tiling to per-tile hash to manifest A source LAZ file is split by an octree into spatial tiles; each tile is canonically hashed; the hashes roll up into a manifest committed to Git while tile blobs go to the payload store; branches per acquisition merge into main. Source .laz Octree tiling tile 0,0 tile 1,0 tile n,m sort + SHA-256 a1f9… sort + SHA-256 7c02… sort + SHA-256 be44… Manifest → Git Tile blobs → DVC / S3 branch per acquisition merges into main

Step 1 — Tile a LAZ into a fixed spatial grid

Use PDAL’s filters.chipper to cut the cloud into tiles of a bounded point count, or a fixed metric grid for reproducible bounds. The snippet below reads a .laz, splits it, and writes one tile file per cell keyed by its grid index.

import pdal
import json

TILE_CAPACITY = 3_000_000   # target points per tile

def tile_laz(src_path: str, out_dir: str) -> None:
    """Split a LAZ into per-tile LAZ files using PDAL's chipper filter."""
    pipeline = {
        "pipeline": [
            src_path,
            {"type": "filters.chipper", "capacity": TILE_CAPACITY},
            {
                "type": "writers.las",
                "filename": f"{out_dir}/tile_#.laz",
                "compression": "laszip",
                "forward": "all",   # preserve CRS, scale, offset from source
            },
        ]
    }
    pdal.Pipeline(json.dumps(pipeline)).execute()

The # placeholder makes PDAL emit tile_0.laz, tile_1.laz, and so on. forward: "all" carries the source header — CRS, scale, and offset — into every tile so the pieces reassemble losslessly.

Step 2 — Hash each tile canonically

Read every tile, sort its points into a deterministic order, and hash the coordinate and classification arrays together. Order-invariance is the whole point: the same tile exported twice must produce the same hash.

import laspy
import numpy as np
import hashlib
from pathlib import Path

SCALE = 1000   # quantize to millimetres before sorting/hashing

def tile_hash(tile_path: str) -> str:
    """Order-invariant content hash of a tile's geometry + classification."""
    las = laspy.read(tile_path)
    xyz = np.column_stack([
        np.round(las.x * SCALE).astype(np.int64),
        np.round(las.y * SCALE).astype(np.int64),
        np.round(las.z * SCALE).astype(np.int64),
        las.classification.astype(np.int64),
    ])
    # Canonical order: lexicographic sort makes the hash independent of file order
    order = np.lexsort((xyz[:, 3], xyz[:, 2], xyz[:, 1], xyz[:, 0]))
    canonical = np.ascontiguousarray(xyz[order])
    return hashlib.sha256(canonical.tobytes()).hexdigest()

Step 3 — Write a tile manifest

Roll every tile’s hash, bounds, and point count into a JSON manifest. This small text file is what Git tracks; the heavy .laz blobs go to the payload store keyed by their hash.

def build_manifest(tile_dir: str) -> dict:
    """Produce a hash-keyed manifest for every tile in a directory."""
    manifest = {"tiles": {}}
    for tile_path in sorted(Path(tile_dir).glob("tile_*.laz")):
        las = laspy.read(str(tile_path))
        h = tile_hash(str(tile_path))
        manifest["tiles"][tile_path.name] = {
            "hash": h,
            "point_count": int(las.header.point_count),
            "bounds": [float(las.x.min()), float(las.y.min()),
                       float(las.x.max()), float(las.y.max())],
        }
    return manifest

Step 4 — Commit only changed tiles

Diff the new manifest against the previous commit’s manifest and push only tiles whose hash changed. Unchanged tiles are already in the store under the same key, so they cost nothing.

def changed_tiles(old: dict, new: dict) -> list[str]:
    """Return tile names whose hash differs (or which are new)."""
    old_hashes = {name: t["hash"] for name, t in old.get("tiles", {}).items()}
    return [
        name for name, t in new["tiles"].items()
        if old_hashes.get(name) != t["hash"]
    ]

# push_to_store() uploads only these blobs, keyed by hash, to DVC or S3
# then `git add manifest.json && git commit` records the new state

This is the same content-addressed economy that large file handling in DVC for GIS applies to whole datasets, pushed down to the tile so a one-corner edit no longer re-stores the whole cloud.

Step 5 — Branch per acquisition

Create a branch named for each survey pass, land its tiles and manifest there, then merge into main behind a classification-aware diff review. A branch is just a manifest state, so branching is cheap:

git switch -c acq/2026-07-10-flight-north
# run steps 1-4 for the new acquisition, commit the manifest
git switch main
git merge --no-ff acq/2026-07-10-flight-north   # gate this merge on a diff review

Wire the PDAL side of this — the pinned, reproducible read/filter/write pipeline that feeds step 1 — through the companion Integrating PDAL Pipelines into a Versioning Workflow page, and gate the step-5 merge on the visual and geometric review described in Reviewing Point-Cloud Diffs with CloudCompare.


Code Reliability Patterns

Quantize before you hash, always. Raw float coordinates carry IEEE-754 noise that differs across platforms and export paths, so two identical tiles can hash differently. Round to integer millimetres (or whatever your survey precision warrants) before sorting. Store the scale factor in the manifest so the quantization is reproducible.

Guard the tiling origin. A shifted origin re-cuts every boundary and invalidates every hash. Assert at pipeline start that the configured origin and edge length match the values recorded in the manifest header; abort loudly on mismatch rather than silently re-storing the whole dataset.

Stage, verify, then promote. Never overwrite the canonical manifest in place. Write the new manifest and blobs to a staging area, re-read them, re-hash a sample of tiles to confirm the store round-trips, and only then commit. This mirrors the rollback discipline used across spatial merge pipelines.

def verify_roundtrip(manifest: dict, tile_dir: str, sample: int = 5) -> None:
    """Re-hash a sample of tiles and assert they match the manifest."""
    import random
    names = random.sample(list(manifest["tiles"]), min(sample, len(manifest["tiles"])))
    for name in names:
        recomputed = tile_hash(f"{tile_dir}/{name}")
        expected = manifest["tiles"][name]["hash"]
        assert recomputed == expected, f"tile {name} failed round-trip verification"

Handle empty and single-point tiles. Sparse survey edges produce tiles with a handful of points; np.lexsort and hashing still work, but guard against zero-point tiles from over-aggressive chipping, which should be dropped from the manifest rather than hashed as empty.


Performance & Scale Considerations

The dominant cost is not hashing but reading and re-writing tiles, so tile size is the master tuning knob. Smaller tiles isolate change more finely but multiply per-file open/close overhead and bloat the manifest; larger tiles cut overhead but re-store more untouched data whenever any point inside them changes.

The benchmark below tiles a 180-million-point airborne acquisition (roughly 15 points/m², one UTM zone) on a 16-core workstation and then commits a re-classification that touched 4% of the area. “Re-stored” is the data volume pushed to the payload store for that one incremental commit.

Tile edge Points / tile Tiles Full hash pass Manifest size Re-stored on 4% edit
1000 m ~15 M 48 41 s 9 KB 620 MB
500 m ~3.8 M 190 46 s 34 KB 210 MB
250 m ~0.9 M 760 58 s 135 KB 74 MB
125 m ~0.24 M 3040 92 s 540 KB 41 MB

The 250–500 m band is the sweet spot for this density: re-store volume drops sharply as tiles shrink from 1000 m, but below 250 m the manifest and I/O overhead start to dominate while the re-store savings flatten. Parallelize the hash pass across cores with concurrent.futures.ProcessPoolExecutor, since each tile hashes independently, and cache the k-d tree used for diffing when comparing one baseline against many candidate acquisitions.


Troubleshooting & Failure Modes

Symptom Root cause Fix
Every tile hash changes after a re-export with no edits Hashing raw float coords or unsorted arrays; float noise or reordering flips the hash Quantize to integer millimetres and np.lexsort before hashing so the hash is order- and noise-invariant
Incremental commit re-stores nearly the whole cloud Tiling origin or edge length changed between runs, re-cutting every boundary Pin origin + edge length in committed config; assert they match the manifest header before tiling
Diff reports millions of “added” and “removed” points on an unchanged site Match tolerance tighter than inter-acquisition GNSS drift, so matched points fail to pair Set the k-d tree radius to 1.5–2× nominal point spacing to absorb registration residual
Reclassification shows up as add + remove instead of “reclassified” Diff matches on full attribute vector including classification, so a class change breaks the match Match on position only, then compare classification of the matched pair to detect reclassification
PDAL chipper emits a huge number of tiny tiles capacity set far below tile point budget, or sparse edges Raise filters.chipper capacity, or post-merge sub-threshold tiles before hashing
COPC file re-stored whole on every commit Treating the COPC as an opaque blob rather than hashing its node hierarchy Hash per COPC octree node and manifest the node hashes so only changed nodes re-store

FAQ

Why hash tiles instead of the whole LAZ file?

A single edited point rewrites the entire LAZ file, so a whole-file hash always changes and forces you to re-store gigabytes. Tiling isolates change: only the tiles containing edited points get new hashes, so a re-classification touching one corner of a survey pushes a few megabytes instead of the full cloud. The manifest, meanwhile, stays small enough to live comfortably in Git.

How should I choose a tile size for point-cloud versioning?

Aim for 1–8 million points per tile. Smaller tiles isolate change more precisely but explode the manifest and per-tile I/O overhead; larger tiles cut overhead but re-store more unchanged data on every edit. For airborne LiDAR at 10–20 points per square metre, a 250 m or 500 m square tile usually lands in that range — confirm against your own density with the benchmark table above.

What does a point-cloud diff actually compare?

A useful diff reports three sets: points added, points removed, and points whose classification or attributes changed while their position stayed fixed. Matching is done by nearest-neighbour within a tolerance rather than by array index, because acquisitions never store points in the same order and GNSS drift shifts every coordinate slightly. The tolerance should track your point spacing.

Should each flight line be its own branch?

Branch at the granularity your team reviews at. Per-acquisition branches suit most projects: one branch per survey date or campaign. Per-flight-line branches make sense only when individual lines are processed and approved independently, because you then merge many small branches and the review overhead multiplies. Processing-stage branches — raw, classified, thinned — are a useful third axis when a cloud passes through distinct approval gates.

Can I version COPC files directly with Git?

Not the bytes — a COPC file is large and binary, so it belongs in DVC or object storage with a pointer in Git. But COPC already embeds an octree, so you can hash its node hierarchy instead of re-tiling. Commit the manifest of node hashes to Git and let the version store hold the payload, which gives you the same incremental economy without a separate tiling step.


Back to Choosing Formats & Tools for Spatial Data Versioning