ezu
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.
Workspace
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 |
Try it
Install the CLI from crates.io:
That 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.
# Terrain style — pulls raster DEM tiles from terrain.reearth.land.
# bbox mosaic — stitch the tiles covering a lon/lat box into one PNG.
# XYZ pyramid — bulk-render `<out>/<z>/<x>/<y>.png` for a zoom range.
# Validate a style document (parse + build graph + resolve assets).
# Exits non-zero on error — drop into a pre-commit hook / CI step.
# `--verbose` (or `-v`) enables per-node debug logs from the
# evaluator: op name, cache hit/miss, output shape, eval duration.
The 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:
# Output PNGs in ./out/tokyo/
The live editor (browser-based, edit JSON → see the map update, schema-validated as you type):
# Open http://127.0.0.1:8080
The 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).
How it paints
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.
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.
Custom ops
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.
Brushes
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.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.