Producing PNG Thumbnails and Quicklooks
Read at thumbnail size, stretch with the product’s fixed limits, and let nodata be transparent:
import numpy as np
import rasterio
from PIL import Image
with rasterio.open("ndvi.tif") as src:
f = max(1, src.width // 512)
a = src.read(1, out_shape=(src.height // f, src.width // f), masked=True)
s = np.clip((a - LO) / (HI - LO), 0, 1)
rgba = np.dstack([(s.filled(0) * 255).astype("uint8")] * 3 + [np.where(a.mask, 0, 255).astype("uint8")])
Image.fromarray(rgba, "RGBA").save("ndvi_thumb.png", optimize=True)
A second of compute per product buys a browsable archive and a review step that catches what assertions miss. This page belongs to preparing rasters for the web in Visualization, Tiling & Web Delivery.
What Thumbnails Catch That Tests Do Not
The value of a thumbnail is in bulk. One image tells you little; a grid of a hundred tells you immediately which three are different from the rest, and the human eye is far better at spotting “different from the others” than any threshold you would think to write in advance.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
rasterio |
>=1.3.0 |
Decimated masked reads from overviews |
numpy |
>=1.23 |
Stretch and alpha construction |
pillow |
>=10.0 |
PNG encoding and contact-sheet assembly |
pip install "rasterio>=1.3.0" "numpy>=1.23" "pillow>=10.0"
Complete Working Example
from pathlib import Path
import numpy as np
import rasterio
from PIL import Image
def thumbnail(src_path: str, png_path: str, *, size: int = 512,
limits: tuple[float, float], bands: tuple[int, ...] = (1,),
palette: dict[int, tuple[int, int, int]] | None = None) -> Path:
"""Write an RGBA thumbnail with transparent nodata."""
with rasterio.open(src_path) as src:
f = max(1, int(max(src.width, src.height) / size))
shape = (max(1, src.height // f), max(1, src.width // f))
arrs = [src.read(b, out_shape=shape, masked=True) for b in bands]
mask = np.ma.getmaskarray(arrs[0])
if palette: # categorical product
codes = arrs[0].filled(0).astype("int32")
rgb = np.zeros((*shape, 3), dtype="uint8")
for code, colour in palette.items():
rgb[codes == code] = colour
else: # continuous: 1 or 3 bands
lo, hi = limits
planes = [np.clip((a.astype("float32") - lo) / max(hi - lo, 1e-9), 0, 1)
for a in arrs]
if len(planes) == 1:
planes = planes * 3
rgb = (np.dstack([p.filled(0) for p in planes]) * 255).astype("uint8")
alpha = np.where(mask, 0, 255).astype("uint8")
out = Path(png_path)
Image.fromarray(np.dstack([rgb, alpha]), "RGBA").save(out, optimize=True)
return out
def contact_sheet(pngs: list[Path], out_path: str, *, cols: int = 8,
cell: int = 192) -> None:
"""Tile thumbnails into one image for quick review."""
rows = (len(pngs) + cols - 1) // cols
sheet = Image.new("RGBA", (cols * cell, rows * cell), (255, 255, 255, 255))
for i, p in enumerate(pngs):
im = Image.open(p)
im.thumbnail((cell - 8, cell - 8))
sheet.paste(im, ((i % cols) * cell + 4, (i // cols) * cell + 4), im)
sheet.save(out_path, optimize=True)
Supporting a palette for categorical products means one function serves every output a pipeline writes, and reading the palette from the file’s own colour table — rather than passing it in — would make it self-configuring for class rasters written as described in writing prediction rasters as COGs.
Cost at Archive Scale
The overview dependency is the reason thumbnails belong at the end of a pipeline, after the COG with its overviews has been written. Generating them from an intermediate striped file costs thirty times more and gains nothing.
At archive scale the job is trivially parallel — one product per task, output written next to the product — and fits the scheduling patterns in parameterizing Prefect flows for multi-tile runs. Regenerating every thumbnail when a stretch changes is a cheap, fast batch job precisely because each one reads so little.
Registering Thumbnails with the Product
A thumbnail that nobody can find is only half useful. Registering it as an asset on the product’s catalog record — with the thumbnail role — means every catalog browser and search client shows it automatically, which turns an archive of opaque filenames into something a person can scan.
import pystac
item.add_asset("thumbnail", pystac.Asset(
href="s3://example-bucket/products/v3/ndvi_36NYF_thumb.png",
media_type=pystac.MediaType.PNG,
roles=["thumbnail"],
title="NDVI quicklook",
))
Store the thumbnail next to the product it depicts, with a predictable name, so that a missing thumbnail is itself a detectable condition: a product without its sibling PNG is a product whose pipeline did not finish cleanly.
Verification
import numpy as np
from PIL import Image
def thumb_flags(png_path: str) -> list[str]:
im = np.asarray(Image.open(png_path).convert("RGBA"))
flags = []
if not (400 <= max(im.shape[:2]) <= 600):
flags.append("size")
alpha = im[..., 3]
if (alpha == 0).mean() > 0.5:
flags.append("mostly-nodata")
opaque = im[..., :3][alpha > 0]
if opaque.size:
m = float(opaque.mean())
if m < 15:
flags.append("black")
elif m > 240:
flags.append("saturated")
return flags
The flags do not replace the human scan; they order it. Sorting the contact sheet so flagged products appear first means the review starts with the most likely problems, and a clean run shows nothing flagged at the top of the sheet.
Common Errors
Thumbnails of nodata-heavy scenes are black
Nodata was filled with zero rather than made transparent. Build the alpha channel from the mask, as above.
Every thumbnail looks equally good
Per-scene percentile stretches are hiding the failures. Use the product’s fixed limits, the same ones recorded in the display copy’s tags.
Generation is slow
The source has no overviews, or thumbnails are generated from an intermediate file. Generate from the final COG, as the last step of the pipeline, so the overview it reads already exists and costs a few hundred kilobytes rather than the full band.
Class thumbnails show smeared colours
The overview used average resampling. Class products need mode overviews, or the thumbnail invents intermediate values that match no class at all.
Frequently Asked Questions
Q: What size should a thumbnail be? About 512 pixels on the long side. That is enough to see cloud, seams, inverted classes and gross nodata problems, small enough to load hundreds at once in a catalog list, and cheap to produce from an overview level.
Q: Why use the product’s fixed stretch rather than per-scene percentiles? So that thumbnails are comparable. A per-scene stretch makes a scene that failed look as well-contrasted as one that succeeded, hiding exactly the problems the thumbnails exist to reveal.
Q: Are thumbnails worth it for automated pipelines nobody looks at? Especially then. A contact sheet reviewed for two minutes after a run catches inverted class maps, black scenes and diagonal seams that no assertion is written for, and the thumbnails make the archive browsable for whoever inherits it.
Q: PNG or JPEG for thumbnails? PNG, because it carries the alpha channel that makes nodata transparent and it is lossless for class products. JPEG is smaller for true-colour previews but cannot represent transparency, which makes a mostly-empty scene look like a mostly-black one.
Related
- Preparing Rasters for the Web — the parent topic.
- Converting Rasters to 8-bit for Display — the same fixed stretch at full size.
- Validating COG Structure in CI — the automated checks thumbnails complement.
- Exporting Publication Quality Map Figures — the carefully annotated counterpart.