Octree Indexing Point Clouds with Morton Codes
This page builds an octree over a LiDAR point cloud using nothing but NumPy and 3D Morton (Z-order) codes — quantising coordinates in EPSG:32618 into a power-of-two cube, interleaving the bits of x, y and z into one 64-bit key, sorting the cloud by that key once, and then answering node membership, box queries and level-of-detail sampling with integer shifts and binary search.
Why you hit this
Octrees underpin almost every point cloud format built for streaming: COPC and EPT are octrees, Potree is an octree, and 3D Tiles point cloud tilesets are usually generated from one. Understanding how they are addressed is the difference between treating those formats as black boxes and being able to debug a node that never loads, a hierarchy that is unbalanced, or a pipeline that spends an hour building an index a sort could have built in seconds. The formats themselves are compared in LAZ vs COPC vs EPT for point cloud delivery; this page is the index underneath them.
Prerequisites
numpy>=1.24andlaspy[lazrs]>=2.5.- A point cloud in a projected metric CRS — EPSG:32618+5703 in the examples. An octree over longitude and latitude has cells that are not cubes, which breaks every spacing assumption below.
- Familiarity with the 2D equivalent in computing quadkeys and tile bounds in Python: a Morton code is a quadkey with a third axis, written in binary instead of base four.
Step-by-Step
1. Quantise the cloud into a cube
import laspy
import numpy as np
las = laspy.read("harbour_block_07.laz")
xyz = np.column_stack([las.x, las.y, las.z]) # EPSG:32618+5703, metres
BITS = 21 # 21 bits per axis → 63-bit key
lo = xyz.min(axis=0)
edge = float((xyz.max(axis=0) - lo).max()) * 1.000001 # cube side: the largest extent
cell = edge / (1 << BITS)
q = np.floor((xyz - lo) / cell).astype(np.uint64)
q = np.minimum(q, (1 << BITS) - 1)
print(f"cube edge {edge:.2f} m, finest cell {cell * 1000:.3f} mm, {len(xyz):,} points")
An octree splits a cube, so the bounding box is expanded to the length of its longest side along all three axes. A 500 m × 500 m block that is only 60 m tall wastes most of its vertical subdivisions on empty air, which is normal — empty nodes cost nothing because they are never materialised. Twenty-one bits per axis gives a finest cell under a tenth of a millimetre on a 500 m cube, far finer than any scanner, and packs three axes into a single uint64.
2. Interleave the bits into Morton codes
def part1by2(v):
v = v.astype(np.uint64) & np.uint64(0x1FFFFF)
v = (v | (v << np.uint64(32))) & np.uint64(0x1F00000000FFFF)
v = (v | (v << np.uint64(16))) & np.uint64(0x1F0000FF0000FF)
v = (v | (v << np.uint64(8))) & np.uint64(0x100F00F00F00F00F)
v = (v | (v << np.uint64(4))) & np.uint64(0x10C30C30C30C30C3)
v = (v | (v << np.uint64(2))) & np.uint64(0x1249249249249249)
return v
def morton3(qx, qy, qz):
return part1by2(qx) | (part1by2(qy) << np.uint64(1)) | (part1by2(qz) << np.uint64(2))
codes = morton3(q[:, 0], q[:, 1], q[:, 2])
order = np.argsort(codes, kind="stable")
codes = codes[order]
xyz = xyz[order]
print("first codes:", [f"{c:#018x}" for c in codes[:3]])
part1by2 spreads the 21 bits of one coordinate out so that two zero bits sit between each original bit; the magic masks do that in six constant-time steps instead of a 21-iteration loop. Shifting y’s spread bits by one and z’s by two before OR-ing gives the interleaved pattern …z₁y₁x₁z₀y₀x₀. Every np.uint64(...) wrapper is there for a reason: shifting a uint64 array by a Python int promotes to float64 in older NumPy versions and silently destroys the high bits.
3. Address nodes by shifting
def node_key(codes, level, bits=BITS):
"""Octree node at `level` (0 = root) containing each code."""
return codes >> np.uint64(3 * (bits - level))
for level in (4, 6, 8):
keys, counts = np.unique(node_key(codes, level), return_counts=True)
size = edge / (1 << level)
print(f"level {level}: node edge {size:6.2f} m, {len(keys):6,} occupied nodes, "
f"max {counts.max():,} pts, median {int(np.median(counts)):,} pts")
Because the cloud is sorted by code and a node key is a prefix of the code, every point in a node sits in one contiguous run of the sorted arrays. That is the property the rest of the index relies on: a node’s points are a slice, found by binary search, with no tree structure stored anywhere.
4. Fetch a node’s points by binary search
def node_slice(codes, key, level, bits=BITS):
shift = np.uint64(3 * (bits - level))
start_code = np.uint64(key) << shift
end_code = (np.uint64(key) + np.uint64(1)) << shift
return slice(np.searchsorted(codes, start_code, "left"), np.searchsorted(codes, end_code, "left"))
level = 6
keys, counts = np.unique(node_key(codes, level), return_counts=True)
busiest = keys[np.argmax(counts)]
s = node_slice(codes, busiest, level)
print(f"node {busiest} at level {level}: points {s.start:,}–{s.stop:,} ({s.stop - s.start:,})")
assert s.stop - s.start == counts.max()
Two searchsorted calls on a sorted array of 50 million codes take microseconds. The assertion is the cheapest correctness test there is: the slice length has to equal the count from np.unique, and if the codes were not sorted, or a shift was computed in the wrong dtype, it will not.
5. Sample a level of detail per node
Streaming formats store a thinned subset of points at each coarse node and the rest deeper down. With a sorted Morton array, a simple, deterministic version is one point per finer grid cell per node.
def lod_sample(codes, level, per_axis_bits=4, bits=BITS):
"""Keep the first point in each of 8**per_axis_bits sub-cells of every node at `level`."""
sub_level = level + per_axis_bits
sub_keys = node_key(codes, sub_level, bits)
first = np.concatenate([[True], sub_keys[1:] != sub_keys[:-1]])
return np.flatnonzero(first)
for level in (2, 4, 6):
idx = lod_sample(codes, level)
spacing = edge / (1 << (level + 4))
print(f"LOD for level {level}: {len(idx):,} points, nominal spacing {spacing:.2f} m")
Because the array is sorted, “the first point in each sub-cell” is just every position where the sub-cell key changes — one vectorised comparison. At level L the sample has a nominal spacing of the node edge divided by 16, which gives a coarse-to-fine sequence whose spacing halves at each level, the structure a renderer’s screen-space-error test expects. Production writers like PDAL’s COPC writer choose the retained point more carefully, but the addressing is the same.
Expected Output & Verification
cube edge 512.37 m, finest cell 0.244 mm, 48,302,117 points
level 4: node edge 32.02 m, 214 occupied nodes, max 1,873,002 pts, median 188,404 pts
level 6: node edge 8.01 m, 3,120 occupied nodes, max 198,441 pts, median 11,907 pts
level 8: node edge 2.00 m, 39,516 occupied nodes, max 18,220 pts, median 891 pts
node 190472 at level 6: points 21,004,318–21,202,759 (198,441)
LOD for level 2: 38,114 points, nominal spacing 8.01 m
LOD for level 4: 402,882 points, nominal spacing 2.00 m
LOD for level 6: 4,118,760 points, nominal spacing 0.50 m
Verify that decoding a code returns the quantised coordinates exactly, and that each node’s points lie inside its geometric bounds:
def compact1by2(v):
v = v & np.uint64(0x1249249249249249)
v = (v ^ (v >> np.uint64(2))) & np.uint64(0x10C30C30C30C30C3)
v = (v ^ (v >> np.uint64(4))) & np.uint64(0x100F00F00F00F00F)
v = (v ^ (v >> np.uint64(8))) & np.uint64(0x1F0000FF0000FF)
v = (v ^ (v >> np.uint64(16))) & np.uint64(0x1F00000000FFFF)
v = (v ^ (v >> np.uint64(32))) & np.uint64(0x1FFFFF)
return v
qs = q[order]
assert np.array_equal(compact1by2(codes), qs[:, 0])
assert np.array_equal(compact1by2(codes >> np.uint64(1)), qs[:, 1])
assert np.array_equal(compact1by2(codes >> np.uint64(2)), qs[:, 2])
k = node_key(np.array([codes[s.start]]), level)[0]
nq = np.array([compact1by2(np.uint64(k) >> np.uint64(i)) for i in range(3)], dtype=np.float64)
node_lo = lo + nq * (edge / (1 << level))
pts = xyz[s]
assert np.all(pts >= node_lo - 1e-6) and np.all(pts <= node_lo + edge / (1 << level) + 1e-6)
print("round trip and node bounds verified")
Common Errors
Codes are not unique and neighbouring points get the same code. Quantisation was too coarse — BITS set low to save space, or the cube edge computed from one axis only. Duplicates are legitimate at the finest level for coincident returns, but more than a fraction of a percent means the grid is coarser than the data.
OverflowError or codes wrapping to small values. A shift was applied with a Python integer to a uint64 array, or the quantised values exceeded 21 bits because the maximum coordinate was not clamped. Keep every shift and mask as np.uint64 and clamp to (1 << BITS) - 1.
Node slices are empty for nodes that np.unique reports. The arrays were sorted by code but xyz was not reordered with the same permutation, or the codes array was re-sorted after the points were. Sort once, apply the same order to everything, and keep the assertion from step 4 in the pipeline.
Frequently Asked Questions
How does this relate to COPC and EPT keys?
Both address nodes by level and integer x, y, z within that level — D-X-Y-Z — which is the same information as a Morton node key, unpacked. Decoding a node key with compact1by2 at a given level yields exactly those X, Y, Z values.
Can Morton codes answer nearest-neighbour queries?
Approximately. Points close in code are close in space, but not every spatially close point is close in code, because Z-order jumps at node boundaries. Use the Morton sort to partition the data, then a KD-tree within and across adjacent nodes for exact neighbours.
Why not just use a KD-tree for everything?
A KD-tree is excellent in memory and has no natural serialisation into streamable, independently loadable chunks. An octree over Morton codes maps directly onto files and byte ranges, which is what delivery formats need.
Related Guides
- Building an R-Tree Index for 3D Tile Lookup — indexing tiles rather than points
- Choosing Between S2, H3 and Geohash for 3D Data — global cell systems compared
- Converting Point Clouds to 3D Tiles with py3dtiles — an octree-based tiler in practice