Install
$ agentstack add skill-portolan-sdi-portolan-skills-portolan-thumbnails ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ● Filesystem access Used
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Framed Thumbnails with Chiitiler
Render a collection's own styles/default.json server-side with chiitiler, frame it to the shape of the browser card, and check the result before it ships. The rendering half of this is the easy half. Most bad thumbnails come from the bbox, not the renderer.
When to use: any collection whose thumbnail people will actually look at. The matplotlib thumbnails that portolan check --fix writes stay the baseline; this replaces them with something that matches the portal.
Requirements
- Node.js 18+ and npm
- Git, to clone chiitiler
- A collection with a
.pmtilesfile andstyles/default.json - An agent that can view images. Step 5 is not optional and cannot be automated away.
- DuckDB, optional. Needed only to find a dense subregion in a large collection.
Why Framing Is the Whole Job
chiitiler's /clip endpoint takes four parameters: url (or a posted style), bbox, size, and quality. The output aspect ratio and the zoom both fall out of the bbox, and size sets the longest edge. Every framing decision is therefore a decision about which bbox to send.
Passing the collection's raw extent produces the failures the St. Louis catalog hit in August 2026: tall narrow rectangles floating in wide cards, dense parcel layers rendered so far out that tippecanoe's thinning shows as holes, and six cards in a row that could be swapped without anyone noticing.
Rewriting the bbox is reframing, not distortion. Nothing gets stretched. The conversion-defaults best practice forbids changing the aspect of the data, which this does not do; it changes how much context sits around the data.
Step 1 — Read the Collection
Every signal you need is in collection.json and the PMTiles header.
| Signal | Where | Tells you | |---|---|---| | geoparquet:geometry_type | collection.json | Points and lines need more zoom than polygons | | geoparquet:feature_count | collection.json | The single strongest strategy signal | | extent.spatial.bbox[0] | collection.json | The starting frame | | thumbnail-role asset href | collection.json | Where to write, and in which format | | pmtiles:max_zoom | collection.json, when present | The deepest zoom with complete data | | pmtiles:center | collection.json, when present | tippecanoe's densest-area guess | | pmtiles:layers | collection.json, when present | Ground truth for source-layer |
The pmtiles:* properties come from newer conversions and are missing from older catalogs; only 63 of 188 collections in the pergamino-ide test catalog carry them. Read the PMTiles header instead, which always works.
python3 /tmp/portolan-thumbs/frame.py \
--pmtiles publico_arbolado/publico_arbolado.pmtiles
# {"min_zoom": 0, "max_zoom": 13, "center": [-60.578613, -33.888655, 13],
# "bounds": [-60.638266, -33.923181, -60.512274, -33.860783]}
Treat center as a hint. tippecanoe sometimes writes the bbox corner rather than a dense cluster, as it did for two of the six collections checked in August 2026. Confirm it with a feature count before you build a window around it.
Step 2 — Choose a Strategy
A, full extent. Frame the collection's whole bbox. Right for boundaries, borders, districts, wards, precincts, neighborhoods, watersheds, city limits, and anything with a small feature count.
B, zoomed window. A 3:2 window at a chosen zoom, centred on a dense cluster. Right for parcels, buildings, blocks, addresses, service requests, permits, sales, and trees.
Work through the signals cheapest first. These are defaults, not rules, and you should override them when the data says otherwise.
| Signal | Default | |---|---| | Polygons, feature_count = 5000 | B | | Points or lines, feature_count >= 1000 | B | | Anything else | A when fill >= 0.4, otherwise B | | aspect outside 2.2:1 after capping | Decide explicitly and record why |
fill and aspect come from Step 3, so the last two rows mean running frame.py first and then reconsidering.
Step 3 — Compute the Bbox
Write the helper once per session. Run mkdir -p /tmp/portolan-thumbs and save the following as /tmp/portolan-thumbs/frame.py.
#!/usr/bin/env python3
"""Frame a bbox for thumbnail rendering. Prints JSON."""
import argparse, json, math, struct, sys
R = 6378137.0
EARTH_CIRC = 40075016.686
TARGET_ASPECT, MARGIN, MAX_CONTEXT, FRAME_ASPECT_LIMIT = 1.5, 0.05, 2.5, 2.2
def mx(lon): return math.radians(lon) * R
def inv_mx(x): return math.degrees(x / R)
def inv_my(y): return math.degrees(2 * math.atan(math.exp(y / R)) - math.pi / 2)
def my(lat):
lat = max(min(lat, 85.05112878), -85.05112878)
return R * math.log(math.tan(math.pi / 4 + math.radians(lat) / 2))
def eff_zoom(span_m, size_px): return math.log2(EARTH_CIRC * size_px / (256 * span_m))
def span_for_zoom(z, size_px): return EARTH_CIRC * size_px / (256 * 2 ** z)
def clamp(lo, hi, axis, warnings):
"""Keep the frame inside the Mercator world by shifting, never squashing."""
half = EARTH_CIRC / 2
if hi - lo > EARTH_CIRC:
warnings.append("clamped-%s: frame larger than the world" % axis)
return -half, half
shift = (half - hi) if hi > half else ((-half - lo) if lo = 1.0:
continue
c = (lo + hi) / 2
if axis == "x":
x0, x1 = c - 500, c + 500
else:
y0, y1 = c - 500, c + 500
warnings.append("degenerate-%s: span floored at 1 km" % axis)
# Margin first: fractional per axis, so the aspect is unchanged.
dx, dy = (x1 - x0) * margin, (y1 - y0) * margin
x0, x1, y0, y1 = x0 - dx, x1 + dx, y0 - dy, y1 + dy
dataW = W = x1 - x0
dataH = H = y1 - y0
if W / H 0:
x0, x1 = x0 - pad, x1 + pad
elif W / H > target: # too wide, heighten
pad = (min(W / target, dataH * max_context) - H) / 2
if pad > 0:
y0, y1 = y0 - pad, y1 + pad
x0, x1 = clamp(x0, x1, "x", warnings)
y0, y1 = clamp(y0, y1, "y", warnings)
fill = min(dataW / (x1 - x0), dataH / (y1 - y0))
return (inv_mx(x0), inv_my(y0), inv_mx(x1), inv_my(y1)), fill, warnings
def window(clon, clat, z, size_px, target=TARGET_ASPECT):
"""Strategy B: a target-aspect window at zoom z, centred on (clon, clat)."""
span = span_for_zoom(z, size_px)
hw, hh = span / 2, span / (2 * target)
cx, cy = mx(clon), my(clat)
return inv_mx(cx - hw), inv_my(cy - hh), inv_mx(cx + hw), inv_my(cy + hh)
def report(bbox, size_px, warnings):
x0, x1, y0, y1 = mx(bbox[0]), mx(bbox[2]), my(bbox[1]), my(bbox[3])
aspect = (x1 - x0) / (y1 - y0)
span = max(x1 - x0, y1 - y0)
if not 1 / FRAME_ASPECT_LIMIT /tmp/chiitiler.log 2>&1 /dev/null
render "$WORK/blank-style.json" "$BBOX" 256 "$WORK/blank.png" png 100 > /dev/null
if [ "$MAIN" != "200" ]; then
echo "FAIL http=$MAIN $(head -c 120 "$OUT")"
rm -f "$OUT"
exit 1
fi
PH=$(sha256sum "$WORK/probe.png" | cut -d' ' -f1)
BH=$(sha256sum "$WORK/blank.png" | cut -d' ' -f1)
PS=$(stat -c%s "$WORK/probe.png"); BS=$(stat -c%s "$WORK/blank.png")
if [ "$PH" = "$BH" ]; then GATE1="FAIL-empty"
elif [ "$PS" -lt $(( BS * 115 / 100 )) ]; then GATE1="WARN-sparse"
else GATE1="PASS"; fi
echo "gate1=$GATE1 probe=$PS blank=$BS bytes=$(stat -c%s "$OUT") out=$OUT"
Write to the path the collection's thumbnail asset already points at. Read it rather than assuming an extension:
OUT=$(python3 -c "
import json
c = json.load(open('collection.json'))
print(next(a['href'] for a in c['assets'].values()
if 'thumbnail' in (a.get('roles') or [])))")
Step 5 — Check the Result
Two gates. Both run before anything is pushed.
Gate 1, Automated
render_one.sh renders a 256-pixel probe over the same bbox with the collection's layers on a white background and no basemap, plus a blank reference that is the white background alone. Identical hashes mean no data landed in the frame. A probe within 15% of the blank's file size means almost none did. The hashes are deterministic: three identical renders of the same style and bbox produced byte-identical PNGs in August 2026.
Gate 1 replaces the old 100-byte file-size check, which passed any image chiitiler managed to encode, including a uniformly blank one. Keep checking that the file exists and that the status was 200, since that catches transport failures. A render error returns 500 with a short text body, and curl -o writes that text into your .png.
Gate 2, Visual
View every image. Six questions; any "no" is a failure.
- Data present. Are features visible, rather than basemap alone? An
all-basemap, blank, or solid-colour image is a failure, never a valid result.
- Data is the subject. Do features occupy a quarter of the frame or more?
- Shape. Is it landscape and close to 3:2? A tall portrait image fails unless
you deliberately chose full-extent framing for a boundary layer and said so.
- Completeness. Do continuous fabrics such as parcels and blocks run edge to
edge without scattered holes? Holes mean the render sits below the archive's maximum zoom.
- Legibility at card size. Imagine it at 350x230. Pale fills over a light
basemap read as flat grey.
- Distinctness. Set beside its siblings, is it recognisable? If two cards
could be swapped without anyone noticing, change the framing depth or palette.
Remediation
| Symptom | Fix | |---|---| | All basemap, probe empty | Confirm the source declares minzoom/maxzoom, that source-layer matches pmtiles:layers, that the source key survived the rewrite, and that the bbox intersects the data | | All basemap, probe has data | The data is under the basemap or fully transparent. Check layer order and paint opacity | | Black background | The basemap failed to fetch and transparency became black. Keep a white background layer beneath it, and check the {z}/{x}/{y} template survived the shell | | Thin or portrait image | Framing was skipped, or MAX_CONTEXT capped it. Re-run frame.py, then either --max-context 99 or Strategy B | | Sliver of data in a big frame | fill is too low. Switch to Strategy B, or crop with a quantile trim | | Scattered holes in a continuous fabric | Shrink the window until the render sits at or above max_zoom. If it already does, the archive needs retiling | | Dense points render as one blob | Zoom in. Structure appears when features separate | | Washed out, flat grey | Lower BASEMAP_OPACITY, use a no-labels basemap, or raise the fill opacity in the style | | Identical to a sibling | Change strategy, raise the OFFSET rank, or change the palette |
Retry at most three times per collection, then report what is left and why. Some collections cannot produce a good thumbnail. A two-point collection is a locator map and nothing more; say so rather than burning attempts on it.
Legibility problems that survive reframing belong to the style, not to this skill. The sourcecoop skill covers varying default styles.
Defects the Validators Miss
Rendering 182 pergamino-ide collections in August 2026 surfaced four faults that rashid and stac-check both pass. Each reaches this skill as a failed request or a blank image, never as a finding.
A symbol layer with no glyphs endpoint kills the renderer. MapLibre GL Native crashes when a layer needs a font it cannot fetch. The chiitiler worker dies and curl reports an empty reply rather than a status code, which reads like a server crash. Adding a glyphs entry turns the crash into a readable HTTP 500 with unknown pbf field type. Keep the symbol layers in styles/default.json, where browsers render the labels, and strip them from the render and probe styles in buildstyle.py:
layers = [l for l in layers if l.get("type") != "symbol"]
https://fonts.openmaptiles.org/{fontstack}/{range}.pbf is a glyphs source that works, for the case where you want labels in the image.
A match expression needs all-integer or all-string labels. MapLibre rejects the style with HTTP 400 when the labels are floats (0.9, 1.0, 1.1) and again when integers sit beside a string (227, 330, "Linea 2"). Coerce both sides to string. Wrap the getter as ["to-string", ["get", "col"]], write each integral float as an integer so 1.0 becomes "1", and drop the duplicate labels that coercion creates.
Three paint patterns render blank, and no renderer is at fault. fill-opacity of 0.0, circle-color of #ffffff against the white background, and a white fill at partial opacity each produce a correct render of nothing. Raise the zero opacity. Give a white circle a circle-stroke-color and a circle-stroke-width, and a white fill a fill-outline-color.
A sparse collection can be tiled to zoom 0. One two-feature collection had an archive stopping at maxzoom 0, which puts both points inside a single pixel, and no framing recovers that. Set pmtiles.max_zoom in .portolan/config.yaml and regenerate. That collection went to 23 tiles.
Regenerating needs the generation path switched on. portolan add skips PMTiles for a collection unless --pmtiles was passed or pmtiles.enabled is true in config, and --force-pmtiles only sets the force flag on a generator it never reaches otherwise. Run both flags together:
portolan add publico_arbolado/ --pmtiles --force-pmtiles
Unchanged files do not block this. add counts a skipped file's collection as affected so that tile generation still runs over already-tracked data.
Step 6 — Work Through the Catalog
Cards are seen side by side, so judge them as a set. Aim for roughly a third full-extent and the rest zoomed at varying depths, some at an overview that fills the box and some deep enough to show real detail. Never put two thumbnails on the same neighborhood; raise the OFFSET rank instead. A portrait boundary layer is fine as variety when it passes Gate 2.
Keep a record as you go, at /tmp/portolan-thumbs/framing.tsv:
collection strategy bbox zoom rank verdict
publico_arbolado B -60.603932,-33.906046,-60.559986,-33.881728 15.0 0 pass
publico_barrios A -60.651220,-33.935211,-60.495382,-33.848971 13.17 - pass
It makes the second pass over a failing collection cheap, and it is the evidence that Gate 2 actually ran.
Step 7 — Register and Push
The thumbnail asset was registered when the collection was converted, and portolan check --fix will not repoint a stale href. Writing collection.thumb.png next to a registered collection.thumb.jpg leaves the catalog pointing at the old image, so write to the existing href by default. To change format deliberately, delete the old file and update the asset's href and type together, then push. versions.json picks up the new checksums.
python3 - <<'PY'
import json
c = json.load(open('collection.json'))
for a in c['assets'].values():
if 'thumbnail' in (a.get('roles') or []):
a['href'] = './publico_arbolado.thumb.png'
a['type'] = 'image/png'
json.dump(c, open('collection.json', 'w'), indent=2)
PY
rm -f publico_arbolado.thumb.jpg
portolan push s3://bucket/catalog
Basemap Options
| Style | URL | |-------|-----| | Carto Light (default) | https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png | | Carto Light, no labels | https://basemaps.cartocdn.com/light_nolabels/{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 |
BASEMAP_OPACITY=0.55 keeps the basemap as context. Raise it when sparse data needs geographic anchoring, lower it when pale fills are getting lost, and use the no-lab
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: portolan-sdi
- Source: portolan-sdi/portolan-skills
- License: Apache-2.0
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.