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

Three defects visible in a two-second scan A grid of eight thumbnails from one pipeline run. Five look normal. One is almost entirely black because a scale factor was applied twice. One has a hard diagonal seam from a failed mosaic step. One is mostly transparent because the scene was nearly all nodata. None of these would fail a schema check. One run, eight products, three problems black seam mostly empty Each would pass a dtype, CRS and COG check. Each is obvious in a grid.

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

Overviews make thumbnails nearly free With overviews a 512 pixel thumbnail of a full Sentinel-2 tile reads the matching overview level, about 300 kilobytes, and takes a fraction of a second. Without overviews the same thumbnail must read and decimate the full resolution band, around 240 megabytes, taking several seconds and a large memory spike. One 512 px thumbnail from a 10,980 px scene with overviews 0.2 s · 300 KB read without 6 s · 240 MB read, full band in memory Across 10,000 products that is half an hour versus most of a day, and a thumbnail step that fits on any worker versus one that needs a large one.

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

Cheap automated checks on every thumbnail Each thumbnail is checked for its dimensions, for the fraction of transparent pixels which reflects nodata coverage, and for the mean brightness of its opaque pixels which catches black or saturated renders. Anything outside the expected band is flagged for the human review of the contact sheet. Three numbers per thumbnail size long side 400-600 px transparent fraction flag above 50% mean brightness flag below 15 or above 240 Flags sort the contact sheet so the suspicious products appear first.
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.