Generate Stamen Watercolor–style raster map tiles from OpenStreetMap data — with multi-pass rendering, mask processing, watercolor textures, and seamless compositing.
WaterColorMap is built for "old-school" raster cartography: we render clean layer masks (Mapnik), distort edges organically (blur + deterministic Perlin noise + threshold), apply seamless watercolor textures, then composite everything into final web-ready tiles.
- Full watercolor tile pipeline (render → masks → textures → composite)
- Deterministic edges across tile boundaries (no seams)
- Multi-pass Mapnik rendering for clean layer isolation
- Built-in textures and Mapnik styles (land/water/parks/civic/roads)
- Fast batch generation with safe caching and
--forceregeneration - Docker and native Linux workflows
- Linux (tested on Ubuntu 24.04)
- Go 1.25+
- Mapnik 3.1+ (
libmapnik-dev,mapnik-utils,python3-mapnik) - Build tooling:
pkg-config,build-essential - Optional: Just for one-liner workflows
git clone https://github.com/cwbudde/WaterColorMap.git
cd WaterColorMap
sudo apt update
sudo apt install -y libmapnik-dev mapnik-utils python3-mapnik build-essential pkg-config
cp config.example.yaml config.yaml
just build
# Generate a single tile (Hanover example)
./bin/watercolormap generate --zoom 13 --x 4317 --y 2692More setup details (including troubleshooting) are in SETUP.md.
cp config.example.yaml config.yaml
just docker-build
docker run --rm \
-v "$PWD/config.yaml:/app/config.yaml:ro" \
-v "$PWD/tiles:/app/tiles" \
-v "$PWD/cache:/app/cache" \
-v "$PWD/assets:/app/assets:ro" \
-e WATERCOLORMAP_CONFIG=/app/config.yaml \
watercolormap:latest generate --zoom 13 --x 4317 --y 2692Generate a few tiles, then look at them in the built-in Leaflet demo. This is the whole path, end to end:
# 1. Generate a small batch around Hanover (zoom 12-13)
./bin/watercolormap generate \
--bbox "9.65,52.32,9.85,52.43" \
--zoom-min 12 --zoom-max 13 \
--allow-failures
# 2. Serve the tiles plus the demo UI
just serve
# equivalently: ./bin/watercolormap serve --addr 127.0.0.1:8080
# 3. Open the demo
# http://127.0.0.1:8080/demo/just smoke does steps 1 and 2 in one go for a 3×3 block at zoom 13.
A single tile, by coordinate:
watercolormap generate --zoom 13 --x 4317 --y 2692A batch, by bounding box (minLon,minLat,maxLon,maxLat) and zoom range:
watercolormap generate \
--bbox "9.65,52.32,9.85,52.43" \
--zoom-min 10 --zoom-max 16 \
--hidpi --allow-failuresUseful flags: --force (regenerate existing tiles), --workers,
--hidpi (also write @2x tiles), --png-compression,
--folder-structure (flat or nested), and --format mbtiles with
--output-file to write an MBTiles file directly. See
watercolormap generate --help for the full list.
watercolormap convert --input-dir ./tiles --output hanover.mbtiles--output is required. See watercolormap convert --help for the metadata flags (--name, --description, --attribution, --bounds).
watercolormap textures --textures-dir assets/textures --size 1024Textures are deterministic for a given --seed. See watercolormap textures --help for the remaining flags.
serve hosts the tiles and the Leaflet demo, and generates any tile that is
missing on the fly (--generate-missing, on by default):
# Serve a tile folder (defaults to --output-dir)
watercolormap serve --addr 127.0.0.1:8080 --tiles-dir ./tiles
# Or serve an MBTiles file instead
watercolormap serve --mbtiles ./tiles.mbtilesRoutes: /demo/ (Leaflet UI), /tiles/z{z}_x{x}_y{y}.png, /tiles/status
(JSON) and /tiles/status/stream (SSE), /healthz.
Cross-origin requests are off by default. A page served from another origin — the WASM playground, a GitHub Pages demo — needs the header switched on explicitly:
watercolormap serve --cors-origin '*' # any origin
watercolormap serve --cors-origin https://example.com
# or: just serve-corsThe value is also settable as serve.cors_origin in config.yaml. An empty
value (the default) sends no Access-Control-* headers at all.
There is a minimal browser playground (Leaflet) that renders tiles on demand entirely in the browser — it queries Overpass directly and rasterises in WASM, with no backend. It can be deployed via GitHub Pages.
- Live (GitHub Pages): https://cwbudde.github.io/WaterColorMap/
- Details: docs/wasm-playground.md
- Local (build + serve):
just build-wasm-local
# open http://localhost:8000/wasm-playground/docs/wasm-playground/wasm.wasm and docs/wasm-playground/wasm_exec.js are build artifacts produced by just build-wasm; they are not committed.
Note: the playground uses the pure-Go rasteriser in internal/raster, not Mapnik, so its output differs from the tiles generate produces. For Mapnik-quality tiles on demand, run the backend server (see Serving tiles). The playground page is served from a different origin than the tile server, so CORS has to be switched on:
./bin/watercolormap serve --addr 127.0.0.1:8080 --cors-origin '*'
# or: just serve-corsBy default, tiles are written to ./tiles as PNG files using the naming scheme:
tiles/
z13_x4297_y2754.png
z13_x4297_y2754@2x.png # optional HiDPI output
HiDPI (@2x) tiles are generated by passing --hidpi to watercolormap generate.
PNG encoding can be tuned via --png-compression (default, speed, best, none).
During generation, intermediate layer renders and processed masks may be stored in the cache directory for debugging and faster incremental builds.
- Fetch OSM features for the requested tile (Overpass API)
- Convert features to GeoJSON per layer (land/water/parks/civic/roads)
- Render each layer via Mapnik to a clean RGBA mask image (multi-pass)
- Convert layer images to binary masks and apply the watercolor mask pipeline:
- Gaussian blur
- deterministic Perlin noise overlay
- thresholding + antialias
- Apply seamless watercolor textures as alpha-masked fills
- Composite layers in the correct order into a final tile
Mask design and rationale: docs/watercolor-mask-design.md Per-stage detail: docs/3.1-mask-processing-pipeline.md
A full map of the documentation — design notes, performance write-ups and the archived records of completed phases — is in AGENTS.md.
Configuration is loaded from:
- The YAML file given by
--config(defaults to./config.yaml) - CLI flags, which override the file
Start with the example file: config.example.yaml
Note that YAML keys use underscores where the corresponding flag uses hyphens
(generate.zoom_min for --zoom-min, serve.tiles_dir for --tiles-dir).
Keys that are actually read:
data-source: OSM data source (default:overpass; no other source is implemented)output-dir: where generated tiles go (default:./tiles)verbose,log-level: logging (default level:info)overpass.endpoint/overpass.servers: read byserveonly —generatealways builds a default single-endpoint Overpass sourceocean.*: the water polygons used for ocean and coastline rendering (see below)generate.*,serve.*,convert.*,textures.*: mirror the flags of the respective command
OpenStreetMap does not map the ocean — the sea is modelled as the absence of land — so the open sea has to come from somewhere else. Without it, ocean tiles render as land and coastal tiles come out inverted, with the sea painted tan and lakes painted blue.
The source is the processed water polygons from osmdata.openstreetmap.de, rendered directly through Mapnik's shapefile plugin:
just fetch-water-polygons # ~1 GB into ./data (gitignored)
# or, for low zooms only:
just fetch-water-polygons-simplified # ~120 MBThen point config.yaml at them — see the ocean: block in
config.example.yaml. Ocean rendering is off until it is
configured; inland tiles render identically either way.
just build
just test
just fmt
just lint
just checkcmd/watercolormap/ # CLI entry
internal/datasource/ # OSM/Overpass fetching
internal/geojson/ # OSM features → GeoJSON
internal/renderer/ # Mapnik rendering + multi-pass
internal/mask/ # Watercolor mask processing
internal/tile/ # z/x/y math + bounds
assets/styles/ # Mapnik styles
assets/textures/ # Seamless watercolor textures
docs/ # Design notes and phase docs
- Map data: © OpenStreetMap contributors
- Watercolor inspiration: Stamen Design's "Watercolor" process and textures writeups
MIT License — see LICENSE for details.