How to Rasterise a Point Cloud to a Grid in Python

Problem statement

Turning millions of scattered points into a regular grid is the operation underneath every lidar product β€” DTM, DSM, CHM, density, intensity. It is also where naive code becomes unusably slow.

A Python loop over 12,968,770 points takes minutes. The vectorised equivalent takes under a second, and the difference is one NumPy idiom.

The other half of the problem is choosing a reduction and a cell size that the data supports. Measured on that survey:

0.25 m cells: 38.23% of cells contain no point at all
1.00 m cells:  7.47%
5.00 m cells:  7.29%

The floor at about 7.3% is water. Below it you are looking at resolution; at it, at real absence.

Quick answer

import numpy as np


def rasterise(x, y, values, cell, bounds, how="mean"):
    """Reduce scattered points onto a regular grid, vectorised."""
    left, bottom, right, top = bounds
    width = int(np.ceil((right - left) / cell))
    height = int(np.ceil((top - bottom) / cell))

    col = np.clip(((x - left) / cell).astype(int), 0, width - 1)
    row = np.clip(((top - y) / cell).astype(int), 0, height - 1)
    flat = row * width + col

    if how == "count":
        out = np.zeros(width * height)
        np.add.at(out, flat, 1)
    elif how == "min":
        out = np.full(width * height, np.inf)
        np.minimum.at(out, flat, values)
        out[np.isinf(out)] = np.nan
    elif how == "max":
        out = np.full(width * height, -np.inf)
        np.maximum.at(out, flat, values)
        out[np.isinf(out)] = np.nan
    elif how == "mean":
        total = np.zeros(width * height)
        count = np.zeros(width * height)
        np.add.at(total, flat, values)
        np.add.at(count, flat, 1)
        out = np.where(count > 0, total / np.maximum(count, 1), np.nan)
    else:
        raise ValueError(f"unknown reduction {how!r}")

    return out.reshape(height, width)

np.add.at, np.minimum.at and np.maximum.at are the unbuffered in-place operations. They handle repeated indices correctly, which plain fancy indexing does not.

Scattered points assigned to grid cells by integer division, then reduced per cell with count, min, max or mean.
Two integer divisions produce the cell index. Everything after that is one vectorised reduction.

Step-by-step solution

1. Define the grid explicitly

left, bottom, right, top = bounds
width = int(np.ceil((right - left) / cell))
height = int(np.ceil((top - bottom) / cell))
transform = from_origin(left, top, cell, cell)

Deriving the grid from the data's own extent is convenient and makes two rasters from two tiles non-aligned. For anything that will be mosaicked or stacked, snap the origin to a multiple of the cell size.

2. Compute cell indices by integer division

col = np.clip(((x - left) / cell).astype(int), 0, width - 1)
row = np.clip(((top - y) / cell).astype(int), 0, height - 1)

The top - y for rows is the usual source of vertically flipped rasters: raster row 0 is the top, while y increases upward.

np.clip handles the point exactly on the right or bottom edge, which would otherwise index one past the end.

3. Flatten to one index and reduce

flat = row * width + col

A single flat index turns a 2D scatter into a 1D reduction, which is what the .at operations need. Reshape at the end.

4. Do not use a Python loop, and do not use plain indexing

out[flat] += 1        # WRONG: repeated indices are applied once
np.add.at(out, flat, 1)   # correct

This is the trap. out[flat] += 1 reads, adds and writes as three separate vectorised steps, so where an index appears twenty times it is incremented once. The result looks like a valid count raster and undercounts every populated cell.

5. Return the counts alongside the values

A cell built from one point and a cell built from a hundred are indistinguishable in the output. The count raster costs one extra reduction and makes the product interpretable.

Plain fancy-index addition applying a repeated index once against np.add.at accumulating every occurrence.
The wrong version produces a plausible raster with every populated cell undercounted.

Code examples

Example 1 β€” a rasteriser with percentiles and counts

import numpy as np
import rasterio
from rasterio.transform import from_origin


def rasterise_cloud(x, y, values, cell=1.0, bounds=None, crs=None,
                    reductions=("min", "max", "mean", "count"),
                    percentiles=(), out_path=None):
    """Several reductions in one pass over the cell index."""
    if bounds is None:
        bounds = (x.min(), y.min(), x.max(), y.max())
    left, bottom, right, top = bounds
    width = int(np.ceil((right - left) / cell))
    height = int(np.ceil((top - bottom) / cell))
    transform = from_origin(left, top, cell, cell)

    col = np.clip(((x - left) / cell).astype(int), 0, width - 1)
    row = np.clip(((top - y) / cell).astype(int), 0, height - 1)
    flat = row * width + col
    n = width * height

    bands, names = [], []
    count = np.zeros(n)
    np.add.at(count, flat, 1)

    for how in reductions:
        if how == "count":
            bands.append(count.copy())
        elif how == "min":
            g = np.full(n, np.inf); np.minimum.at(g, flat, values)
            g[np.isinf(g)] = np.nan; bands.append(g)
        elif how == "max":
            g = np.full(n, -np.inf); np.maximum.at(g, flat, values)
            g[np.isinf(g)] = np.nan; bands.append(g)
        elif how == "mean":
            total = np.zeros(n); np.add.at(total, flat, values)
            bands.append(np.where(count > 0, total / np.maximum(count, 1), np.nan))
        names.append(how)

    if percentiles:
        order = np.argsort(flat, kind="stable")
        fs, vs = flat[order], values[order]
        edges = np.searchsorted(fs, np.arange(n + 1))
        for q in percentiles:
            g = np.full(n, np.nan)
            for i in np.nonzero(count > 0)[0]:
                g[i] = np.percentile(vs[edges[i]:edges[i + 1]], q)
            bands.append(g); names.append(f"p{q}")

    stack = np.stack([b.reshape(height, width) for b in bands]).astype("float32")
    print(f"  {width} x {height} at {cell}, "
          f"{(count == 0).mean():.2%} empty, bands {names}")

    if out_path:
        profile = dict(driver="GTiff", height=height, width=width,
                       count=len(bands), dtype="float32", crs=crs,
                       transform=transform, nodata=np.nan,
                       compress="deflate", tiled=True)
        with rasterio.open(out_path, "w", **profile) as dst:
            dst.write(stack)
            for i, name in enumerate(names, start=1):
                dst.set_band_description(i, name)
    return stack, names, transform

The sort-and-slice approach for percentiles is the only part that loops, and it loops over cells rather than points β€” thousands rather than millions.

Example 2 β€” choosing the cell size from occupancy

import numpy as np


def occupancy_sweep(x, y, mask=None, cells=(0.25, 0.5, 1.0, 2.0, 5.0)):
    """What fraction of cells contain a point, at each resolution?"""
    px, py = (x[mask], y[mask]) if mask is not None else (x, y)
    left, top = x.min(), y.max()

    rows = []
    for cell in cells:
        width = int(np.ceil((x.max() - left) / cell))
        height = int(np.ceil((top - y.min()) / cell))
        col = np.clip(((px - left) / cell).astype(int), 0, width - 1)
        row = np.clip(((top - py) / cell).astype(int), 0, height - 1)
        counts = np.zeros(width * height)
        np.add.at(counts, row * width + col, 1)

        rows.append({"cell": cell, "cells": counts.size,
                     "empty": float((counts == 0).mean()),
                     "median": float(np.median(counts[counts > 0]))})
        print(f"  {cell:5.2f} m  {counts.size:10,} cells  "
              f"{(counts == 0).mean():7.2%} empty  "
              f"median {np.median(counts[counts > 0]):5.0f} points/cell")
    return rows
   0.25 m  12,288,524 cells   38.23% empty  median     1 points/cell
   0.50 m   3,073,008 cells    9.00% empty  median     4 points/cell
   1.00 m     768,252 cells    7.47% empty  median    17 points/cell
   2.00 m     192,282 cells    7.34% empty  median    66 points/cell
   5.00 m      30,976 cells    7.29% empty  median   410 points/cell

The empty fraction stops falling between 1 m and 2 m. That plateau is the honest resolution limit for this cloud.

Example 3 β€” rasterising a cloud too large for memory

import glob
import numpy as np
import laspy


def rasterise_tiles(pattern, cell, bounds, crs, ground_class=2):
    """Accumulate a DTM across many tiles without loading them together."""
    left, bottom, right, top = bounds
    width = int(np.ceil((right - left) / cell))
    height = int(np.ceil((top - bottom) / cell))

    grid = np.full(width * height, np.inf)
    counts = np.zeros(width * height)
    tiles = 0

    for path in sorted(glob.glob(pattern)):
        with laspy.open(path) as reader:
            h = reader.header
            if (h.maxs[0] < left or h.mins[0] > right or
                    h.maxs[1] < bottom or h.mins[1] > top):
                continue
        tiles += 1
        for points in laspy.open(path).chunk_iterator(1_000_000):
            x = np.asarray(points.x); y = np.asarray(points.y)
            z = np.asarray(points.z)
            keep = (np.asarray(points.classification) == ground_class) & \
                   (x >= left) & (x < right) & (y >= bottom) & (y < top)
            if not keep.any():
                continue
            col = ((x[keep] - left) / cell).astype(int)
            row = ((top - y[keep]) / cell).astype(int)
            flat = row * width + col
            np.minimum.at(grid, flat, z[keep])
            np.add.at(counts, flat, 1)

    grid[np.isinf(grid)] = np.nan
    print(f"  {tiles} tiles, {int(counts.sum()):,} ground points, "
          f"{np.isnan(grid).mean():.2%} empty")
    return grid.reshape(height, width), counts.reshape(height, width)

Because minimum and count are both associative, the grid can be accumulated across tiles and chunks without holding any of them. Mean needs the same treatment via separate sum and count arrays; median and percentiles cannot be accumulated this way at all.

Explanation

Why np.add.at and not +=

out[flat] += 1 is three operations: gather the values at flat, add one, scatter them back. With a repeated index, all copies gather the same original value and the last scatter wins β€” so twenty points in a cell increment it once.

np.add.at is unbuffered: it applies the operation for each index in turn. It is slower per element than a buffered operation and correct, which is the trade you want.

The failure is silent and produces a count raster that looks fine and undercounts every populated cell. Every count-based product built on it β€” density, occupancy, weighting β€” is wrong.

Why the reduction choice is not cosmetic

Each reduction answers a different question, and the wrong one is wrong in a specific direction:

  • min for ground: robust to misclassified low vegetation, vulnerable to low noise.
  • max for surfaces: the top of the canopy, vulnerable to high noise.
  • mean for continuous fields like intensity: smooth, and biased by any outlier.
  • count for density: exact, and the only one that needs no values at all.
  • percentiles for canopy structure: expensive, and the only ones that describe a distribution.

Why cells with one point are the real risk

At 1 m on this survey the median cell holds 17 points, which sounds comfortable. The distribution is what matters: cells at the edge of the flight line, under canopy, or at the tile boundary may hold one.

A one-point cell has no redundancy. Whatever reduction you use returns that point, so a single bad measurement becomes a cell value with no way to detect it from the raster alone.

Returning the count raster is what lets a downstream user mask thinly supported cells.

Why grid alignment matters more than it seems

Two tiles rasterised from their own extents produce grids offset by a fraction of a cell. Mosaicking them requires resampling, which blurs; stacking them for a difference produces a systematic shift.

Snapping the origin to a multiple of the cell size β€” left = floor(left / cell) * cell β€” makes every product in a project share pixel corners for free. It costs one line and removes a whole class of alignment bug.

Five per-cell reductions with what each is used for and what it is vulnerable to.
Only count is exact. The rest trade one kind of error for another.

Edge cases or notes

  • np.add.at, not +=. Plain indexing applies a repeated index once.
  • Rows count from the top. Use top - y, or the raster is flipped.
  • np.clip the indices so a point exactly on the far edge does not overflow.
  • Snap the grid origin to a multiple of the cell size.
  • Return the count raster alongside the values.
  • min, max, sum and count accumulate across tiles. Median and percentiles do not.
  • Choose the cell size from the occupancy plateau, not from nominal spacing.
  • Percentiles need a sort per cell; loop over cells, never over points.

FAQ

How do I rasterise a point cloud in Python?

Compute integer row and column indices by dividing the offset coordinates by the cell size, flatten to one index, and reduce with np.add.at, np.minimum.at or np.maximum.at.

Why is my count raster too low?

You used out[flat] += 1. Plain fancy indexing applies a repeated index once. Use np.add.at.

Why is my raster upside down?

Raster row 0 is the top and y increases upward, so the row index must be (top - y) / cell, not (y - bottom) / cell.

What cell size should I use?

The one where the empty-cell fraction stops falling. On this survey that was between 1 m and 2 m, with a floor of about 7.3% from water.

Which reduction should I use?

Minimum for ground, maximum for surfaces, mean for continuous attributes, count for density, percentiles for distributions. Each is wrong in a specific direction.

How do I rasterise a cloud too large for memory?

Accumulate across tiles and chunks. Minimum, maximum, sum and count are associative and can be built incrementally; median and percentiles cannot.

Should I output the point count?

Yes. A cell built from one point and one built from a hundred look identical otherwise, and the difference is the only clue to reliability.