Load a Point Cloud Layer in PyQGIS
Opening a point cloud from Python is one line, and the line is different for each of the three formats QGIS supports. Get the provider key wrong and you get an invalid layer with a message that does not name the real problem. Get it right on an unindexed file and the layer is valid, empty for the first few seconds, and quietly rebuilding an index that it will rebuild again the next time the script runs.
This recipe belongs to Point Cloud & LiDAR Workflows in PyQGIS. It covers choosing the provider, verifying that the layer is genuinely usable rather than merely valid, dealing with a missing CRS, and structuring a script so the indexing cost is paid once.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer, built with PDAL support — every official installer is.
- A LiDAR file: a
.copc.laz, an Entwineept.json, or a plain.las/.laz. - Write access to somewhere you can keep converted copies, if the delivery is unindexed.
Open the layer with the right provider
QgsPointCloudLayer takes the same three arguments as every other layer class, and the third one carries all the weight.
from qgis.core import QgsPointCloudLayer, QgsProject
cloud = QgsPointCloudLayer(
"/data/lidar/tile_0345.copc.laz",
"tile_0345",
"copc",
)
if not cloud.isValid():
raise SystemExit(cloud.error().summary() or "layer failed to load")
QgsProject.instance().addMapLayer(cloud)
Breakdown: Unlike QgsVectorLayer, where "ogr" covers almost everything, there is no general-purpose point cloud provider — copc, ept and pdal are three separate readers with three separate URI conventions. error().summary() is worth printing rather than a generic message, because the two common failures ("Cannot open file" and "Unsupported provider") point at completely different fixes. Adding to the project is optional: an algorithm will happily take a layer that was never added, and a headless script normally should not add it.
Deriving the key from the path keeps a batch script honest:
import os
def provider_for(path):
lower = path.lower()
if lower.endswith(".copc.laz"):
return "copc"
if os.path.basename(lower) == "ept.json":
return "ept"
if lower.endswith((".las", ".laz")):
return "pdal"
raise ValueError(f"not a point cloud path: {path}")
cloud = QgsPointCloudLayer(path, os.path.basename(path), provider_for(path))
Breakdown: Testing .copc.laz before .laz matters, because a COPC file ends in both and the plain pdal provider will open it — treating an already-indexed file as unindexed and rebuilding an index it already has. The ept case keys on the filename rather than an extension, since the URI you pass is the metadata file inside the index directory, not the directory itself.
Valid is not the same as ready
A layer built over an unindexed file reports isValid() immediately, because the header parsed. The octree, though, is being built on a background thread, and until it finishes the layer has an extent and a point count but nothing to draw. In an interactive session you see this as a cloud that appears a few seconds after everything else. In a script it is worse: the render or the export runs against an index that does not exist yet.
The reliable answer is to stop loading unindexed files in scripts at all. Convert once, then load the converted copy:
import os
import processing
def ensure_copc(source, cache_dir):
target = os.path.join(
cache_dir,
os.path.splitext(os.path.basename(source))[0] + ".copc.laz",
)
if not os.path.exists(target):
processing.run("pdal:createcopc", {"INPUT": source, "OUTPUT": target})
return target
path = ensure_copc("/data/delivery/tile_0345.laz", "/data/indexed")
cloud = QgsPointCloudLayer(path, "tile_0345", "copc")
Breakdown: The existence check makes the function idempotent, so a batch job that is re-run after a failure skips everything it already converted — the same pattern as any other cached derivation. pdal:createcopc is synchronous inside processing.run(), which is exactly the property the background indexer lacks: when the call returns, the index is on disk and complete. Keep the cache directory outside the delivery folder so a re-delivery does not mix converted and original files.
Fixing a missing or wrong CRS
LAS stores its projection in a variable-length record, and plenty of producers write something QGIS cannot interpret. The symptom is a layer that loads fine and sits at coordinates that make no sense next to the rest of the project.
crs = cloud.crs()
print("authid:", crs.authid() or "(none)")
print("extent:", cloud.extent().toString(1))
If the authid is empty, do not set the CRS on the layer object and move on — that only relabels the layer in memory and the next script to open the file has the same problem. Write it into the file:
processing.run("pdal:assignprojection", {
"INPUT": "/data/indexed/tile_0345.copc.laz",
"CRS": "EPSG:27700",
"OUTPUT": "/data/indexed/tile_0345_crs.copc.laz",
})
Breakdown: pdal:assignprojection declares a projection without moving any coordinates, which is what you want when the numbers are right and only the label is missing. Its sibling pdal:reproject actually transforms the coordinates and is what you want when the file is genuinely in the wrong system. Confusing the two produces a cloud that is wrong by the distance between two projections, and the mistake is invisible until you overlay something else — the same distinction that applies to handling a missing CRS on a vector layer.
Loading a whole survey as one layer
Adding forty tiles to a project gives you forty entries in the layer tree and forty things to iterate. A virtual point cloud collapses them into one.
import glob
tiles = sorted(glob.glob("/data/indexed/*.copc.laz"))
processing.run("pdal:virtualpointcloud", {
"LAYERS": tiles,
"OUTPUT": "/data/indexed/survey.vpc",
})
survey = QgsPointCloudLayer("/data/indexed/survey.vpc", "survey", "pdal")
print(survey.pointCount(), "points across", len(tiles), "tiles")
Breakdown: The .vpc is a small JSON manifest holding each member's path, extent and point count, so building it is fast and reading it is faster. Note the provider key: a virtual point cloud is opened through pdal, not copc, because the manifest is not itself an octree. Because the manifest carries per-tile extents, a clip or an export against this layer opens only the tiles that intersect the requested area — which is the entire reason to build it.
QGIS version compatibility
| Version | Point cloud support |
|---|---|
| 3.18 | QgsPointCloudLayer and the EPT provider arrive |
| 3.26 | COPC provider; subset strings on point cloud layers |
| 3.32 | The pdal: Processing algorithms, including createcopc |
| 3.34 LTR | Virtual point clouds (.vpc); the baseline for everything here |
| 3.40 LTR | Faster index reads; pdal: parameter names unchanged |
Code written against the pdal: algorithms and the layer-level API above runs unchanged from 3.34 onwards. The low-level index classes did change names between 3.34 and 3.40, which is the reason this recipe does not use them.
Troubleshooting
isValid()is False on a file that opens in the QGIS browser. The provider key is wrong — most oftenpdalwas passed for a.copc.laz, oreptwas passed for the index directory rather than theept.jsoninside it.- The layer loads but draws nothing. Either the index is still building (see above), or the point budget is exhausted by another dense layer in the project.
- The extent is a tiny box near zero. The header has no CRS and no scale, or the file is a header-only stub from an interrupted transfer.
- The cloud is in the sea off West Africa. No CRS on the header. Fix it with
pdal:assignprojectionrather than by setting the CRS on the layer. pdal:createcopcfails on a file that loads fine. The file is a LAS 1.4 variant with a point format PDAL will not write to COPC; convert withpdal:convertformatfirst.- Every run of the script takes minutes before anything happens. Unindexed inputs. Convert once and cache, as above.
Conclusion
Pick the provider key from the file, treat isValid() as the start of the check rather than the end, and never let a production script open an unindexed file. Convert deliveries to COPC once, build a virtual point cloud over the tiles, and the rest of the work in this guide's other recipes starts from a layer that is ready the instant it is constructed.
Frequently Asked Questions
Can I load a point cloud from a URL? Yes, for COPC and EPT, which are both designed for range requests over HTTP. Pass the URL as the URI with the same provider key. Throughput depends entirely on the server honouring range requests; one that does not will download the whole file for every pan.
Does QGIS support LAS 1.4 and the newer point formats?
It reads them. Some conversions are narrower than the reader — pdal:createcopc in particular will refuse a few exotic point formats, in which case pdal:convertformat to a common format first.
How do I unload a cloud and free its memory?QgsProject.instance().removeMapLayer(cloud.id()) if it was added, or simply let the Python reference go out of scope if it was not. The index cache on disk is separate and is not cleaned up by either.
Is there a way to see indexing progress?
The task appears in the QGIS task manager in the GUI. Headless there is no progress reporting, which is another argument for converting explicitly with pdal:createcopc, where processing.run() gives you a feedback object.