LAS, LAZ and COPC Explained: How Point Clouds Are Stored
Problem statement
A point cloud file is a header plus a long run of fixed-width records. That layout makes it fast to read from start to finish and slow to answer the question everyone actually asks: give me the points in this box.
The formats exist on a ladder that trades compression and indexing against simplicity:
- LAS β the uncompressed base format. Simple, large, no spatial index.
- LAZ β LAS compressed in chunks. Typically 5β10Γ smaller, still no spatial index.
- COPC β LAZ with an octree index inside it, so a bounding-box query at a chosen resolution is a range request.
- EPT β an octree stored as a directory of many small LAZ files plus a JSON hierarchy.
Measured on a real 3DEP survey: 12,968,770 points over 877 Γ 876 m arrived as 287 LAZ files totalling 82.8 MB β 6.4 bytes per point on disk, against 34 bytes per point for the same records uncompressed.
Quick answer
import laspy
las = laspy.read("tile.laz")
h = las.header
print(f"LAS {h.version}, point format {h.point_format.id}")
print(f"{h.point_count:,} points")
print(f"scales {h.scales}")
print(f"offsets {h.offsets}")
print(f"raw X range {las.X.min()} .. {las.X.max()} (int32)")
print(f"scaled x {las.x.min():.2f} .. {las.x.max():.2f}")
LAS 1.2, point format 1
180,756 points
scales [0.01 0.01 0.01]
offsets [-1.73345e+07 8.07826e+06 4.86000e+02]
raw X range -802212 .. -640954 (int32)
scaled x -17342522.12 .. -17340909.54
las.X and las.x are different attributes: the raw 32-bit integer and the scaled coordinate. Using the wrong one is off by a factor of 100 here, and by 1,000 in files with millimetre scaling.
Step-by-step solution
1. Understand why coordinates are integers
LAS stores X, Y and Z as signed 32-bit integers, with a scale and offset per axis in the header:
position = raw_integer * scale + offset
That is a deliberate design. Integers of a fixed scale give uniform precision across the file β every coordinate is exact to 1 cm here β whereas floating point gives relative precision, so a coordinate of 17 million metres would lose resolution exactly where a projected CRS puts you.
The cost is a fixed range. With a 0.01 scale, a signed 32-bit integer spans about Β±21 million, which is why the offset exists: it moves the origin near the data.
2. Know what the point format determines
The point format ID fixes which attributes exist. Format 1 adds GPS time to the basic format 0; formats 2 and 3 add RGB; formats 6 and above (LAS 1.4) extend the return numbering and the classification range.
print([d.name for d in las.point_format.dimensions])
['X', 'Y', 'Z', 'intensity', 'return_number', 'number_of_returns',
'scan_direction_flag', 'edge_of_flight_line', 'classification',
'synthetic', 'key_point', 'withheld', 'scan_angle_rank', 'user_data',
'point_source_id', 'gps_time', 'OriginId']
An older format cannot hold more than 5 returns per pulse or classification codes above 31. If you need those, the file must be LAS 1.4 with point format 6 or higher.
3. Know what LAZ does and does not add
LAZ compresses the point records in independent chunks, typically 50,000 points each. Compression is lossless and typically gets a factor of 5β10 β here 82.8 MB for records that would occupy about 440 MB uncompressed.
What LAZ does not add is a spatial index. Finding the points in a box still means decompressing every chunk and testing. The chunking helps only in that a reader can skip whole chunks whose declared bounds miss the query, if the writer recorded them.
4. Use COPC or EPT when you need spatial queries
COPC stores the points in octree order inside a single LAZ file, with a hierarchy that maps each octree node to a byte range. A viewer can then fetch exactly the nodes overlapping the view, at exactly the depth the zoom level needs, using HTTP range requests.
EPT stores the same idea as a directory: ept.json, a hierarchy of JSON files, and one LAZ per node. The survey measured here is EPT, which is why it arrived as 287 separate files.
depth 0: 1 node, 19,873 points
depth 3: 31 nodes, 1,857,900 points
depth 5: 317 nodes, 44,739,745 points
depth 7: 4,720 nodes, 281,081,360 points
Each depth is a complete, coarser sample of the whole cloud β so a low-resolution overview costs one small node rather than the whole file.
5. Check the CRS, and do not assume it is metric
The survey measured here is stored in EPSG:3857, Web Mercator. Its extent reads as 1,613 Γ 1,613 m and the ground truth is 877 Γ 876 m β a factor of 1.92 at 58.6Β° north.
Every metric quantity computed in those units is wrong. See laspy returns the wrong coordinates.
Code examples
Example 1 β a header report before you read any points
import laspy
import numpy as np
def header_report(path):
"""Everything the header knows, without decompressing the points."""
with laspy.open(path) as reader:
h = reader.header
print(f" LAS {h.version}, point format {h.point_format.id}")
print(f" {h.point_count:,} points")
print(f" scales {h.scales}")
print(f" offsets {h.offsets}")
print(f" bounds x {h.mins[0]:.2f}..{h.maxs[0]:.2f}")
print(f" y {h.mins[1]:.2f}..{h.maxs[1]:.2f}")
print(f" z {h.mins[2]:.2f}..{h.maxs[2]:.2f}")
crs = h.parse_crs()
print(f" CRS {crs.to_string() if crs else 'NOT SET'}")
if crs and not crs.is_projected:
print(" ! geographic CRS β reproject before any metric computation")
extent = (h.maxs[0] - h.mins[0]) * (h.maxs[1] - h.mins[1])
print(f" nominal density {h.point_count / extent:.1f} per square unit"
f" (units of the file CRS)")
return h
Opening with laspy.open reads the header only. On an 80 MB file that is instant, against seconds to decompress every point β worth it when you are surveying a directory of tiles.
Example 2 β reading in chunks, so a large file fits
import laspy
import numpy as np
def chunked_stats(path, chunk_size=1_000_000, ground_class=2):
"""Stream a large LAZ without materialising it."""
total = 0
z_min, z_max = np.inf, -np.inf
class_counts = {}
with laspy.open(path) as reader:
for points in reader.chunk_iterator(chunk_size):
total += len(points)
z = np.asarray(points.z)
z_min = min(z_min, z.min())
z_max = max(z_max, z.max())
codes, counts = np.unique(points.classification, return_counts=True)
for code, count in zip(codes.tolist(), counts.tolist()):
class_counts[code] = class_counts.get(code, 0) + count
print(f" {total:,} points, z {z_min:.2f}..{z_max:.2f}")
for code, count in sorted(class_counts.items(), key=lambda kv: -kv[1]):
print(f" class {code:2d}: {count:12,} {count / total:6.2%}")
return class_counts
A 13-million-point cloud is 311 MB as float64 coordinates alone. Streaming keeps memory flat, and for anything that reduces β histograms, grids, counts β costs nothing.
Example 3 β merging a directory of tiles safely
import glob
import laspy
import numpy as np
def merge_tiles(pattern, max_points=None):
"""Concatenate tiles, checking that they agree on scale and CRS."""
files = sorted(glob.glob(pattern))
if not files:
raise FileNotFoundError(pattern)
with laspy.open(files[0]) as reader:
reference = reader.header
scales, crs = reference.scales, reference.parse_crs()
xs, ys, zs, cls, ret, nret = [], [], [], [], [], []
total = 0
for path in files:
las = laspy.read(path)
if not np.allclose(las.header.scales, scales):
raise ValueError(f"{path}: scales {las.header.scales} "
f"differ from {scales}")
if las.header.parse_crs() != crs:
raise ValueError(f"{path}: CRS differs from the first tile")
xs.append(las.x); ys.append(las.y); zs.append(las.z)
cls.append(np.asarray(las.classification))
ret.append(np.asarray(las.return_number))
nret.append(np.asarray(las.number_of_returns))
total += len(las.points)
if max_points and total >= max_points:
break
print(f" merged {len(xs)} tiles, {total:,} points")
return (np.concatenate(xs), np.concatenate(ys), np.concatenate(zs),
np.concatenate(cls), np.concatenate(ret), np.concatenate(nret))
merged 287 tiles, 12,968,770 points
Checking the scales matters more than it looks. Tiles written with different scales still merge without error in x (scaled) space, and produce a cloud that cannot be written back to a single LAS file without re-scaling.
Explanation
Why fixed-point integers instead of floats
Floating point has relative precision: a float32 has about 7 significant digits, so at a UTM easting of 500,000 m it resolves about 3 cm, and at a Web Mercator easting of 17,000,000 m about 1 m.
Scaled integers have absolute precision. Every coordinate in the file measured here is exact to 1 cm regardless of position, because the offset moves the origin next to the data and the integer counts centimetres from there.
The trade is a bounded range and a mandatory header lookup. A reader that ignores the scale and offset gets numbers that are wrong by a factor of 100 and look plausible.
Why LAZ does not make queries fast
Compression reduces bytes; it does not reorganise. In a LAZ file the points are still in acquisition order, so the points inside any bounding box are scattered through every chunk.
The consequence is that "read a small area from a big file" costs almost as much as reading the whole file. That is exactly the operation a web viewer, a tiling job or an interactive tool needs, which is why the indexed formats exist.
Why an octree is the right index
Point clouds need two things from an index: spatial selection and level of detail. An octree gives both from one structure.
Each node holds a sample of the points in its cube, and its children hold finer samples of the eight sub-cubes. Reading to depth 3 gives a coarse view of everything; descending only where the viewport is gives detail where it is needed.
That is why the depth counts above rise to a peak and then fall: the deepest level holds only the leftover points in the densest regions, not a uniform layer.
Why merging tiles is not always safe
Adjacent tiles from one survey normally share a CRS, scale and offset. Tiles from different deliveries, or reprocessed at different times, may not.
The failure is silent. Scaled coordinates merge fine, and only the write-back fails β or worse, succeeds with a re-scaled precision that quietly loses a decimal place. Assert on scales and parse_crs() at merge time.
Edge cases or notes
las.Xis the raw integer;las.xis scaled. Off by 100 or 1,000.- The offset is not the minimum. It is an arbitrary origin near the data.
- Point format limits the attributes. Formats below 6 cap returns at 5 and classes at 31.
- LAZ has no spatial index. Use COPC or EPT for bounding-box queries.
- CRS may be missing entirely.
parse_crs()returningNoneis common in older files. - Check the CRS is projected before computing anything metric.
- Stream large files with
chunk_iterator; 13 M points is 311 MB of coordinates alone. - Assert scale and CRS agreement when merging tiles.
Internal links
- LiDAR point clouds explained: returns, classes and intensity β what the records contain
- How to read a LAS or LAZ file in Python with laspy β the practical reading guide
- laspy returns the wrong coordinates: scale, offset and CRS β the integer trap in detail
- A LAZ file will not open in Python β backend and version errors
- How to clip and tile a point cloud in Python β working around the missing index
- Point density explained β what the density figure needs
- Cloud-optimised GeoTIFF explained β the same indexing idea for rasters
- GIS vector file formats compared β the equivalent ladder for vector data
FAQ
What is the difference between LAS and LAZ?
LAZ is losslessly compressed LAS, typically 5β10Γ smaller. It adds no spatial index, so bounding-box queries still cost a full read.
What is COPC?
Cloud-Optimised Point Cloud: a LAZ file whose points are stored in octree order with a byte-range index, so a viewer can fetch just the region and resolution it needs over HTTP.
Why are point cloud coordinates stored as integers?
For uniform absolute precision. raw * scale + offset gives every point the same resolution regardless of position, which floating point cannot at large coordinate values.
Why is las.X different from las.x?
X is the raw 32-bit integer as stored; x applies the header's scale and offset. Confusing them is off by a factor of 100 or 1,000.
How do I read a huge LAZ file without running out of memory?
Use laspy.open(...).chunk_iterator(n) and reduce as you go. A 13-million-point cloud is 311 MB of coordinates alone as float64.
Can I merge LAS tiles by concatenating them?
Only after checking that they share a CRS, scale and offset. Mismatched scales merge silently in scaled space and fail or lose precision on write-back.
What is EPT?
Entwine Point Tile: an octree stored as a directory of small LAZ files plus a JSON hierarchy. The 3DEP survey used here arrived as 287 such files.