Calculating Benthic Rugosity and BPI
Habitat models want two things from a bathymetric surface that slope alone cannot provide: how complex the seabed is, and whether a given place is high or low relative to its surroundings. Rugosity and the bathymetric position index answer those, and both have a widely-used implementation that answers a subtly different question from the one the model needs. This page gives working implementations of both, within deriving slope, rugosity and terrain metrics.
Root cause: the legacy rugosity measure is a slope measure
The oldest rugosity metric is surface area divided by planar area. On a plane tilted at angle θ that ratio is exactly 1/cos θ, so it is a monotonic function of slope and nothing else. A steep smooth wall scores high; a flat boulder field scores low. For habitat work that ranking is backwards — the boulder field provides shelter and the wall does not.
The vector ruggedness measure (VRM) fixes it by measuring the dispersion of surface orientations rather than their magnitude. Each cell’s slope and aspect define a unit normal; over a neighbourhood those normals are summed as vectors and the length of the resultant is compared against the count. Parallel normals — any plane, at any tilt — give a resultant of full length and a VRM of zero. Scattered normals give a short resultant and a VRM approaching one.
import numpy as np
def unit_normals(slope_deg: np.ndarray, aspect_deg: np.ndarray):
"""Per-cell surface normal components from slope and aspect."""
s, a = np.radians(slope_deg), np.radians(aspect_deg)
return (np.sin(s) * np.sin(a), # x
np.sin(s) * np.cos(a), # y
np.cos(s)) # z
The z component is cos(slope), so a flat cell has a normal pointing straight up and a vertical cell has one lying in the plane. That is the whole of the geometry, and everything below is a neighbourhood sum over it.
Step-by-step implementation
1. Vector ruggedness, with nodata propagated
import logging
import numpy as np
from scipy.ndimage import uniform_filter
logger = logging.getLogger(__name__)
def vector_ruggedness(
slope_deg: np.ndarray, aspect_deg: np.ndarray, *, size: int = 3,
min_valid_fraction: float = 0.7,
) -> np.ndarray:
"""Vector ruggedness measure over a size x size neighbourhood, 0 to 1."""
if size < 3 or size % 2 == 0:
raise ValueError("size must be an odd number >= 3")
valid = np.isfinite(slope_deg) & np.isfinite(aspect_deg)
nx, ny, nz = unit_normals(np.where(valid, slope_deg, 0.0),
np.where(valid, aspect_deg, 0.0))
for c in (nx, ny, nz):
c[~valid] = 0.0
n = float(size * size)
sx, sy, sz = (uniform_filter(c, size=size, mode="nearest") * n for c in (nx, ny, nz))
count = uniform_filter(valid.astype("float64"), size=size, mode="nearest") * n
with np.errstate(invalid="ignore", divide="ignore"):
vrm = 1.0 - np.sqrt(sx ** 2 + sy ** 2 + sz ** 2) / count
vrm[count / n < min_valid_fraction] = np.nan
vrm = np.clip(vrm, 0.0, 1.0)
logger.info("VRM %d×%d: median %.4f over %d valid cells",
size, size, float(np.nanmedian(vrm)), int(np.isfinite(vrm).sum()))
return vrm
Dividing by the valid count rather than by the window size is what makes this correct at a coastline or a data hole. Divide by the window size instead and every cell near an edge gets an artificially low resultant, which reads as high ruggedness — a rim of false complexity around every gap, which a habitat model will happily interpret as a reef.
2. BPI: two annuli, then a classification
def annulus_kernel(inner: int, outer: int) -> np.ndarray:
"""Normalised ring kernel for a bathymetric position index."""
if not 0 < inner < outer:
raise ValueError(f"need 0 < inner < outer, got {inner}, {outer}")
ii, jj = np.mgrid[-outer:outer + 1, -outer:outer + 1]
r2 = ii ** 2 + jj ** 2
ring = ((r2 > inner ** 2) & (r2 <= outer ** 2)).astype("float64")
if ring.sum() == 0:
raise ValueError("annulus contains no cells — inner and outer are too close")
return ring / ring.sum()
def bpi_standardised(dem: np.ndarray, inner: int, outer: int) -> np.ndarray:
"""Depth minus the annulus mean, standardised by the global anomaly σ."""
from scipy.ndimage import convolve
valid = np.isfinite(dem)
filled = np.where(valid, dem, 0.0)
k = annulus_kernel(inner, outer)
ring_mean = convolve(filled, k, mode="nearest")
frac = convolve(valid.astype("float64"), k, mode="nearest")
with np.errstate(invalid="ignore", divide="ignore"):
anomaly = dem - ring_mean / np.maximum(frac, 1e-9)
anomaly[frac < 0.7] = np.nan
sigma = float(np.nanstd(anomaly))
if sigma <= 0:
raise ValueError("zero anomaly variance — the DEM is flat or the annulus is degenerate")
return anomaly / sigma
Standardising by a single global sigma keeps values comparable across the whole grid. A per-window standardisation makes a flat basin — where local variability is near zero — into a field of extreme values, which is the most common way a BPI product ends up unusable.
3. Classify from the pair
def benthic_class(fine: np.ndarray, broad: np.ndarray, t: float = 1.0) -> np.ndarray:
"""Four-way benthic position from a fine and a broad BPI."""
out = np.zeros(fine.shape, dtype="uint8") # 0 = flat / slope
out[(fine >= t) & (np.abs(broad) < t)] = 1 # crest on a plain
out[(fine >= t) & (broad >= t)] = 2 # crest on a high
out[(fine <= -t) & (broad >= t)] = 3 # channel incised into a high
out[(fine <= -t) & (np.abs(broad) < t)] = 4 # depression on a plain
out[~np.isfinite(fine) | ~np.isfinite(broad)] = 255
return out
The classes are the product. Raw standardised BPI values are hard for a non-specialist to act on; “channel incised into a bank” is a thing an ecologist can map against a species distribution.
Verification and acceptance test
import pytest
def test_vrm_is_zero_on_any_plane():
"""A plane at any tilt has parallel normals and therefore zero ruggedness."""
for dip in (0.0, 5.0, 30.0, 60.0):
slope = np.full((64, 64), dip)
aspect = np.full((64, 64), 135.0)
vrm = vector_ruggedness(slope, aspect, size=5)
assert np.nanmax(vrm[3:-3, 3:-3]) < 1e-9, f"VRM non-zero on a {dip}° plane"
def test_bpi_is_zero_on_a_plane():
yy, xx = np.mgrid[0:128, 0:128]
dem = -20.0 - 0.01 * xx # uniform slope, no position structure
b = bpi_standardised(dem, inner=3, outer=25)
assert np.nanmax(np.abs(b[30:-30, 30:-30])) < 0.05
Both tests assert the negative — no complexity where there is none, no position anomaly on a uniform slope — and both fail loudly if a sign, an axis or a normalisation is wrong.
What these layers are used for, and what that demands of them
Rugosity and BPI are rarely the deliverable. They are predictor variables in a habitat model, and that use imposes three requirements that a standalone raster does not.
They must be comparable between surveys. A species-distribution model fitted on one survey and applied to another needs both to have been computed at the same analysis scale on grids of the same resolution. A VRM computed at 3×3 on a 1 m grid and one computed at 3×3 on a 5 m grid are different variables with the same name, and a model that mixes them is fitting the survey rather than the seabed.
Their nodata must be honest. A habitat model given a fabricated rim of high rugosity around every data hole will learn that species prefer the edges of survey coverage. Because the rim follows the coastline, and many species genuinely do prefer the coast, the artefact is confounded with the signal and will not show up as an obvious error.
Their correlation structure matters. VRM and BPI are both derived from the same DEM and are not independent. Fine-scale BPI magnitude correlates strongly with VRM, because both respond to short-wavelength structure. Feeding both into a model that assumes independent predictors inflates the apparent importance of terrain, and the standard remedy — check the variance inflation factor before fitting — belongs in the modelling step, not here, but the layers should be shipped with a note that it is needed.
The practical consequence for this stage is small and specific: publish the analysis scale, the grid resolution and the nodata rule in the layer metadata, and publish the pair of BPI annuli rather than a single derived class alone. Everything a downstream modeller needs to check comparability is then in the file.
Edge cases and gotchas
- Aspect is undefined where slope is zero, and libraries return anything from 0 to −9999 there. Since the normal is straight up regardless of aspect when slope is zero, the formulation above is immune — but a VRM implementation that trigonometrically combines aspect before checking slope is not.
- VRM is scale-dependent in the same way slope is. A 3×3 VRM measures cell-scale roughness and an 11×11 measures patch-scale complexity. Publish the window size in the layer name, not only in the metadata.
- BPI annuli in cells, products in metres. The same annulus on a 1 m and a 5 m grid finds features five times apart in size. Specify the radii in metres and convert at run time, as the parent topic does for slope.
Related
- Computing Seafloor Slope From a DEM in Python — the layer both metrics here are built on
- Deriving Slope, Rugosity & Terrain Metrics — the parent topic, and why analysis scale outranks kernel choice
- Preserving Seafloor Features While Smoothing — smoothing decisions upstream change every value here