watermask
Water masks for any area on Earth: the sea, lakes, rivers, canals and docks as a coverage grid, shoreline polylines and a distance-to-shore field. The water comes from OpenStreetMap vector tiles fetched on demand, so there is nothing to download in advance: only the tiles covering the area, at the zoom that matches the output resolution.
One Rust core, published for Rust, Python, Node.js and the browser (WebAssembly).
Cape Cod and the islands. The shoreline is mask.outlines(); the lines at sea are
outlines of mask.distance() at growing distances, fading out.
Amsterdam, Venice, Stockholm: mask.coverage in grey, the shoreline in black,
waterway centre lines in grey. cargo run --release --example readme redraws
every image here from live tiles.
import watermask
vineyard = (-70.85, 41.3, -70.45, 41.55) # west, south, east, north
water = watermask.fetch(vineyard, width=1200) # tiles are cached after the first run
mask = water.mask(vineyard, 1200)
mask.coverage # (1000, 1200) float32, fraction of each pixel that is water
mask.outlines() # shoreline: [(n, 2) float32] in pixels, water on the left
mask.distance() # pixels to the shore, positive in water, negative on land
water.geojson() # the water as GeoJSON in lon/lat, one MultiPolygon per class
Why
Deriving water from elevation (everything at or below 0 m) misses lakes and rivers, which sit above sea level, and floods land that lies below it: the Netherlands, deltas, reclaimed coasts. Complete coastline and water datasets exist (the OSM water polygons), but they are gigabytes and have to be tiled and stored before use. Vector tile services already serve that data cut into tiles, for free, one small tile at a time.
Where the water comes from
By default OpenFreeMap: the whole planet in the
OpenMapTiles schema, rebuilt weekly from
OpenStreetMap, free with no key and no request limits. Any source in that
schema works (MapTiler, a self-hosted copy of OpenFreeMap's planet file): pass
its TileJSON URL or a {z}/{x}/{y} URL template.
Two layers are read:
| layer | kind | classes (default in bold) |
|---|---|---|
water |
areas | ocean, lake, river, dock, pond, swimming_pool |
waterway |
lines | river, canal, stream, ditch, drain |
river areas are the water surface of wide rivers and canals; river lines
are centre lines, of every river, however narrow. Seasonal water
(intermittent) and water underground (tunnels) are left out unless asked
for.
At zoom 14, the deepest, a tile is about 2.4 km across at the equator and its coordinates are quantised to about 0.6 m; OpenMapTiles simplifies geometry there by less than that, so this is full OpenStreetMap detail. At lower zooms tiles are simplified and small features dropped, in step with the pixel size.
Attribution. Maps made with this data must credit "© OpenMapTiles © OpenStreetMap contributors".
How it works
- Zoom. The shallowest zoom whose tiles (drawn 256 px wide) have pixels
no larger than the output's, capped at the source's deepest zoom, then
lowered while the area needs more than
max_tilestiles (default 256). - Tiles. Fetched in parallel and cached on disk, only their water layers
(a fifth of the bytes or less). The cache is
$WATERMASK_CACHE, or else~/Library/Caches/watermaskon macOS,%LOCALAPPDATA%\watermaskon Windows,~/.cache/watermaskon Linux. A cached tile is used for 30 days, across OpenFreeMap's weekly builds, then downloaded again; when offline, older tiles are used anyway. Tiles past 30 days that nobody asks for are deleted, checked once a day. The TileJSON is read each run, since OpenFreeMap's tile URLs change with every build, and the last good copy is used when offline. - Decoding. A small protobuf reader pulls the two layers out of each tile and cuts every feature to its own tile, so the buffer tiles share with their neighbours is not counted twice.
- Mask. Polygons are filled with the nonzero winding rule, so the many pieces of one sea join without seams and islands stay holes. Each pixel gets its exact covered fraction along the row and four sub-rows down it.
- Shoreline. Marching squares at coverage ½, joined into polylines with water on the left.
- Distance. An exact Euclidean distance transform, signed.
- Polygons. For GeoJSON, the pieces of each class are joined with a polygon union (i_overlay), same nonzero rule as the mask, so the polygons cover what the mask covers. The output is OGC-valid (shapely and GEOS take it as is), exteriors counter-clockwise and holes clockwise, and can be cut to a box.
Masks are on a north-up Web Mercator grid. In Rust, Water::mask_with takes
any projection instead.
Rust
[dependencies]
watermask = "0.2" # default-features = false drops the HTTP client
use watermask::{Fetcher, Filter, GeoJsonOptions, Grid, MaskOptions, ZoomLimits};
let bounds = [-70.85, 41.3, -70.45, 41.55];
let grid = Grid::with_width(bounds, 1200);
let water = Fetcher::new().water_for(&grid, &Filter::default(), &ZoomLimits::default(), |done, total| {
eprintln!("{done}/{total} tiles");
})?;
let mask = water.mask(&grid, &MaskOptions::default());
let shore = mask.outlines();
let dist = mask.distance();
// Any projection: lon, lat → pixel.
let tm = water.mask_with(800, 600, &MaskOptions::default(), |lon, lat| my_projection(lon, lat));
// Polygons: joined per class and cut to the box (Water::merged gives the same as structs).
let json = water.to_geojson(&GeoJsonOptions { bounds: Some(bounds), ..Default::default() });
Without the fetch feature, bring tiles from anywhere:
let mut water = watermask::Water::new();
for id in watermask::tiles_for(&bounds, 13) {
water.add_tile(id, &bytes_of(id), &Filter::default())?;
}
cargo run --release --example render -- -70.85,41.3,-70.45,41.55 1200 out.png
draws the mask, shoreline and waterways of an area.
Python
pip install watermask
The example at the top covers most uses. Also:
watermask.fetch(bounds, width, areas=["ocean"], lines=[], source=..., log=print),
water.lines(bounds, width) for waterway centre lines as (class, (n, 2) array),
water.mask(..., line_width=1.5) to burn them into the mask,
Water().add_tile(z, x, y, data) for your own tiles,
zoom_for(bounds, width) and tiles_for(bounds, zoom).
water.geojson(bounds=vineyard) cuts the polygons and lines to the box;
water.geojson(pieces=True) keeps the areas as the tiles cut them. Into
shapely:
from shapely.geometry import shape
sea = next(shape(f["geometry"]) for f in water.geojson(bounds=vineyard)["features"]
if f["properties"]["class"] == "ocean")
Node.js
npm install @pileoni/watermask
const wm = require('@pileoni/watermask')
const vineyard = [-70.85, 41.3, -70.45, 41.55]
const water = wm.fetch(vineyard, { width: 1200 }) // blocks until the tiles are in
const mask = water.mask(vineyard, 1200)
mask.coverage() // Float32Array, row 0 at the top
const [pts, ends] = mask.outlines() // x,y pairs; ends[i] = end of line i, in points
mask.distance() // Float32Array
water.geojson({ bounds: vineyard }) // string; also { pieces: true }
Browser (WebAssembly)
npm install @pileoni/watermask-wasm
The page fetches the tiles; watermask reads them.
import init, { Water, zoomFor, tilesFor, tileUrl } from '@pileoni/watermask-wasm'
await init()
const [w, s, e, n] = [-70.85, 41.3, -70.45, 41.55]
const tilejson = await (await fetch('https://tiles.openfreemap.org/planet')).json()
const z = zoomFor(w, s, e, n, 1200, 256, tilejson.maxzoom)
const ids = tilesFor(w, s, e, n, z) // z, x, y, z, x, y, …
const water = new Water()
await Promise.all(Array.from({ length: ids.length / 3 }, async (_, i) => {
const [tz, tx, ty] = ids.slice(3 * i, 3 * i + 3)
const r = await fetch(tileUrl(tilejson.tiles[0], tz, tx, ty))
if (r.ok) water.addTile(tz, tx, ty, new Uint8Array(await r.arrayBuffer()))
}))
const mask = water.mask(w, s, e, n, 1200)
const json = water.geojson(false, [w, s, e, n]) // pieces?, bounds?
demo/index.html does this on a canvas: build the package into demo/pkg
(below), then serve the folder (python3 -m http.server -d demo).
Building
cargo test # core, offline (real tiles in tests/fixtures)
maturin develop --release && pytest # Python, in a virtualenv
cd node && npm install && npm run build && npm test
wasm-pack build crates/watermask-wasm --release --target web --out-dir ../../demo/pkg
Limits
- Masks are Web Mercator unless you project yourself (
mask_with, Rust only). - Waterway lines are cut to the box but not joined: a river is one line per tile it crosses.
- The union snaps coordinates to a grid of about 2³⁰ steps across the area: under a millimetre for a city, a few centimetres for the whole world.
- Water covered by something else (
covered=yes) is not in the tiles. - Across the 180° meridian, tiles are fetched correctly; GeoJSON longitudes on the far side run past 180.
License
MIT.
Metadata
Release files for watermask 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| watermask-0.2.0.tar.gz | 90.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| watermask-0.2.0-cp310-abi3-macosx_11_0_arm64.whl | CPython 3.10 | abi3 | macOS 11.0+ ARM64 | Details |
Total release size: 1.4 MB
Release files / watermask-0.2.0.tar.gz
| Download URL | watermask-0.2.0.tar.gz |
|---|---|
| Size | 90.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7ad1ec3c0bb9d117010d281220ee326942fdb30120ab513f1b9893bb3bc00011
|
|
BLAKE2b-256 checksum How to use checksums |
d1c2ac84b18e5c145d6070f5d326f772206381236dad53759d52216317f0b45d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / watermask-0.2.0-cp310-abi3-macosx_11_0_arm64.whl
| Download URL | watermask-0.2.0-cp310-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 1.3 MB |
| Tags | CPython 3.10 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
a25b7a05768dbfad47204485a7d9a85994853e86deef8fb6344b8991c122840e
|
|
BLAKE2b-256 checksum How to use checksums |
cb3868189ea78297e8ca502b252e80af284ee6870c7c2ed71c5c240cfe47c886
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|