Style a Point Cloud Renderer in PyQGIS
A point cloud that renders as a solid brown slab and a point cloud that renders as a legible landscape differ by about six lines of Python. There is no automatic styling worth relying on: QGIS picks a default renderer from what it finds in the header, and on a survey with unusual classes or no colour that default is almost always the wrong one.
This recipe belongs to Point Cloud & LiDAR Workflows in PyQGIS. It covers the four renderer classes, the point size and budget settings that decide whether a dense cloud is readable, and how to make an overview map with forty tiles that does not take a minute to redraw.
Prerequisites
- QGIS 3.34 LTR or newer.
- A loaded
QgsPointCloudLayer— see loading a point cloud layer. - A look at the header first: which attributes exist decides which renderers are even possible.
Colour by classification
The classified renderer is the workhorse. Build it with only the categories you want visible.
from qgis.core import QgsPointCloudClassifiedRenderer, QgsPointCloudCategory
from qgis.PyQt.QtGui import QColor
renderer = QgsPointCloudClassifiedRenderer("Classification")
renderer.setCategories([
QgsPointCloudCategory(2, QColor("#8d6e3f"), "ground"),
QgsPointCloudCategory(3, QColor("#9dc183"), "low vegetation"),
QgsPointCloudCategory(5, QColor("#2f7a3d"), "high vegetation"),
QgsPointCloudCategory(6, QColor("#a45b5b"), "building"),
QgsPointCloudCategory(9, QColor("#4f7dbd"), "water"),
])
cloud.setRenderer(renderer)
cloud.triggerRepaint()
Breakdown: Points whose class is not in the list are not drawn at all, which makes the category list a display filter as well as a palette — a fast way to look at buildings only without touching the subset string. The label is what appears in the legend and in the layout legend, so it is worth writing properly rather than leaving as the numeric code. QgsPointCloudClassifiedRenderer.defaultCategories() returns the full ASPRS set with conventional colours if you would rather start there:
renderer = QgsPointCloudClassifiedRenderer("Classification")
categories = QgsPointCloudClassifiedRenderer.defaultCategories()
renderer.setCategories([c for c in categories if c.value() in (2, 5, 6)])
Breakdown: Filtering the default list keeps the conventional colours — which readers of LiDAR maps recognise — while showing only the classes that matter. Each QgsPointCloudCategory exposes value(), color(), label() and pointSize(), so per-class point sizes are possible: drawing ground at 1 mm and buildings at 2.5 mm makes structures pop out of a dense scene.
Colour by a numeric attribute
Height, intensity and return count are continuous, so they want a ramp.
from qgis.core import (
QgsPointCloudAttributeByRampRenderer,
QgsStyle,
QgsColorRampShader,
)
stats = cloud.statistics()
low, high = stats.minimum("Z"), stats.maximum("Z")
ramp = QgsStyle.defaultStyle().colorRamp("Viridis")
shader = QgsColorRampShader(low, high, ramp, QgsColorRampShader.Interpolated)
shader.classifyColorRamp(classes=12)
renderer = QgsPointCloudAttributeByRampRenderer()
renderer.setAttribute("Z")
renderer.setMinimum(low)
renderer.setMaximum(high)
renderer.setColorRampShader(shader)
cloud.setRenderer(renderer)
cloud.triggerRepaint()
Breakdown: Setting the minimum and maximum on both the shader and the renderer looks redundant and is not: the renderer's own range is what the legend reports and what clamps out-of-range points, while the shader's range is what maps a value to a colour. Taking the range from the statistics rather than hard-coding it means the same script produces a sensible picture on a mountain tile and a floodplain tile. If a stray noise point at 4,000 m survived the filter, the whole ramp collapses into its bottom few per cent — which is why removing noise comes before styling, not after.
Point size, symbol and why the cloud looks solid
from qgis.core import Qgis
renderer.setPointSize(1.4)
renderer.setPointSizeUnit(Qgis.RenderUnit.Millimeters)
renderer.setPointSymbol(Qgis.PointCloudSymbol.Circle)
cloud.setRenderer(renderer)
Breakdown: Every point cloud renderer inherits these three settings from the base class, so the same three lines apply whichever renderer you built above. Millimetres are the sensible unit for anything destined for print, because the size stays physically constant across export resolutions; pixels are right when the target is a fixed-size image. Square symbols render measurably faster than Circle at large point sizes, which matters on an animation but not on a single map.
Using captured colour
Where the survey was colourised from aerial imagery — increasingly common on urban work — the file carries Red, Green and Blue attributes and the cloud can be drawn as a photograph made of points.
from qgis.core import QgsPointCloudRgbRenderer, QgsContrastEnhancement
names = {a.name() for a in cloud.attributes().attributes()}
if {"Red", "Green", "Blue"} <= names:
renderer = QgsPointCloudRgbRenderer()
renderer.setRedAttribute("Red")
renderer.setGreenAttribute("Green")
renderer.setBlueAttribute("Blue")
enhancement = QgsContrastEnhancement()
enhancement.setContrastEnhancementAlgorithm(
QgsContrastEnhancement.StretchToMinimumMaximum
)
enhancement.setMinimumValue(0)
enhancement.setMaximumValue(65535)
renderer.setRedContrastEnhancement(enhancement)
cloud.setRenderer(renderer)
Breakdown: Checking the attribute set first is what stops this failing on a survey that was never colourised, and it is a one-line guard worth having in any script that runs over a mixed delivery. The contrast enhancement is the part everybody forgets: LAS stores colour as 16-bit unsigned integers, and a renderer that assumes 8-bit shows everything as near-black. Setting the maximum to 65535 fixes that; setting it to 255 is correct for the minority of files that store 8-bit colour, and you can tell which you have from statistics().maximum("Red"). Each band takes its own enhancement object, so all three need setting for a neutral result — enhancing only red gives the cloud a colour cast.
The point budget
The budget is the ceiling on how many points QGIS will draw in one refresh, and it is a global setting rather than a layer property. A headless render that comes back sparse is nearly always hitting it.
from qgis.core import QgsSettings
settings = QgsSettings()
print(settings.value("qgis/pointCloudPointBudget"))
settings.setValue("qgis/pointCloudPointBudget", 10_000_000)
Breakdown: Raising the budget trades memory and redraw time for completeness, and ten million is comfortable on a modern desktop for a still image. Set it back afterwards in an interactive session, or you make every subsequent pan slow. In a batch export the process is short-lived so it does not matter. Because this is a global setting, note that it interacts with everything else in the project — the budget is shared across all point cloud layers, so a project with four clouds gives each a quarter of the picture.
An index map of a whole survey
from qgis.core import QgsPointCloudExtentRenderer
survey = QgsPointCloudLayer("/data/indexed/survey.vpc", "survey", "pdal")
survey.setRenderer(QgsPointCloudExtentRenderer())
QgsProject.instance().addMapLayer(survey)
Breakdown: The extent renderer draws each member tile's bounding box and nothing else, so a forty-tile survey redraws instantly at any zoom. It is the right layer to put underneath an area-of-interest polygon when deciding which tiles a job needs, and it pairs naturally with the coverage thinking in clipping and tiling point clouds.
QGIS version compatibility
The four renderer classes have existed since 3.18. Qgis.RenderUnit and Qgis.PointCloudSymbol are the 3.30+ enum locations; on 3.28 and earlier the same values live on QgsUnitTypes and QgsPointCloudRenderer respectively. The point budget setting key is stable, though the QGIS 3.40 default is higher than the 3.34 one, so a script that assumes the old default will over-raise it.
Troubleshooting
- The cloud draws as a grey rectangle. The extent renderer is active — either set deliberately, or fallen back to because the index is not ready.
- Nothing draws at all. The classified renderer has categories that match no class present. Check
statistics().classesOf("Classification"). - The ramp is all one colour. A noise point is stretching the range. Filter class 7 and 18, or set the renderer minimum and maximum by hand.
- The exported image is far sparser than the canvas. The point budget applies to the render job; raise it before exporting.
setPointSizeUnitraises AttributeError. Pre-3.30 enum location — useQgsUnitTypes.RenderMillimeters.- Colours are washed out with the RGB renderer. The survey stores 16-bit colour and the renderer is reading it as 8-bit; set the contrast enhancement on the renderer's red, green and blue band settings.
Conclusion
Choose the renderer from the question, take ranges from the file's own statistics so the script travels, set point size in millimetres for print, and remember that the point budget is global and is the usual reason an export looks thinner than the canvas. Styling a cloud well takes six lines; the value is in the two you spend reading the statistics first.
Frequently Asked Questions
Can I save point cloud styling as a QML?
Yes — layer.saveNamedStyle(path) and loadNamedStyle work on point cloud layers as they do on vector layers, which is the practical way to apply one agreed palette across a survey. See saving and loading a QML style.
How do I style a cloud in the 3D view?
Through QgsPointCloudLayer3DRenderer, which holds its own symbol and is set with layer.setRenderer3D(). It is configured separately from the 2D renderer and does not inherit from it.
Why does per-class point size have no effect?
Per-category sizes are applied only when the category's own size is non-zero; a category constructed without one falls back to the renderer's global size. Set it explicitly with category.setPointSize().
Does a subset string change what the legend shows? No. The legend reflects the renderer's categories, not the filter, so a filtered view can show legend entries for classes that are currently hidden. Trim the categories as well if the legend needs to be honest.