Painterly cartography — render vector tiles as paintings.
ezu (絵図) is a Rust map rendering engine that turns vector tiles (MVT /
PMTiles) into painterly raster tiles via the
hokusai brush engine and a
declarative style language called Ezu Style. Where conventional map
engines aim for cartographic accuracy, ezu aims for artistic
interpretation — watercolor, ink wash, ukiyo-e, and beyond — while
preserving the geographic data underneath.
Each crate has its own README with API details and examples.
| Crate | crates.io | Description |
|---|---|---|
ezu |
Umbrella crate, re-exports + feature flags | |
ezu-core |
Tile / world coordinates, deterministic seeding | |
ezu-features |
GIS feature parsing (MVT via geozero, GeoJSON) — no remote fetch |
|
ezu-style |
Style spec parser (serde) — pure data, no rendering |
|
ezu-graph |
Typed node-DAG evaluator (Cache, Rayon parallel) | |
ezu-paint |
Painting primitives, built-in nodes, host glue (PNG / brush bank) | |
ezu-cli |
Command-line tool — tile / bbox / tiles rendering, check style validator, serve live editor + tile server |
Install the CLI from crates.io:
cargo install ezu-cliThat puts an ezu binary on your PATH. Point it at any style (URL
or local path) and a tile source (PMTiles URL/file, an {z}/{x}/{y}
MVT URL/path, or a TileJSON) and it spits out PNGs. The style can
declare its own tile sources in a sources block (MVT, PMTiles, or
raster DEM); CLI flags override anything declared there for one-off
swaps:
# Single tile to PNG (use `--out tile.webp` for lossless WebP). The
# reference styles bundle their own `sources` block (Protomaps daily
# build + Re:Earth Terrain), so no `--pmtiles` / `--mvt` is needed —
# pass them to override what the style declares.
ezu tile \
--style https://raw.githubusercontent.com/reearth/ezu/main/crates/ezu/examples/styles/watercolor-basic.json \
--tile 13/7276/3225 --out tile.png
# Terrain style — pulls raster DEM tiles from terrain.reearth.land.
ezu tile --style crates/ezu/examples/styles/hillshade.json \
--tile 11/1813/807 --out fuji.png
# bbox mosaic — stitch the tiles covering a lon/lat box into one PNG.
ezu bbox --style URL_OR_PATH \
--bbox 139.74,35.65,139.78,35.69 --zoom 13 --out tokyo.png
# XYZ pyramid — bulk-render `<out>/<z>/<x>/<y>.png` for a zoom range.
ezu tiles --style URL_OR_PATH \
--bbox 139.74,35.65,139.78,35.69 \
--min-zoom 10 --max-zoom 14 --out pyramid
# Validate a style document (parse + build graph + resolve assets).
# Exits non-zero on error — drop into a pre-commit hook / CI step.
ezu check style.json
ezu check style.json --no-fetch # parse + graph only, offline
# `--verbose` (or `-v`) enables per-node debug logs from the
# evaluator: op name, cache hit/miss, output shape, eval duration.
ezu --verbose tile --style style.json --tile 13/7276/3225 --out tile.pngThe reference style references brushes by name (watercolor_glazing,
2B_pencil, …) — these are CC0 MyPaint brushes bundled into the binary,
so they resolve without any host-side file staging. To bring your own
.myb brush, declare it in the style's assets block (with an
http(s):// URL or a path relative to --assets-dir).
For deeper hacking, clone the repo and try the tokyo example, which
renders a 2×2 batch under the reference watercolor style with Rayon
parallelism turned on:
cargo run --release --features parallel -p ezu --example tokyo
# Output PNGs in ./out/tokyo/The live editor (browser-based, edit JSON → see the map update, schema-validated as you type):
ezu serve # default example style
ezu serve crates/ezu/examples/styles/pencil-sketch.json # open a specific style
ezu serve https://example.com/style.json # or fetch one over http(s)
# Open http://127.0.0.1:8080The editor (MapLibre GL based) supports:
- Open / URL / Save — load a style from a local file or http(s) URL,
save the current buffer as
<name>.json. Open on Chromium browsers uses the File System Access API so Save writes back in place. - Apply with
⌘↵/Ctrl+↵(works anywhere on the page). - Live preview — when enabled, auto-applies on every keystroke that parses + schema-validates + server-validates clean.
- External-edit reload — when launched with a local path
(
ezu serve foo.json), the server polls the file and pushes Server-Sent Events on every change. The editor swaps the buffer silently when clean, or surfaces a Reload banner when the user has unsaved edits. The↻ HH:MM:SSindicator in the toolbar shows the last auto-reload. On Chromium, the same watch also runs against files opened via the in-browser file picker. Opening a different file viaOpen…/URL…detaches the server watch for that session. - Source MVT inspector — toggle a vector overlay of the underlying MVT, with per-layer ON/OFF and click-to-inspect feature properties. Layers are discovered from the tile at the map center; pan/zoom rescans automatically.
- Tile grid + zoom indicator — toggle a
z/x/yboundary overlay (drawn per tile viamaplibregl.addProtocol), and read the live zoom value (click to copyz @ lat,lng).
A style is a typed node DAG, not an ordered layer list. Every
operation is a node; ports are statically type-checked across six
kinds — Features (geometry + props), Raster (canvas-sized RGBA),
Sprite (image at native dimensions, consumed by placement ops),
Brush (hokusai brush handle), Scalar (constants),
ScalarField (per-pixel f32 grid — elevation, distance, scalar
noise; carries optional geographic scaling). Ports list the kinds
they accept, so polymorphic ops (e.g. blur over Raster/Sprite)
pass the input kind straight through. Intermediate buffers are
cached and reusable across tiles.
External inputs — images, brushes, per-tile MVT/GeoJSON feature
layers — enter through one uniform AssetLoader trait. The style
references each binding by name (tile.<layer> for per-tile feature
data, bare names for document-scoped assets); the host fills the
bindings before rendering. Asset src entries can be local file
paths or http(s):// URLs — native hosts (CLI, server, examples)
prefetch URLs via ezu_paint::host::prefetch_doc_assets at startup
(gated behind the http feature). Source-format choice (MVT vs
GeoJSON vs synthesized) is a host concern, not a node concern.
The minimum op set ships in ezu-paint:
- Sources —
solid,circle(both with optionalkind: spritefor synthetic placement/tiling source),noise(white / value / perlin / simplex / worley, with fBm octaves and domain warp, world-anchored for seamless tile borders;kind: scalaremits raw fBm as aScalarFieldfor terrain stylization),features,brush-file,image(load a PNG/WebP asset as aSpritefor placement / tiling ops) - Rasterization —
fill-solid(tiny-skia + libblur),fill-dabs(hokusai scatter-dab fill, world-deterministic so dabs stay seamless across tile boundaries),line(hokusai stroke along polylines),stamp(paint an image per feature point — accepts aSpriteor canvas-sizedRaster),place(composite one image at fixed canvas coordinates withfit: none/cover/contain/stretch),tiling(repeat an image across the canvas, world-anchored for seamless tile borders) - Composition —
blur(libblur Gaussian),blend(W3C 16 blend modes — multiply / screen / overlay / soft-light / hue / luminosity etc., pluscompositeoperators (destination-outfor brush-style eraser),clipfor Photoshop-style clipping masks, and an optional alpha-maskinput) - Warp —
displace(Photoshop-style displacement map: R/G channels of a second raster drive per-pixel offsets),warp(domain warp via built-in noise; world-anchored for seamless tile borders). Both grow upstream pad byamp-pxand exposeclamp/transparent/mirrorboundary modes - Adjustment —
brightness-contrast,levels(Photoshop-style in/out black/white + gamma),hsl(hue rotation + saturation/lightness shift),invert,color-to-alpha(chroma key) - Morphology / edges —
erode/dilate(per-channel min/max box filter, for mask cleanup),edge-detect(Sobel gradient magnitude),sharpen(4-neighbour Laplacian) - Channel ops —
channel-shuffle(rearrange RGBA, or stamp constants0/1into channels),posterize(per-channel quantisation) - Geometry (Voronoi family) —
voronoi(point set → diagram edges),voronoi-fracture(split polygons into Voronoi sub-cells via seed points),medial-axis(polygon → skeleton polylines for river / lake centrelines and similar),triangulate(Delaunay) - Geometry (set + transform) —
feature-boolean(union / intersection / difference / xor over polygons),transform(translate / rotate / scale),bbox(axis-aligned envelope),smooth(Chaikin),densify,resample - Utility —
switch(build-time A/B selection over any port kind; great for param-driven variants),pick-channel(extract R/G/B/A/luminance from a Raster as aScalarField, bridging intomap-range/threshold/color-ramp) - Scalar math —
map-range(linear remap with optional clamp on aScalarField),threshold(binarise with optional soft ramp) - Gradients —
gradient-linear,gradient-radial(elliptical viaaspect),gradient-conic,gradient-diamond. All take color stops and ananchor: "tile" | "world"for tile-local or world-anchored (seamless across tiles) patterns. - Terrain —
dem(sample a host-bound raster-DEM mosaic as aScalarFieldwithgeo_scalepopulated; the host declares the tile pyramid insourcesand handles fetch / decode / 3×3 stitch / overzoom upsampling for terrarium and mapbox-rgb encodings),hillshade(Horn-method analytical shade withshadeor multiply-friendlyreliefmode, optional ESRI multidirectional),slope,color-ramp(any scalar field → colour via a stops table; canonical use is hypsometric tinting of a DEM).
Example: a watercolor water layer with a brushed road on top of an earth-tone background.
{
"name": "demo",
"tile-size": 512,
"pad": 24,
"sources": { "glazing": { "type": "brush", "src": "builtin:watercolor_glazing" } },
"nodes": {
"bg": { "op": "solid", "color": "#fbf6e6" },
"earth": { "op": "features", "name": "tile.earth" },
"earth_p":{ "op": "fill-solid", "features": "@earth", "fill": "#e8d9b0" },
"water": { "op": "features", "name": "tile.water" },
"water_p":{ "op": "fill-dabs", "features": "@water",
"color": "#5876a0", "opacity": 0.22,
"radius-px": 7, "spacing-px": 3 },
"roads": { "op": "features", "name": "tile.roads",
"filter": { "kind_detail": "motorway" } },
"brush": { "op": "brush-file", "src": "@glazing" },
"roads_p":{ "op": "line", "features": "@roads", "brush": "@brush",
"color": "#4a3424", "radius-px": 2.6 },
"c1": { "op": "blend", "base": "@bg", "over": "@earth_p" },
"c2": { "op": "blend", "base": "@c1", "over": "@water_p" },
"out": { "op": "blend", "base": "@c2", "over": "@roads_p" }
},
"output": "@out"
}The full reference watercolor style is in
crates/ezu/examples/styles/watercolor-basic.json.
All painting happens on a padded canvas (tile_size + 2 * pad) so
gaussian blurs and MVT buffer geometry that overflows [0, extent]
land inside the buffer; the output is cropped to the tile by
ezu-paint::host before encoding.
NodeFactory is a public trait — any downstream crate can register
its own ops on top of ezu-paint::nodes::default_registry() and feed
the registry to ezu-graph::build_graph. The JSON Schema served at
/schemas/ezu-style.json by ezu serve is derived from the live
registry, so custom ops get editor autocomplete (and as-you-type
validation in the live editor) out of the box.
The reference styles consume CC0 brushes by David Revoy from
mypaint/mypaint-brushes,
bundled into ezu-paint at compile time
(crates/ezu-paint/src/builtin/,
attribution in
builtin/CREDITS.md). Any
MyPaint .myb brush works — declare it in the style's assets block
and the host loads it from disk or HTTP.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
