ezu 0.3.0

Painterly cartography engine: render vector tiles as paintings (umbrella crate)
Documentation

ezu

Crates.io docs.rs CI License: MIT OR Apache-2.0

Painterly cartography — render vector tiles as paintings.

ezu pencil-sketch render of central Japan — © OpenStreetMap contributors, © Protomaps

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:

cargo install ezu-cli

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.
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.png

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:

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: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:SS indicator 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 via Open… / 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/y boundary overlay (drawn per tile via maplibregl.addProtocol), and read the live zoom value (click to copy z @ 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:

  • Sourcessolid, circle (both with optional kind: sprite for synthetic placement/tiling source), noise (white / value / perlin / simplex / worley, with fBm octaves and domain warp, world-anchored for seamless tile borders; kind: scalar emits raw fBm as a ScalarField for terrain stylization), features, brush-file, image (load a PNG/WebP asset as a Sprite for placement / tiling ops)
  • Rasterizationfill-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 a Sprite or canvas-sized Raster), place (composite one image at fixed canvas coordinates with fit: none/cover/contain/stretch), tiling (repeat an image across the canvas, world-anchored for seamless tile borders)
  • Compositionblur (libblur Gaussian), blend (W3C 16 blend modes — multiply / screen / overlay / soft-light / hue / luminosity etc., plus composite operators (destination-out for brush-style eraser), clip for Photoshop-style clipping masks, and an optional alpha-mask input)
  • Warpdisplace (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 by amp-px and expose clamp / transparent / mirror boundary modes
  • Adjustmentbrightness-contrast, levels (Photoshop-style in/out black/white + gamma), hsl (hue rotation + saturation/lightness shift), invert, color-to-alpha (chroma key)
  • Morphology / edgeserode / dilate (per-channel min/max box filter, for mask cleanup), edge-detect (Sobel gradient magnitude), sharpen (4-neighbour Laplacian)
  • Channel opschannel-shuffle (rearrange RGBA, or stamp constants 0 / 1 into 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
  • Utilityswitch (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 a ScalarField, bridging into map-range / threshold / color-ramp)
  • Scalar mathmap-range (linear remap with optional clamp on a ScalarField), threshold (binarise with optional soft ramp)
  • Gradientsgradient-linear, gradient-radial (elliptical via aspect), gradient-conic, gradient-diamond. All take color stops and an anchor: "tile" | "world" for tile-local or world-anchored (seamless across tiles) patterns.
  • Terraindem (sample a host-bound raster-DEM mosaic as a ScalarField with geo_scale populated; the host declares the tile pyramid in sources and handles fetch / decode / 3×3 stitch / overzoom upsampling for terrarium and mapbox-rgb encodings), hillshade (Horn-method analytical shade with shade or multiply-friendly relief mode, 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.

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.