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.

Four renderers, four questions answeredThe classified renderer colours each point by its classification code and answers what is here. The RGB renderer uses captured red, green and blue values and answers what it looks like. The attribute-by-ramp renderer maps height or intensity through a colour ramp and answers how much. The extent renderer draws only each tile's outline and answers where the data is.Pick the renderer from the question you are askingClassifiedone colour per class"what is here?"the usual defaultRGBcaptured colour"what does it look like?"needs colourised dataAttribute by rampZ, Intensity, returns"how much?"set the range yourselfExtentoutlines only"where is the data?"free to drawthe fourth one is not a fallback — it is the right answer for an index mapforty tiles drawn as forty rectangles instead of four billion points

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

Point size is a scale-dependent decisionAt a small point size a cloud zoomed out shows gaps between points and the ground beneath. At a medium size the points just touch and the surface reads as continuous. At a large size the points overlap heavily, the surface becomes a solid block of colour and every structure in it disappears.Too small reads as noise; too large reads as paint0.5 mmgaps; ground shows through1.4 mmreads as a surface4 mmstructure disappearsa size that is right zoomed in is wrong zoomed out — set it for the scale you export at1.0–1.6 mm suits most printed maps of a dense survey

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.
  • setPointSizeUnit raises AttributeError. Pre-3.30 enum location — use QgsUnitTypes.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.