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.

Which provider key does this file need?A file ending in copc.laz uses the copc provider and opens from its embedded octree. An ept.json file uses the ept provider and reads an index directory beside it. A plain las or laz file uses the pdal provider, which has to build an index in a cache directory before any point can be drawn.The extension tells you the provider keya file on diskends .copc.lazprovider key "copc"octree inside the filenamed ept.jsonprovider key "ept"index in a folder beside itplain .las or .lazprovider key "pdal"no index — one gets builtthe third branch is the one that costs youconvert once with pdal:createcopc and every later run starts from the first branch

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 Entwine ept.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.

Valid immediately, drawable laterFor an indexed cloud the header read and the index open happen together, so the layer is drawable as soon as it is valid. For an unindexed cloud the header read returns a valid layer at once, but a background indexing task runs for seconds or minutes before the first point can be drawn, and any render started in between produces an empty image.isValid() returns True long before the cloud can be drawnindexed (COPC / EPT)header readoctree openeddrawable and queryabletotal: millisecondsunindexed (.las / .laz)header readbackground indexing task — seconds to minutesdrawable and queryableisValid() herea render started anywhere in this band produces an empty image

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")

What a virtual point cloud buys youA vpc file is a small manifest holding each member tile's path, extent and point count. Because the extents are in the manifest, an operation over a small area of interest consults the manifest first and opens only the tiles that intersect it, leaving the rest of the survey untouched.The manifest is read; most of the tiles never aresurvey.vpctile_0344.copc.laz · 41 Mptstile_0345.copc.laz · 39 Mptstile_0346.copc.laz · 44 Mpts… six more memberseach entry carries an extentand a point countabout 4 kB on diskthe survey on the grounddashed box: the area of interest — two tiles opened, seven skippedwithout the manifest, the same clip reads every tile to discover it is empty

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

VersionPoint cloud support
3.18QgsPointCloudLayer and the EPT provider arrive
3.26COPC provider; subset strings on point cloud layers
3.32The pdal: Processing algorithms, including createcopc
3.34 LTRVirtual point clouds (.vpc); the baseline for everything here
3.40 LTRFaster 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 often pdal was passed for a .copc.laz, or ept was passed for the index directory rather than the ept.json inside 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:assignprojection rather than by setting the CRS on the layer.
  • pdal:createcopc fails 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 with pdal:convertformat first.
  • 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.