High-Quality Thumbnails with Chiitiler
Generate styled thumbnails from Portolan collections by rendering the actual styles/default.json server-side using chiitiler. This produces hero-quality images matching the browser portal — far better than the default matplotlib-based thumbnails.
When to use: For collections that need polished thumbnails (hero images, featured collections). This is opt-in and requires Node.js — the default portolan check --fix thumbnails remain the baseline.
Requirements:
- Node.js 18+ and npm
- Git (to clone chiitiler)
- A collection with
.pmtilesandstyles/default.json
Quick Start
Run this script in the catalog directory. It renders thumbnails for all collections that have PMTiles and styles.
bash#!/usr/bin/env bash set -euo pipefail ############################################################################# # CONFIGURATION — edit these ############################################################################# CATALOG_DIR="/path/to/catalog" # Catalog root (contains collection dirs) OUTPUT_FORMAT="png" # png | jpg | webp SIZE=1024 # Longest edge in pixels QUALITY=90 # JPEG/WebP quality (ignored for PNG) USE_BASEMAP=true # Add basemap under data layers BASEMAP_URL="https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png" # Alternative basemaps: # Carto Dark: https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png # OSM: https://tile.openstreetmap.org/{z}/{x}/{y}.png # Stadia: https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png ############################################################################# # SETUP — install chiitiler if needed ############################################################################# CHIITILER_DIR="${CHIITILER_DIR:-/tmp/chiitiler}" if [ ! -d "$CHIITILER_DIR/node_modules" ]; then echo "Installing chiitiler to $CHIITILER_DIR..." rm -rf "$CHIITILER_DIR" git clone --depth 1 https://github.com/Kanahiro/chiitiler "$CHIITILER_DIR" cd "$CHIITILER_DIR" && npm install --silent fi ############################################################################# # START SERVER ############################################################################# PORT=13579 echo "Starting chiitiler on port $PORT..." cd "$CHIITILER_DIR" # Multi-process mode (CHIITILER_PROCESSES=0) prevents memory crashes on large batches # Memory cache improves performance for repeated basemap tile fetches CHIITILER_PROCESSES=0 npx tsx src/main.ts tile-server \ --port $PORT \ --cache memory \ > /tmp/chiitiler.log 2>&1 & SERVER_PID=$! # Wait for server to be ready for i in {1..30}; do curl -s "http://localhost:$PORT/" > /dev/null 2>&1 && break sleep 1 done if ! kill -0 $SERVER_PID 2>/dev/null; then echo "ERROR: chiitiler failed to start. See /tmp/chiitiler.log" exit 1 fi echo "Server ready (PID $SERVER_PID)" # Ensure cleanup on exit trap "kill $SERVER_PID 2>/dev/null; echo 'Server stopped'" EXIT ############################################################################# # RENDER THUMBNAILS ############################################################################# cd "$CATALOG_DIR" COUNT=0 for COLLECTION_DIR in */; do COLLECTION_DIR="${COLLECTION_DIR%/}" # Skip if missing required files PMTILES_FILE=$(ls "$COLLECTION_DIR"/*.pmtiles 2>/dev/null | head -1) || continue [ -f "$COLLECTION_DIR/styles/default.json" ] || continue [ -f "$COLLECTION_DIR/collection.json" ] || continue PMTILES_PATH=$(realpath "$PMTILES_FILE") PMTILES_NAME=$(basename "$PMTILES_FILE" .pmtiles) # Extract bbox from collection.json BBOX=$(python3 -c " import json c = json.load(open('$COLLECTION_DIR/collection.json')) bbox = c['extent']['spatial']['bbox'][0] print(f'{bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]}') ") # Build render-ready style (preserves original styles/default.json exactly) python3 << PYSTYLE import json style = json.load(open('$COLLECTION_DIR/styles/default.json')) # Configure sources sources = {} if $USE_BASEMAP: sources['basemap'] = { 'type': 'raster', 'tiles': ['$BASEMAP_URL'], 'tileSize': 256 } sources['data'] = { 'type': 'vector', 'tiles': ['pmtiles://$PMTILES_PATH/{z}/{x}/{y}'] } style['sources'] = sources # Build layer stack layers = [] if $USE_BASEMAP: layers.append({ 'id': 'basemap', 'type': 'raster', 'source': 'basemap' }) else: # White background when no basemap (avoids black in JPEG) layers.append({ 'id': 'background', 'type': 'background', 'paint': {'background-color': '#ffffff'} }) # Add original layers unchanged layers.extend(style.get('layers', [])) style['layers'] = layers with open('$COLLECTION_DIR/.render-style.json', 'w') as f: json.dump(style, f) PYSTYLE # Render thumbnail OUTPUT_FILE="$COLLECTION_DIR/${PMTILES_NAME}.thumb.${OUTPUT_FORMAT}" curl -s -X POST \ "http://localhost:$PORT/clip.${OUTPUT_FORMAT}?bbox=${BBOX}&size=${SIZE}&quality=${QUALITY}" \ -H "Content-Type: application/json" \ -d "{\"style\": $(cat "$COLLECTION_DIR/.render-style.json")}" \ -o "$OUTPUT_FILE" # Cleanup temp file rm -f "$COLLECTION_DIR/.render-style.json" # Verify output (100 byte threshold handles edge cases with tiny bboxes) if [ -f "$OUTPUT_FILE" ] && [ $(stat -c%s "$OUTPUT_FILE") -gt 100 ]; then echo "✓ $COLLECTION_DIR → $(basename $OUTPUT_FILE) ($(stat -c%s "$OUTPUT_FILE") bytes)" ((COUNT++)) else echo "✗ $COLLECTION_DIR — render failed" rm -f "$OUTPUT_FILE" fi done echo "" echo "Done: $COUNT thumbnails generated"
Single Collection
To render just one collection:
bashCOLLECTION_DIR="/path/to/catalog/my_collection" CHIITILER_DIR="/tmp/chiitiler" PORT=13579 # Start server (if not already running) cd "$CHIITILER_DIR" CHIITILER_PROCESSES=0 npx tsx src/main.ts tile-server \ --port $PORT --cache memory & SERVER_PID=$! sleep 3 # Render cd "$COLLECTION_DIR" PMTILES_PATH=$(realpath *.pmtiles) PMTILES_NAME=$(basename "$PMTILES_PATH" .pmtiles) BBOX=$(python3 -c " import json c = json.load(open('collection.json')) bbox = c['extent']['spatial']['bbox'][0] print(f'{bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]}') ") python3 << PYSTYLE import json style = json.load(open('styles/default.json')) style['sources'] = { 'basemap': { 'type': 'raster', 'tiles': ['https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'], 'tileSize': 256 }, 'data': { 'type': 'vector', 'tiles': ['pmtiles://$PMTILES_PATH/{z}/{x}/{y}'] } } style['layers'] = [ {'id': 'basemap', 'type': 'raster', 'source': 'basemap'} ] + style.get('layers', []) json.dump(style, open('.render-style.json', 'w')) PYSTYLE curl -s -X POST \ "http://localhost:$PORT/clip.png?bbox=${BBOX}&size=1024" \ -H "Content-Type: application/json" \ -d "{\"style\": $(cat .render-style.json)}" \ -o "${PMTILES_NAME}.thumb.png" rm -f .render-style.json kill $SERVER_PID 2>/dev/null echo "Generated: ${PMTILES_NAME}.thumb.png"
Basemap Options
The script defaults to Carto Light. To change, set BASEMAP_URL:
| Style | URL |
|---|---|
| Carto Light (default) | https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png |
| Carto Dark | https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png |
| Carto Voyager | https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png |
| OpenStreetMap | https://tile.openstreetmap.org/{z}/{x}/{y}.png |
| Stadia Smooth | https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png |
| Stadia Dark | https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png |
Set USE_BASEMAP=false for a plain white background (smaller file size, no external requests).
Output Formats
| Format | Use case |
|---|---|
png | Lossless, supports transparency, larger files |
jpg | Smaller files, no transparency, good for photos |
webp | Best compression, modern browser support |
How It Works
- Reads
styles/default.json— the collection's actual portal style - Rewrites sources — points to local PMTiles via
pmtiles://protocol - Adds basemap layer — underneath original layers (optional)
- Renders via chiitiler — MapLibre GL Native server-side rendering
- Preserves original styling — colors, opacity, outlines unchanged
The original styles/default.json is never modified. Only a temporary .render-style.json is created and deleted after rendering.
Troubleshooting
| Issue | Solution |
|---|---|
| "npm install" fails | Ensure Node.js 18+: node --version |
| Server won't start | Check port: lsof -i :13579 |
| Server crashes mid-batch | Ensure CHIITILER_PROCESSES=0 is set (enables multi-process mode) |
| Empty/black image | Verify PMTiles has data in the bbox extent |
| Very thin/small image | Collection has near-zero bbox height/width — this is valid |
| Basemap not loading | Check network access to tile server |
| Very slow rendering | Large bbox or high zoom — reduce SIZE |
After Generating
The thumbnails are ready to use. To sync to remote:
bashcd /path/to/catalog portolan push s3://bucket/catalog
The versions.json will auto-update with the new thumbnail checksums.


