Run a Viewshed Analysis in PyQGIS

A viewshed is the area visible from a point: what a lookout tower sees, which homes will see a proposed wind turbine, where a mast's antenna has line of sight, how much of a valley a camera covers. The calculation is conceptually simple — trace a line from the observer to every cell of an elevation model and test whether terrain gets in the way — and the result depends heavily on choices that are easy to overlook: observer and target heights, the maximum distance, earth curvature, and above all whether the elevation model includes buildings and trees.

This recipe belongs to Terrain & Interpolation Analysis. It runs a viewshed with GDAL, sets heights and distance deliberately, accounts for curvature with GRASS, combines several observers into a cumulative viewshed, and explains the limits of the input surface.

Line of sight over terrainA terrain profile with an observer on a hill at 1.6 metres above ground and targets beyond. Lines of sight from the observer reach the near slope and the far ridge but are blocked from the valley behind a crest, which lies in a shadow. Raising the target height, for example to the top of a 100 metre turbine, makes cells visible that were hidden at ground level.What the terrain hideshidden behind the crestobserver + 1.6 mvisible ridge

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series. gdal:viewshed is available from QGIS 3.28 with GDAL 3.1 or newer; GRASS's r.viewshed is available when the GRASS provider is installed.
  • A DEM in a projected CRS with metre units — or better, a digital surface model (DSM) that includes buildings and vegetation, as explained below.
  • Observer locations as coordinates or a point layer.

Run a single viewshed with GDAL

The GDAL algorithm takes the elevation raster, the observer position and heights, and writes a raster where visible cells have one value and invisible cells another.

import processing
from qgis.core import QgsRasterLayer, QgsPointXY, QgsProject

dem = QgsRasterLayer("/data/terrain/dsm_2m.tif", "DSM")
observer = QgsPointXY(571244.0, 5934019.0)

vs = processing.run("gdal:viewshed", {
    "INPUT": dem,
    "BAND": 1,
    "OBSERVER": f"{observer.x()},{observer.y()} [{dem.crs().authid()}]",
    "OBSERVER_HEIGHT": 1.6,
    "TARGET_HEIGHT": 0.0,
    "MAX_DISTANCE": 5000,
    "OUTPUT": "/data/results/viewshed_tower.tif",
})["OUTPUT"]

result = QgsRasterLayer(vs, "viewshed (tower)")
QgsProject.instance().addMapLayer(result)

Breakdown: OBSERVER_HEIGHT is added to the ground elevation at the observer — 1.6 m for eye level, the mast height for an antenna. TARGET_HEIGHT is added at every target cell: 0 asks "can I see the ground there?", while 100 asks "can I see a 100 m turbine there?" — a very different question with a much larger answer. MAX_DISTANCE bounds the computation; beyond 10–20 km, atmospheric visibility and curvature dominate. The output is 255 for visible and 0 for invisible by default; check processing.algorithmHelp("gdal:viewshed") for the exact parameter names and value options on your version.

Choose heights and distance deliberately

Observer and target heights encode the question being asked, and they change results dramatically.

Heights encode the questionThree common questions. Can a walker see the ground: observer 1.6 metres, target 0. Can a resident see the turbine's hub: observer 1.6 metres at each home, target 120 metres. Can a mast's antenna reach a receiver: observer the antenna height, target the receiver height. Swapping observer and target in the turbine case gives reciprocal visibility for many homes in one run.What are you asking?walkerobserver 1.6 mtarget 0 msees the groundturbineobserver: turbine120 m hubtarget 1.6 mhomes that see itradio linkobserver: antennatarget: receiverline of sight

For turbines and masts, run the viewshed from the structure with the structure's height as observer height and eye level as target height: the result shows every place from which the structure is visible, in one run, instead of one run per home. Visibility is reciprocal in this geometric sense, provided heights are swapped consistently. For maximum distance, use the distance at which the object is still perceptible — a turbine is visible tens of kilometres away, a person a few hundred metres.

Account for earth curvature

Over long distances the earth curves away below the line of sight — about 8 m at 10 km, 31 m at 20 km — and atmospheric refraction bends light back slightly. GRASS's r.viewshed includes both.

from qgis.core import QgsApplication

ids = [a.id() for a in QgsApplication.processingRegistry().algorithms() if a.id().endswith("r.viewshed")]
alg = ids[0] if ids else None
print("using", alg)

grass_vs = processing.run(alg, {
    "input": dem,
    "coordinates": f"{observer.x()},{observer.y()}",
    "observer_elevation": 120,
    "target_elevation": 1.6,
    "max_distance": 20000,
    "refraction_coeff": 0.14286,
    "-c": True,                      # consider earth curvature
    "-r": True,                      # consider refraction
    "-b": True,                      # binary output: 1 visible, 0 not
    "output": "/data/results/viewshed_turbine_curved.tif",
})["output"]

Breakdown: The GRASS provider's id prefix has changed between releases (grass7: on older ones), so finding the algorithm by its suffix keeps the script portable. The -c and -r flags switch on curvature and refraction, with the standard coefficient of 1/7. The binary flag gives a simple 1/0 output that is easy to combine; without it, r.viewshed writes the vertical angle to each visible cell, which is useful for prominence studies. Running GRASS algorithms from Python is covered generally in running GRASS and SAGA algorithms.

Combine several observers

Coverage questions involve many observers — all cameras on a site, every tower in a fire-watch network, every turbine in a wind farm. A cumulative viewshed counts, for each cell, how many observers see it.

from qgis.core import QgsVectorLayer
import numpy as np
from osgeo import gdal

towers = QgsVectorLayer("/data/sites/lookout_towers.gpkg", "towers", "ogr")
paths = []
for f in towers.getFeatures():
    p = f.geometry().asPoint()
    out = f"/data/results/vs_{f['tower_id']}.tif"
    processing.run("gdal:viewshed", {
        "INPUT": dem, "BAND": 1, "OBSERVER": f"{p.x()},{p.y()} [{dem.crs().authid()}]",
        "OBSERVER_HEIGHT": f["height_m"], "TARGET_HEIGHT": 0, "MAX_DISTANCE": 15000,
        "OUTPUT": out})
    paths.append(out)

stack = None
for path in paths:
    arr = (gdal.Open(path).ReadAsArray() > 0).astype("uint8")
    stack = arr if stack is None else stack + arr
print("cells seen by at least one tower:", int((stack > 0).sum()))

Breakdown: Each tower's viewshed uses its own height from the attribute table. Converting each output to 0/1 and summing gives the number of towers that see every cell; stack > 0 is the combined coverage, and cells with zero are blind spots. All viewsheds share the DEM's grid, so they align without resampling — as long as the same DEM and the same maximum distance are used. Write the stack back to a GeoTIFF with the DEM's geotransform, as in writing a NumPy array to a raster.

Use the right surface

The most important decision is not a parameter but the input. A bare-earth DEM has no buildings or trees, so a viewshed on it shows what would be visible on an empty landscape — typically far more than in reality.

DEM or DSMA bare-earth digital terrain model omits buildings and trees, so lines of sight pass through them and the viewshed overstates visibility, often greatly in towns and forests. A digital surface model includes them, giving realistic results for observers on the ground. For targets on roofs or in clearings, the DSM's treetops and rooftops are the target surface, which is usually what you want.Bare earth sees through housesDTM (bare earth)no buildings, no treesvisibility overstatedDSM (surface)buildings and canopyrealistic from the ground

Use a DSM from LiDAR or photogrammetry wherever possible — creating a DEM from a point cloud shows how to produce one with all returns. If only a DTM exists, the result is an upper bound: "at most this is visible". Resolution matters too: a 25 m DEM smooths away small crests that block views, while a 1 m DSM can make a single hedge block an entire field of view. Choose the resolution of the question, and report which surface was used with every viewshed map.

Summarise visibility for reports

Planning reports need numbers: how many homes see the turbine, how many hectares of the protected area are visible from the new road.

from qgis.core import QgsVectorLayer

homes = QgsVectorLayer("/data/sites/residential_addresses.gpkg", "homes", "ogr")
sampled = processing.run("native:rastersampling", {
    "INPUT": homes, "RASTERCOPY": grass_vs, "COLUMN_PREFIX": "vis_",
    "OUTPUT": "memory:"})["OUTPUT"]
visible = sum(1 for f in sampled.getFeatures() if f["vis_1"] == 1)
print(f"{visible} of {homes.featureCount()} homes can see the turbine hub")

Breakdown: Sampling the viewshed at each address turns the raster into a count of affected homes, the figure planning committees ask for. Areas within polygons — protected land, a park — come from zonal statistics of the binary viewshed, as in zonal statistics. Always report the observer and target heights, maximum distance, curvature setting and surface used next to the numbers.

QGIS version compatibility

gdal:viewshed requires QGIS 3.28 or newer with GDAL 3.1+, and is present on 3.34 LTR, 3.40 LTR and QGIS 4. The GRASS r.viewshed algorithm's provider prefix differs between releases; find it by suffix as shown. Parameter names follow GRASS's own option names.

Troubleshooting

  • Everything is visible. The surface is a bare-earth DTM, or target height is far above the ground.
  • Nothing beyond a few kilometres is visible. MAX_DISTANCE is too small, or curvature is applied at a coarse resolution.
  • The observer cell is invisible. The observer lies on NoData or outside the DEM.
  • Results differ between GDAL and GRASS. Curvature, refraction and cell-centre conventions differ; use one tool consistently.

Conclusion

Run viewsheds with gdal:viewshed for quick results and GRASS r.viewshed when curvature and refraction matter, set observer and target heights to express the real question, bound the distance sensibly, combine observers into cumulative coverage, use a surface model rather than bare earth, and sample results at homes or summarise by area for reports.

Frequently Asked Questions

Is a viewshed the same as a line-of-sight profile? A profile tests one line; a viewshed tests every cell. Use an elevation profile to explain a single result.

How long does a viewshed take? Seconds to minutes; time grows with the square of the maximum distance in cells.

Can I compute visibility from a line, such as a road? Place observers along the line, as in generating points along lines, and build a cumulative viewshed.

Should the observer stand on the DSM or the DTM? On a DSM, an observer placed under a tree canopy stands on the treetop. Move observers to open ground, or sample the DTM for their base elevation and add the height yourself.

Can I weight visibility by distance? Yes — multiply the binary viewshed by a distance-decay raster built from the observer, so nearby visible areas count more than distant ones.

Does the QGIS Visibility Analysis plugin do the same? Yes, with additional options such as intervisibility networks; it is a good interactive companion.