Fixing Holes and Voids in Drone-Derived DSMs

You rasterized a dense cloud into a DSM, opened it in QGIS, and found dark craters punched through the surface: a rectangular void over the retention pond, ragged holes across the glazed atrium roof, and a scatter of pinholes over freshly poured concrete. Those cells hold your NoData sentinel (-9999), and any downstream volume calculation, contour, or hillshade will either fail or produce garbage where they fall. This page explains why those voids form, gives one focused routine that closes them, and shows where the fix ends and a re-processing decision begins.

Why NoData voids appear in a DSM

writers.gdal can only interpolate a cell from points that exist near it. A void appears wherever no point landed within the cell’s search radius, and drone photogrammetry produces point-free zones for two distinct reasons. The first is physical: dense stereo matching needs texture, so calm water, clean glass, wet asphalt, and uniform white membrane roofs return almost no matchable features and therefore no points. No amount of rasterization can invent a surface the sensor never reconstructed. The second is sampling: where coverage is merely thin — a flight-line gap, a single missed overlap strip — points exist but are sparser than the cell grid, so cells fall between them and go empty.

The two causes want different fixes. Sampling voids close by widening the writers.gdal radius so each cell reaches a real neighbour; texture voids have no nearby point at any radius and must be filled by interpolating across the hole from its rim. Conflating them is the usual mistake: cranking radius to swallow a genuine texture void smears the entire raster to erase one pond. The correct order is to set radius for the sampling floor, then fill the physical voids as a separate raster post-process. This void handling is the most common defect coming out of generating DSM and DTM from point clouds with PDAL, and it must be resolved before the raster reaches exporting cloud-optimized GeoTIFFs with Rasterio.

Four causes of a DSM void, which look identical in the raster A DSM tile with four holes, each annotated with a different cause. The first is water, which returns no usable match and never will. The second is occlusion behind a tall building, where the ground was simply never seen from two directions. The third is a resolution void, where the grid is finer than the point spacing so the cell had no point to average. The fourth is a filter void, where the ground classifier removed real ground on a steep slope. Beside each, the correct treatment is named: mask water, re-fly or accept for occlusion, coarsen the grid for resolution, and re-tune the filter for the fourth. 1 2 3 4 every void is the same NoData value in the raster 1 · water no texture to match, ever — mask it, do not fill it 2 · occlusion ground never seen twice — re-fly obliquely or accept 3 · resolution grid finer than the point spacing — coarsen the grid 4 · over-filtering real ground removed on a slope — re-tune, do not interpolate Interpolating across all four treats a diagnosis as a rendering problem: three of them are fixed upstream, and one must never be filled at all.

Figure 1 — The raster cannot tell you which void it is holding. Filling first and asking later produces a smooth surface that invents elevations over water and hides a mis-tuned ground filter.

Minimal reproducible solution

The routine below reads the holed DSM, builds a boolean mask of NoData cells, and fills them with rasterio.fill.fillnodata, which interpolates each void inward from its valid rim using an inverse-distance weighting bounded by max_search_distance (in pixels). Bounding the search distance is the safety valve: it lets small pinholes and thin gaps close while leaving a genuinely huge void — a whole lake — untouched, so you never hallucinate a flat plane across an area the sensor never saw.

import numpy as np
import rasterio
from rasterio.fill import fillnodata

NODATA = -9999.0


def fill_dsm_voids(src_path: str, dst_path: str,
                   max_search_distance: float = 12.0,
                   smoothing_iterations: int = 0) -> float:
    """Fill small NoData voids in a DSM and return the fraction filled.

    max_search_distance is in PIXELS: a cell is only filled if a valid
    cell lies within that many pixels, so large physical voids stay NoData.
    """
    with rasterio.open(src_path) as src:
        band = src.read(1)                       # single elevation band
        profile = src.profile
        # Mask is True where data is VALID (fillnodata's convention).
        valid = band != NODATA
        before = (~valid).mean()                 # NoData fraction before fill

        filled = fillnodata(
            band,
            mask=valid.astype(np.uint8),         # 1 = keep, 0 = interpolate
            max_search_distance=max_search_distance,
            smoothing_iterations=smoothing_iterations,
        )
        after = (filled == NODATA).mean()        # residual NoData after fill

    profile.update(dtype="float32", nodata=NODATA, count=1)
    with rasterio.open(dst_path, "w", **profile) as dst:
        dst.write(filled.astype(np.float32), 1)  # preserves CRS + transform

    return before - after                        # fraction of cells recovered

The mask argument follows rasterio’s convention where 1/True marks cells to keep and 0 marks cells to interpolate — inverting it fills the wrong pixels and destroys your real surface, so keep the valid = band != NODATA line exactly as written. Leave smoothing_iterations at 0 for survey DSMs where you must not alter measured elevations; raise it to 12 only for visualization products where a smoother filled patch matters more than metric fidelity. If you would rather stay inside the GDAL toolchain, the command-line equivalent is gdal_fillnodata.py -md 12 dsm.tif dsm_filled.tif, which uses the same algorithm with -md as the pixel search distance.

What each fill method does across a void, in profile A cross-section through a void in a DSM, with the true ground shown as a dashed reference. Three fill results are overlaid. Nearest-neighbour fill produces a flat plateau at the edge value with an abrupt step. Inverse-distance weighting produces a smooth bowl that sags below the true surface in the middle of a wide void. A triangulated fill spans the gap linearly between the rim points, which tracks the truth closely on a planar site and cuts corners across a curved one. The widths at which each becomes unacceptable are annotated. true surface (unknown to the filler) void width nearest — flat, steps at the rim; fine below ~3 cells inverse distance — sags in wide voids, smooth at the rim triangulated — linear span; best on planar ground, cuts curvature None of the three recovers information. Past a few cells wide, every one of them is fabricating terrain with a different bias.

Figure 2 — Fill methods differ in how they are wrong, not in whether they are. Choosing one is choosing which artefact you would rather explain to whoever measures volumes from the result.

Edge-case matrix

Input variant Downstream symptom if unchecked Expected handling
Pinholes over concrete (sparse points) Speckled hillshade, tiny volume errors Widen writers.gdal radius first, then fillnodata mops up residue
Large water body (no points at any radius) fillnodata invents a sloped plane across the lake Keep max_search_distance small so the void stays NoData; mask water explicitly
Void touches the raster edge Fill has no rim on one side, produces a directional ramp Buffer the extent or clip the edge void before fill
NoData stored as NaN, not -9999 band != NODATA misses every void, nothing fills Detect with np.isnan; normalise to one sentinel on read
Multi-band raster (min/max/idw) Only band 1 filled, others still holed Loop bands or fill the single band you export
Filled value below true ground Negative volumes in cut/fill reports Cap the fill to the local elevation range and flag outliers

The lake case is the one that bites: a large physical void filled with a bounded search stays honestly empty, but an unbounded fill produces a plausible-looking ramp that no one notices until a volume report is wrong. When water covers a meaningful area, digitise a mask and hold those cells at NoData deliberately rather than trusting the distance bound alone.

Recording which cells were filled, alongside the surface Two rasters written side by side by the same export. On the left, the filled elevation surface, continuous and free of NoData. On the right, a binary provenance mask at the same grid, where every filled cell is marked. Arrows show both being written from one process. A note explains that without the mask the filled cells are indistinguishable from measured ones, so any downstream volume or slope computation silently includes fabricated terrain, and that the mask costs a fraction of the elevation raster because it compresses to almost nothing. fill step dsm_filled.tif continuous float32 surface no NoData remaining measured and invented cells look identical dsm_fill_mask.tif uint8, 1 where the cell was filled same grid, same transform compresses to a fraction of a percent Any volume, slope or change-detection result computed later can then be reported with the fraction of fabricated area it rests on.

Figure 3 — The fill is defensible only if it is labelled. Writing the mask in the same pass costs almost nothing and is the difference between a surface with known provenance and a surface with a story.

Verification snippet

Confirm the fill closed the sampling voids without touching the elevation of cells that already had data — a fill that shifts valid pixels means the mask convention was inverted.

import numpy as np
import rasterio

NODATA = -9999.0
with rasterio.open("dsm.tif") as a, rasterio.open("dsm_filled.tif") as b:
    orig, fill = a.read(1), b.read(1)

valid_before = orig != NODATA
# 1. Valid cells must be untouched by the fill.
assert np.allclose(orig[valid_before], fill[valid_before]), "fill altered real data"
# 2. NoData fraction must drop.
before = (~valid_before).mean()
after = (fill == NODATA).mean()
assert after < before, "fill recovered no cells — check the mask convention"
print(f"NoData {before:.2%} -> {after:.2%}, recovered {before - after:.2%}")

If assertion 1 fails, mask was inverted; if assertion 2 fails with no change, either there were no voids within max_search_distance or the sentinel did not match the stored NoData value.

Finally, report the filled fraction with the deliverable. A DSM that is 2% interpolated and one that is 25% interpolated are different products, and only the metadata distinguishes them once the surface is continuous.

When to escalate

Filling is cosmetic repair of a sampling problem; it cannot recover a surface the reconstruction never built. Return to generating DSM and DTM from point clouds with PDAL and reprocess when:

  • Voids cover a large, structured region (a whole flight strip), which points to a coverage or overlap failure upstream, not a rasterization gap — re-fly or re-match rather than fill.
  • The holes trace real texture-poor surfaces you must measure (water level, glass roof height); no interpolation is metrically valid there, so capture control on those surfaces or accept NoData.
  • Widening radius alone would close the voids, in which case fix it at the source in writers.gdal and re-rasterize, because a correctly sampled grid is always better than a filled one.

Generating DSM and DTM from Point Clouds with PDAL