Clipping Marine Rasters to a Coastline Mask
Every coastal raster product gets clipped to the water at some point: a bathymetric grid trimmed at the shoreline, a current field masked to the model domain, a habitat suitability surface cut to the survey footprint. The operation looks like a one-liner, and three of its defaults are wrong for marine data in ways that are invisible in a plot. This page sits under coastal vector geometry processing and covers the masking side of the same boundary.
Root cause: a polygon boundary does not fall on cell edges
A raster is a partition of space into cells; a mask polygon is an arbitrary curve. Where the curve crosses a cell, that cell is partly in and partly out, and the masking function has to decide. rasterio.mask exposes the decision as all_touched: False (the default) keeps a cell only if the polygon covers its centre; True keeps a cell if the polygon touches it at all.
For an inland catchment with a 30 m grid, the difference is a rounding detail. For a coastal product it is a systematic bias of one cell ring along the entire coastline, and coastlines are long. On a 1 m bathymetric grid over 400 km of shoreline, the two settings differ by roughly 0.4 km² — and the difference is not noise, it is entirely on one side.
import geopandas as gpd
import rasterio
from rasterio.mask import mask
shore = gpd.read_file("shoreline_mhw.gpkg").to_crs("EPSG:32618")
with rasterio.open("bathy_1m.tif") as src:
strict, _ = mask(src, shore.geometry, crop=True, all_touched=False)
loose, _ = mask(src, shore.geometry, crop=True, all_touched=True)
print((strict != src.nodata).sum(), (loose != src.nodata).sum())
# 1_884_221_004 1_884_612_889 -> 391,885 cells, all of them at the coast
Which is right depends on what the product is for. A navigation surface wants all_touched=False on the water polygon, so a cell that is only partly water is excluded and the surface never claims depth where there may be land. An area or volume statistic wants neither: it wants fractional coverage weights, which rasterio.features.rasterize can approximate by supersampling but which neither flag provides. What matters is that the choice is deliberate and recorded, because it is not recoverable from the output.
Step-by-step fix with production code
1. Reproject the mask, never the raster
Reprojecting a raster resamples it: values are interpolated onto a new grid and the original cell values no longer exist. Reprojecting the mask polygon is exact — it moves vertices, and the raster grid is untouched. Always move the vector.
def align_mask(mask_gdf: gpd.GeoDataFrame, raster_crs) -> gpd.GeoDataFrame:
"""Bring a mask into the raster's CRS. Never the other way round."""
if mask_gdf.crs is None:
raise ValueError("mask has no CRS; assign the correct one before clipping")
if mask_gdf.crs == raster_crs:
return mask_gdf
logger.info("reprojecting mask %s → %s", mask_gdf.crs.to_string(), raster_crs.to_string())
return mask_gdf.to_crs(raster_crs)
2. Read the window, not the file
rasterio.mask.mask with crop=True still reads the full extent before cropping unless it is given a windowed dataset. On a terabyte mosaic clipped to a single estuary, computing the window first turns an impossible read into a small one.
import numpy as np
import rasterio
from rasterio.features import geometry_mask
from rasterio.windows import from_bounds
def clip_windowed(
raster_path: str,
mask_gdf: gpd.GeoDataFrame,
*,
all_touched: bool = False,
) -> tuple[np.ndarray, dict]:
"""Clip to a mask, reading only the mask's window from the raster."""
with rasterio.open(raster_path) as src:
if src.nodata is None:
raise ValueError(
f"{raster_path} declares no nodata value; masking would have to "
"invent one, and 0 is a valid depth"
)
aligned = align_mask(mask_gdf, src.crs)
window = from_bounds(*aligned.total_bounds, transform=src.transform)
window = window.round_offsets().round_lengths()
data = src.read(1, window=window)
transform = src.window_transform(window)
inside = geometry_mask(
aligned.geometry, out_shape=data.shape, transform=transform,
invert=True, all_touched=all_touched,
)
out = np.where(inside, data, src.nodata).astype(data.dtype)
profile = src.profile | {
"height": data.shape[0], "width": data.shape[1], "transform": transform,
}
logger.info(
"clipped %s: %d of %d cells retained", raster_path,
int(inside.sum()), inside.size,
)
return out, profile
The nodata is None guard is the third default that bites. Masking has to write something into the excluded cells, and libraries that default to zero produce a bathymetric grid in which every land cell reads as a depth of exactly 0.0 m — a valid, plausible, catastrophic value that will be averaged into every statistic and contoured as a shoal.
3. Keep the grid, and prove you kept it
The other silent corruption in clipping is grid drift. Any operation that reprojects, resamples or “snaps” the output moves the cell centres, and a mosaic assembled from tiles clipped at slightly different origins will not align — the seams appear as a one-cell offset that looks like a real terrain step.
The defence is to derive the output transform from the source transform and a window, as the function above does, rather than from the mask’s bounds. A window is an integer offset into the existing grid, so the output’s cell centres are a subset of the input’s by construction. Building a transform from mask.total_bounds instead produces an origin at an arbitrary real coordinate and every tile lands on its own lattice.
def assert_grid_preserved(src_transform, out_transform, cell_tol: float = 1e-9) -> None:
"""Output cells must lie on the source lattice, not merely near it."""
if abs(src_transform.a - out_transform.a) > cell_tol or \
abs(src_transform.e - out_transform.e) > cell_tol:
raise ValueError("cell size changed: the clip resampled the raster")
dx = (out_transform.c - src_transform.c) / src_transform.a
dy = (out_transform.f - src_transform.f) / src_transform.e
if abs(dx - round(dx)) > 1e-6 or abs(dy - round(dy)) > 1e-6:
raise ValueError(
f"output origin is {dx - round(dx):.6f}, {dy - round(dy):.6f} cells off "
"the source lattice; tiles clipped this way will not mosaic"
)
This assertion costs microseconds and catches a class of defect whose usual discovery route is a visible seam in a published mosaic.
Verification and acceptance test
The retained cell count and the mask polygon’s area are two independent measurements of the same thing, and they should agree to within the boundary ring.
def test_clip_area_matches_mask_area():
shore = gpd.read_file("shoreline_mhw.gpkg").to_crs("EPSG:32618")
data, profile = clip_windowed("bathy_1m.tif", shore, all_touched=False)
cell_area = abs(profile["transform"].a * profile["transform"].e)
retained = int((data != profile["nodata"]).sum())
raster_area = retained * cell_area
polygon_area = float(shore.geometry.area.sum())
# The boundary ring is the expected disagreement: perimeter × cell size.
perimeter = float(shore.geometry.length.sum())
ring = perimeter * abs(profile["transform"].a)
assert abs(raster_area - polygon_area) < ring, (
f"raster {raster_area:,.0f} m² vs polygon {polygon_area:,.0f} m² "
f"differs by more than one boundary ring ({ring:,.0f} m²)"
)
assert not np.any(data[~np.isnan(data)] == 0.0), "zero-depth cells: nodata leaked"
Edge cases and gotchas
- A mask polygon that extends beyond the raster.
from_boundsreturns a window partly outside the dataset;round_lengthsdoes not clip it, and the read returns a smaller array than the window implies, silently misaligning the mask. Intersect the window with the dataset window before reading. - Multipart masks with many small parts.
geometry_maskrasterizes every part; a mask of 40,000 islets is slow and, worse, allocates per part. Dissolve to a single geometry first withunion_all, which is one call and typically an order of magnitude faster. - Float rasters and integer nodata. A
float32bathymetric grid whose profile declaresnodata=-9999is fine; the same grid written asint16withnodata=-9999silently clips real depths below −9999 cm. Check that the nodata sentinel is outside the data’s actual range, not merely outside its plausible range.
Pipeline placement: clip last, and only for publication
The instinct is to clip early — trim the raster to the study area, then process the smaller grid. For coastal rasters that is usually the wrong order, for two reasons.
Neighbourhood operations need a halo. Slope, curvature, smoothing and gap-filling all read a window around each cell, so a grid clipped to the analysis boundary produces wrong values in the outermost ring of cells: the kernel finds nodata where it needed neighbours. Clipping first and then computing terrain derivatives puts a rim of subtly wrong values around every product, and the rim is exactly the coastal margin the analysis is usually about.
Clipping is also lossy in a way that is not recoverable. Once land cells are nodata, a later decision to move the shoreline — a new datum, a re-survey, a corrected tide model — cannot be applied to the clipped product; it needs the unclipped one. Since the shoreline is a modelled surface that moves whenever the model improves, a pipeline that clips early has baked a provisional decision into its archive.
The working order is: process on the full grid with a generous margin, clip as the final publication step, and keep the unclipped product as the archival copy. Storage is cheap relative to a reprocess, and the clipped view can be regenerated from the archive in seconds whenever the shoreline changes.
The one legitimate early clip is a coarse one: trimming a continental mosaic to a generous bounding box around the survey before processing, with a margin several kernel widths wider than any operation that will run. That is a read-volume optimisation rather than a masking step, and it uses a rectangle rather than the coastline, so none of the above applies to it.
Related
- Simplifying Shorelines Without Breaking Topology — the tolerance that decides how well the mask matches the coast
- Understanding NetCDF vs GeoTIFF for Marine Data — nodata conventions across the two formats
- Vector Shoreline & Coastal Geometry Processing — the parent workflow