Skip to content

Instantly share code, notes, and snippets.

@scottstanie
Last active January 30, 2026 15:53
Show Gist options
  • Select an option

  • Save scottstanie/7f339a391e58947bee8e52a490448443 to your computer and use it in GitHub Desktop.

Select an option

Save scottstanie/7f339a391e58947bee8e52a490448443 to your computer and use it in GitHub Desktop.
Create a water mask in EPSG:4326 using `esa_world_cover_2021` imagery or Natural Earth land polygons.
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "geopandas",
# "rasterio",
# "tyro",
# "requests",
# ]
# ///
"""Create a water mask in EPSG:4326 using Natural Earth land polygons.
This script downloads the Natural Earth 10m land shapefile and rasterizes it
to a 0/1 (water/land) mask for a specified bounding box.
Examples
--------
uv run --script create-water-mask.py --output land_mask.tif -120.9 34.85 -120.25 35.15
"""
from pathlib import Path
import geopandas as gpd
import rasterio as rio
import requests
import tyro
from rasterio import features
from rasterio.transform import from_bounds
NATURAL_EARTH_URL = "https://naciscdn.org/naturalearth/10m/physical/ne_10m_land.zip"
def download_ne_data(target_path: Path, source: str | Path = NATURAL_EARTH_URL) -> Path:
"""Download Natural Earth zip if it doesn't exist."""
if not target_path.exists():
print(f"Downloading Natural Earth data to {target_path}...")
response = requests.get(source, stream=True)
response.raise_for_status()
with open(target_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
return target_path
def create_land_mask(
bounds: tuple[float, float, float, float],
/,
output: Path,
resolution: float = 0.001,
) -> None:
"""Create a land mask from Natural Earth polygons.
Parameters
----------
bounds : tuple[float, float, float, float]
(west, south, east, north) in degrees longitude/latitude.
output : Path
Path to save the output GeoTIFF.
resolution : float
Pixel size in degrees (default 0.001 is ~100m).
"""
if output.exists():
print(f"{output} exists, exiting.")
return
# 1. Setup Data
cache_dir = Path("data_cache")
cache_dir.mkdir(exist_ok=True)
zip_path = cache_dir / "ne_10m_land.zip"
download_ne_data(zip_path)
print(f"Loading polygons and clipping to {bounds}...")
# Load directly from zip using /vsizip/ or geopandas zip support
gdf = gpd.read_file(zip_path)
# Clip to bounds to speed up rasterization
west, south, east, north = bounds
gdf_clipped = gdf.clip([west, south, east, north])
# 2. Define Raster Grid
width = int((east - west) / resolution)
height = int((north - south) / resolution)
# Check for empty dimensions
if width <= 0 or height <= 0:
raise ValueError("Bounds or resolution result in invalid dimensions.")
transform = from_bounds(west, south, east, north, width, height)
print(f"Rasterizing to {width}x{height} grid: {output}")
# 3. Rasterize
# Land polygons = 1, Background (water) = 0
mask_data = features.rasterize(
[(geom, 1) for geom in gdf_clipped.geometry],
out_shape=(height, width),
transform=transform,
fill=0,
dtype="uint8",
)
# 4. Save
output.parent.mkdir(exist_ok=True, parents=True)
meta = {
"driver": "GTiff",
"dtype": "uint8",
"nodata": 255, # Using 255 as nodata, 0/1 for data
"width": width,
"height": height,
"count": 1,
"crs": "EPSG:4326",
"transform": transform,
"compress": "lzw"
}
with rio.open(output, "w", **meta) as dst:
dst.write(mask_data, 1)
print("Done.")
if __name__ == "__main__":
tyro.cli(create_land_mask)
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "tile-mate",
# "tyro",
# ]
# ///
"""Create a water mask in EPSG:4326 using `esa_world_cover_2021` imagery.
Examples
--------
uv run --script create-water-mask.py --output water_mask.tif -120.9 34.85 -120.25 35.15
"""
from pathlib import Path
import rasterio as rio
import tile_mate
import tyro
def download_water_mask(
bounds: tuple[float, float, float, float], /, output: Path
) -> None:
"""Download a water mask for the given DISP dataset.
Retrieves a water mask corresponding to the provided DISP dataset and saves it
in the specified output directory.
Parameters
----------
bounds : tuple[int, int, int, int]
(west, south, east, north) in degrees longitude/latitude of requested area.
output_dir : str | Path
Directory where the downloaded water mask file will be saved.
"""
# Create output directory if does not exist
if output.exists():
print(f"{output} exists, exiting.")
return
print(f"Saving Water Mask: {output}")
output_dir = output.parent
output_dir.mkdir(exist_ok=True)
mask_data, mask_attr = tile_mate.get_raster_from_tiles(
bounds, tile_shortname="esa_world_cover_2021"
)
# Make byte mask
# class 80 is water
water_pixels = mask_data == 80
# 0 was the old nodata
nodata_pixels = mask_data == 0
mask_data[:] = 1
mask_data[water_pixels] = 0
mask_data[nodata_pixels] = 255
mask_data = mask_data.astype("byte")
mask_attr["nodata"] = 255
with rio.open(output, "w", **mask_attr) as dst:
dst.write(mask_data)
if __name__ == "__main__":
tyro.cli(download_water_mask)
"""High-resolution water mask creation from ASF water mask tiles.
ASF's water mask mosaics OpenStreetMap and ESA WorldCover data at high resolution.
Tiles are publicly hosted at s3://asf-dem-west/WATER_MASK/TILES/ and use the naming
convention {lat_dir}{lat}{lon_dir}{lon}.tif (e.g., n60w165.tif) on a 5-degree grid.
In the source tiles: 1 = land, 0 = water.
Output masks can optionally invert this convention and apply shoreline buffering.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
import numpy as np
from numpy.typing import NDArray
from osgeo import gdal
from scipy import ndimage
gdal.UseExceptions()
logger = logging.getLogger(__name__)
TILE_URL_BASE = "https://asf-dem-west.s3.amazonaws.com/WATER_MASK/TILES/"
class WaterValue(Enum):
"""Convention for water mask pixel values."""
ZERO = 0 # water=0, land=1 (ASF native)
ONE = 1 # water=1, land=0 (inverted)
@dataclass
class GeoTransform:
"""Affine geotransform parameters."""
x_origin: float
pixel_width: float
row_rotation: float
y_origin: float
col_rotation: float
pixel_height: float # typically negative
@classmethod
def from_gdal(cls, gt: tuple[float, ...]) -> GeoTransform:
return cls(*gt)
def to_gdal(self) -> tuple[float, ...]:
return (
self.x_origin,
self.pixel_width,
self.row_rotation,
self.y_origin,
self.col_rotation,
self.pixel_height,
)
@property
def bounds(self) -> tuple[float, float, float, float]:
"""Return (x_min, y_min, x_max, y_max) - requires shape to compute."""
raise NotImplementedError("Need raster dimensions to compute bounds")
def get_bounds(self, width: int, height: int) -> tuple[float, float, float, float]:
"""Return (west, south, east, north) given raster dimensions."""
west = self.x_origin
north = self.y_origin
east = west + self.pixel_width * width
south = north + self.pixel_height * height # pixel_height is negative
return (west, south, east, north)
def _coord_to_tile_name(lon: float, lat: float) -> str:
"""Convert a coordinate to ASF tile filename.
Tiles are on a 5-degree grid. E.g., (lon=-163.5, lat=59.0) -> n55w165.tif
"""
lat_floor = int(np.floor(lat / 5) * 5)
lon_floor = int(np.floor(lon / 5) * 5)
lat_dir = "n" if lat_floor >= 0 else "s"
lon_dir = "e" if lon_floor >= 0 else "w"
return f"{lat_dir}{abs(lat_floor):02d}{lon_dir}{abs(lon_floor):03d}.tif"
def _get_tile_urls(west: float, south: float, east: float, north: float) -> list[str]:
"""Get URLs for all tiles covering the given bounds."""
tiles: list[str] = []
corners = [
(west, north), # upper-left
(west, south), # lower-left
(east, north), # upper-right
(east, south), # lower-right
]
# Handle wide extents (high latitudes) that span multiple tiles
width = east - west
if width > 5:
mid_lon = west + width / 2
corners.extend([(mid_lon, north), (mid_lon, south)])
for lon, lat in corners:
tile_name = _coord_to_tile_name(lon, lat)
url = f"/vsicurl/{TILE_URL_BASE}{tile_name}"
if url not in tiles:
tiles.append(url)
return tiles
def _get_raster_info(path: str | Path) -> tuple[GeoTransform, int, int, str]:
"""Get geotransform, dimensions, and projection from a raster."""
ds = gdal.Open(str(path))
gt = GeoTransform.from_gdal(ds.GetGeoTransform())
width, height = ds.RasterXSize, ds.RasterYSize
proj = ds.GetProjection()
ds = None
return gt, width, height, proj
def _buffer_mask(
mask: NDArray[np.uint8],
buffer_pixels: int,
water_value: WaterValue,
) -> NDArray[np.uint8]:
"""Expand water regions by buffer_pixels using morphological dilation."""
if buffer_pixels <= 0:
return mask
# Create circular structuring element
diameter = 2 * buffer_pixels + 1
y, x = np.ogrid[:diameter, :diameter]
center = buffer_pixels
struct = ((x - center) ** 2 + (y - center) ** 2) <= buffer_pixels**2
if water_value == WaterValue.ONE:
# Water is 1, dilate it
return ndimage.binary_dilation(mask, structure=struct).astype(np.uint8)
else:
# Water is 0, erode land (dilate inverted, then invert back)
land = mask == 1
eroded_land = ~ndimage.binary_dilation(~land, structure=struct)
return eroded_land.astype(np.uint8)
def create_water_mask(
bounds: tuple[float, float, float, float],
output: Path,
resolution: float | None = None,
buffer_meters: float = 0.0,
water_value: WaterValue = WaterValue.ONE,
overwrite: bool = False,
) -> Path:
"""Create a water mask GeoTIFF for the given bounds.
Parameters
----------
bounds
(west, south, east, north) in EPSG:4326 degrees.
output
Output GeoTIFF path.
resolution
Pixel size in degrees. If None, uses native tile resolution (~0.0001 deg).
buffer_meters
Buffer distance to expand water mask into land. Approximate conversion
to pixels uses 111 km/degree at equator.
water_value
Convention for output: ONE means water=1/land=0, ZERO means water=0/land=1.
overwrite
If False and output exists, skip creation.
Returns
-------
Path to the output raster.
"""
if output.exists() and not overwrite:
logger.info("Output %s exists, skipping.", output)
return output
west, south, east, north = bounds
tiles = _get_tile_urls(west, south, east, north)
if not tiles:
raise ValueError(f"No water mask tiles found for bounds {bounds}")
logger.info("Using %d tile(s): %s", len(tiles), [t.split("/")[-1] for t in tiles])
# Build VRT from tiles
vrt_options = gdal.BuildVRTOptions(resampleAlg="nearest")
vrt_ds = gdal.BuildVRT("", tiles, options=vrt_options)
if vrt_ds is None:
raise RuntimeError(f"Failed to build VRT from tiles: {tiles}")
# Set up output bounds and resolution
if resolution is None:
# Use native resolution from first tile
tile_ds = gdal.Open(tiles[0])
resolution = tile_ds.GetGeoTransform()[1]
tile_ds = None
width = int(np.ceil((east - west) / resolution))
height = int(np.ceil((north - south) / resolution))
# Warp to target extent
warp_options = gdal.WarpOptions(
format="MEM",
outputBounds=(west, south, east, north),
width=width,
height=height,
resampleAlg="nearest",
)
mem_ds = gdal.Warp("", vrt_ds, options=warp_options)
vrt_ds = None
if mem_ds is None:
raise RuntimeError("Failed to warp water mask to target extent")
# Read data
data = mem_ds.GetRasterBand(1).ReadAsArray()
gt = mem_ds.GetGeoTransform()
proj = mem_ds.GetProjection()
mem_ds = None
# ASF convention: 1=land, 0=water. Invert if requested.
if water_value == WaterValue.ONE:
data = 1 - data
# Apply buffer if requested
if buffer_meters > 0:
# Approximate degrees per meter at this latitude
center_lat = (south + north) / 2
meters_per_deg = 111_000 * np.cos(np.radians(center_lat))
buffer_pixels = int(np.ceil(buffer_meters / (resolution * meters_per_deg)))
logger.info(
"Buffering water by %d pixels (~%.0f m)", buffer_pixels, buffer_meters
)
data = _buffer_mask(data, buffer_pixels, water_value)
# Write output
output.parent.mkdir(parents=True, exist_ok=True)
driver = gdal.GetDriverByName("GTiff")
out_ds = driver.Create(
str(output),
width,
height,
1,
gdal.GDT_Byte,
options=["COMPRESS=LZW", "TILED=YES"],
)
out_ds.SetGeoTransform(gt)
out_ds.SetProjection(proj)
band = out_ds.GetRasterBand(1)
band.WriteArray(data)
band.SetNoDataValue(255)
out_ds.FlushCache()
out_ds = None
logger.info("Created water mask: %s", output)
return output
def create_water_mask_like(
reference: Path,
output: Path,
buffer_meters: float = 0.0,
water_value: WaterValue = WaterValue.ONE,
overwrite: bool = False,
) -> Path:
"""Create a water mask matching the extent and resolution of a reference raster.
Parameters
----------
reference
Reference GeoTIFF to match (must be EPSG:4326).
output
Output GeoTIFF path.
buffer_meters
Buffer distance to expand water mask into land.
water_value
Convention for output values.
overwrite
If False and output exists, skip creation.
Returns
-------
Path to the output raster.
"""
gt, width, height, _ = _get_raster_info(reference)
bounds = gt.get_bounds(width, height)
resolution = gt.pixel_width
return create_water_mask(
bounds=bounds,
output=output,
resolution=resolution,
buffer_meters=buffer_meters,
water_value=water_value,
overwrite=overwrite,
)
def cli(
output: Path,
bounds: tuple[float, float, float, float] | None = None,
reference: Path | None = None,
resolution: float | None = None,
buffer_meters: float = 0.0,
water_is_one: bool = True,
overwrite: bool = False,
verbose: bool = False,
) -> None:
"""Create a high-resolution water mask from ASF water mask tiles.
Provide either --reference to match an existing raster, or all four
bounds (west, south, east, north).
Args:
output: Output GeoTIFF path.
bounds: (west, south, east, north) in latitude/ longitude.
reference: Reference raster to match extent/resolution.
resolution: Pixel size in degrees (default: native ~0.0001 deg).
buffer_meters: Expand water mask into land by this distance.
water_is_one: If True, water=1/land=0; if False, water=0/land=1.
overwrite: Overwrite existing output.
verbose: Enable debug logging.
"""
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(levelname)s: %(message)s",
)
water_value = WaterValue.ONE if water_is_one else WaterValue.ZERO
if reference is None and bounds is None:
raise ValueError(
"Provide either --reference or all bounds (--west, --south, --east, --north)"
)
if reference is not None:
create_water_mask_like(
reference=reference,
output=output,
buffer_meters=buffer_meters,
water_value=water_value,
overwrite=overwrite,
)
else:
create_water_mask(
bounds=bounds,
output=output,
resolution=resolution,
buffer_meters=buffer_meters,
water_value=water_value,
overwrite=overwrite,
)
if __name__ == "__main__":
import tyro
tyro.cli(cli)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment