Operational Context
A Python automation engineer is handed a batch of gigapixel museum captures — flat art shot on a scanning back, each frame a 500-megapixel 16-bit TIFF weighing a gigabyte or more — and asked to make them zoomable in a IIIF viewer. The naive path is to point an Image API service at the full-resolution originals and let it resample regions on demand. That path fails the moment a curator opens the object: the first deep-zoom pan fires dozens of tile requests, each one forces the server to decode and downsample a half-gigapixel frame, and CPU saturates while the viewer stalls on blank tiles. The exact failure this page resolves is serving deep zoom without on-the-fly resampling — pre-computing a tile pyramid once, so that every region and zoom level a client can request already exists on disk as a ready-to-ship JPEG. This work sits inside the IIIF Image API configuration stage and is the pre-processing step that makes the whole IIIF image delivery and manifest generation pipeline responsive rather than merely correct.
Root Cause Analysis
The stalling viewer has three concrete causes, and all three trace back to when the pixels are downsampled.
First, dynamic resampling melts CPU. A IIIF level2 service that computes arbitrary regions and sizes at request time must, for each tile, seek into the source, decode a JPEG or TIFF strip, resample it, and re-encode. For a 24576×18432 source that is cheap per tile in isolation but ruinous in aggregate: a single deep-zoom session issues hundreds of tile requests, and every miss repeats the decode of the full frame’s relevant strips. Concurrency multiplies it until the box is CPU-bound and every visitor waits.
Second, memory blows up with a naive decoder. Reaching for Pillow and writing Image.open(path).load() pulls the entire raster into RAM before you can crop a single tile — roughly width × height × channels × bytes-per-sample, which for a 500 MP 16-bit RGB frame is over 2.7 GB decoded, per worker, per concurrent request. A handful of parallel jobs triggers the OOM killer, the same failure mode that discipline elsewhere in the platform avoids when handling large CSV batches without memory leaks: never materialize the whole payload.
Third, and underneath both, the pyramid does not exist yet. Deep zoom is fundamentally a pre-computation: a set of pre-scaled, pre-tiled overview levels the client can address directly. If those levels are not on disk, every zoom-out is synthesized from full resolution. pyvips — the Python binding to libvips — fixes this at the root because it is a demand-driven, streaming image processor: with sequential access it reads the source in a small rolling window, computes the tile pyramid in bounded memory, and writes it out in one pass, so a 500 MP frame is tiled in tens of megabytes of RAM rather than gigabytes. Pre-cutting the pyramid moves all the resampling cost to ingest time, once, instead of onto every viewer request forever.
Canonical Solution
The fix is to cut a IIIF-addressable tile pyramid ahead of serving. libvips writes this directly through dzsave with layout="iiif3", which emits an IIIF Image API 3.0 tree: an info.json describing the image plus a directory of pre-scaled tiles whose paths are the IIIF region/size/rotation/quality segments a client requests. The three parameters that determine correctness and cost are the tile size, the overlap, and the JPEG quality — and the tile size in particular must equal the tiles[].width the info.json advertises, or every deep-zoom request misses the static tree and falls through to on-the-fly resampling.
from __future__ import annotations
from pathlib import Path
import pyvips
# Tiling parameters that MUST agree with info.json. A IIIF client reads tiles[].width
# and scaleFactors from info.json and requests exactly those region/size combinations;
# if the pyramid on disk was cut to a different tile size, every request misses and the
# service falls back to the on-the-fly resampling this whole exercise exists to avoid.
TILE_SIZE = 512 # px; becomes tiles[0].width in info.json
OVERLAP = 0 # IIIF regions abut exactly — pixel overlap belongs to DZI, not IIIF
JPEG_QUALITY = 85 # visually lossless for reproduction; raise to 90+ for study-grade zoom
def build_iiif_pyramid(src: str | Path, out_dir: str | Path) -> Path:
# access="sequential" is the whole point: libvips reads the source top-to-bottom in a
# small rolling window instead of decoding the entire 500 MP frame into RAM the way a
# naive Image.open().load() does. Peak memory stays in the tens of megabytes.
image = pyvips.Image.new_from_file(str(src), access="sequential")
# Normalize colour before tiling so every emitted tile is display-ready and no
# per-request ICC transform is needed at serve time.
image = to_display_srgb(image)
# layout="iiif3" writes an IIIF Image API 3.0 tree: an info.json plus a pyramid whose
# directory names are IIIF path segments. dzsave derives scaleFactors from the image
# dimensions and TILE_SIZE, so the levels on disk and the advertised levels can't drift.
out = Path(out_dir)
image.dzsave(
str(out / "pyramid"),
layout="iiif3",
tile_size=TILE_SIZE,
overlap=OVERLAP,
suffix=f".jpg[Q={JPEG_QUALITY},strip=true]",
)
return out / "pyramid" / "info.json"
def to_display_srgb(image: pyvips.Image) -> pyvips.Image:
# 16-bit and CMYK museum captures cannot be shipped as JPEG tiles as-is. Convert to
# sRGB through the embedded profile when present, then cast to 8-bit unsigned.
if image.interpretation in ("cmyk", "rgb16", "grey16"):
image = image.icc_transform("srgb", embedded=True)
if image.format != "uchar":
image = image.cast("uchar")
# JPEG has no alpha channel; flatten over white so an alpha band is not misread.
if image.hasalpha():
image = image.flatten(background=[255, 255, 255])
return imageThe info.json this produces is the contract the viewer reads. For a level0 static tree it declares the source dimensions, the single tile width, and the pyramid’s scaleFactors — the powers of two at which whole-image overviews were pre-cut:
{
"@context": "http://iiif.io/api/image/3/context.json",
"id": "https://images.example.org/iiif/3/obj00417/pyramid",
"type": "ImageService3",
"protocol": "http://iiif.io/api/image",
"profile": "level0",
"width": 24576,
"height": 18432,
"tiles": [
{ "width": 512, "height": 512, "scaleFactors": [1, 2, 4, 8, 16, 32, 64] }
]
}Because dzsave computes scaleFactors from the same TILE_SIZE it cuts with, the levels on disk and the levels in info.json are guaranteed to match — the drift that silently breaks deep zoom cannot occur. When the backend is a dynamic IIIF server such as Cantaloupe or IIPImage rather than a static directory, prefer a single tiled, pyramidal TIFF over a tree of millions of tiny JPEGs. The server then reads the matching reduced-resolution level out of one file instead of resampling full resolution:
def build_pyramidal_tiff(src: str | Path, out_file: str | Path) -> Path:
# One file, random tile access, pre-computed overviews. A IIIF server maps each
# requested size to the nearest pyramid level and reads it directly — no full-frame
# resample per request, and no directory of a million 2 KB tiles to sync.
image = pyvips.Image.new_from_file(str(src), access="sequential")
image = to_display_srgb(image)
out = Path(out_file)
image.tiffsave(
str(out),
tile=True,
tile_width=TILE_SIZE,
tile_height=TILE_SIZE,
pyramid=True, # write reduced-resolution overviews, halving each level
compression="jpeg",
Q=JPEG_QUALITY,
bigtiff=True, # allow >4 GB output for gigapixel captures
)
return outBoth outputs move all resampling to ingest time. The static tree ships from object storage or a CDN with zero server compute; the pyramidal TIFF trades that for a live server that can answer arbitrary sizes but reads a pre-scaled level instead of the full frame.
Edge Cases and Variants
- Single object vs. batch.
build_iiif_pyramidis a pure function of one source and one output directory, so a batch is just a bounded worker pool over a queue of paths — the same async ingestion shape used for configuring Celery for museum data sync. Cap worker concurrency to physical cores: libvips already parallelizes each tiling internally, so oversubscribing workers only thrashes the cache. - 16-bit and CMYK sources. Scanning backs routinely deliver 16-bit RGB or CMYK. Shipping those as tiles is invalid — JPEG is 8-bit and browsers expect sRGB.
to_display_srgbruns the ICC transform through the embedded profile and casts toucharbefore any tile is written, so colour is converted once at ingest rather than misrendered per request. - sRGB vs. wide-gamut delivery. If study use demands Adobe RGB or a wider working space, keep the archival master untouched and derive an sRGB delivery pyramid; the viewer tier should always be sRGB for predictable colour across clients.
- Tile size must match info.json. The single most common breakage is cutting at 256 while the manifest or server assumes 512 (or vice versa). Keep
TILE_SIZEas one constant that feeds both thedzsavecall and any hand-writteninfo.json, and treat a mismatch as a hard failure in validation. - Overlap is a DZI concept, not IIIF. Deep Zoom Image (DZI) tiles overlap by a pixel or two to hide seams; IIIF regions abut exactly. Set
overlap=0for IIIF output — a non-zero overlap shifts every region boundary and produces off-by-one tile requests. - Static level0 vs. dynamic level2. Static tiles answer only the exact sizes in the pyramid (
profile: level0); a viewer that requests an arbitrary size will not find it. If clients need free scaling, serve the pyramidal TIFF through alevel2server, which reads the nearest pre-cut level instead of resampling full resolution. Fronting either with a cache is covered in caching IIIF Image API responses at the edge.
Validation
The invariant worth proving is that the pyramid on disk and the pyramid the info.json advertises are the same pyramid — matching tile width and a contiguous set of scaleFactors. This test builds a small pyramid and asserts against its own info.json, so it needs no fixtures beyond libvips:
# test_pyramid.py -> run with: pytest -q test_pyramid.py
import json
import math
from pathlib import Path
import pyvips
import pytest
TILE_SIZE = 512
def expected_scale_factors(width: int, height: int, tile: int) -> list[int]:
# Powers of two until the whole image fits inside a single tile — exactly the levels
# dzsave writes. Any gap here means a zoom level the client can request is missing.
longest, factors, sf = max(width, height), [], 1
while math.ceil(longest / sf) > tile:
factors.append(sf)
sf *= 2
factors.append(sf)
return factors
@pytest.fixture(scope="module")
def pyramid_dir(tmp_path_factory) -> Path:
out = tmp_path_factory.mktemp("iiif")
# A synthetic 6000x4000 frame exercises several pyramid levels without a real capture.
pyvips.Image.black(6000, 4000).dzsave(
str(out / "obj"), layout="iiif3", tile_size=TILE_SIZE, overlap=0,
)
return out / "obj"
def test_info_json_advertises_the_tile_size_we_cut(pyramid_dir: Path):
info = json.loads((pyramid_dir / "info.json").read_text())
assert info["tiles"][0]["width"] == TILE_SIZE # matches what dzsave was told
assert info["profile"] == "level0" # precomputed, no resampling
def test_scale_factors_are_contiguous_powers_of_two(pyramid_dir: Path):
info = json.loads((pyramid_dir / "info.json").read_text())
expected = expected_scale_factors(info["width"], info["height"], TILE_SIZE)
assert info["tiles"][0]["scaleFactors"] == expected # every zoom level is present
def test_tile_directories_were_written(pyramid_dir: Path):
# A level0 tree names full-region tile directories on disk; an empty tree means a
# client zoom request 404s and the viewer paints a blank tile.
dirs = [p for p in pyramid_dir.iterdir() if p.is_dir()]
assert dirs, "no tile directories were written"For a quick shell check during ingest — confirming the pyramid was actually cut before you publish the manifest — count the advertised levels against the JSON:
python - <<'PY'
import json, pathlib
info = json.loads(pathlib.Path("pyramid/info.json").read_text())
t = info["tiles"][0]
print(f"tile width={t['width']} levels={len(t['scaleFactors'])} -> {t['scaleFactors']}")
PYA green test run and a sane level count confirm that the tile width the viewer will request equals the width you cut, and that no zoom level is missing between full resolution and the single-tile overview.
Standards Alignment
This work targets the IIIF Image API 3.0. The info.json dzsave writes is an ImageService3 document whose tiles array is the normative deep-zoom contract: each entry names a tile width and the scaleFactors at which whole-image overviews exist, and a compliant client requests only those region/size combinations. A static tree is profile: level0 — precomputed tiles only, no arbitrary scaling — while a dynamic server backed by the pyramidal TIFF can advertise level2 and still avoid full-frame resampling by reading the nearest pre-cut level. That same ImageService3 document is what a IIIF viewer discovers through the service block of a Presentation manifest, so this pyramid is the pixel source that building IIIF presentation manifests points at, and it inherits the publication policy decided upstream in the rights metadata and licensing automation stage. See the IIIF Image API 3.0 specification for the exact tiles and sizes semantics your info.json must satisfy.
FAQ
What tile size should I use, 256 or 512?
512 is the better default for reproduction imagery: it halves the request count of a 256 pyramid for the same viewport, which matters more than raw tile weight over HTTP/2. The only hard rule is consistency — TILE_SIZE must feed both the dzsave call and the info.json tiles[].width. A viewer reads the advertised width and requests exactly that; cut at a different size and every deep-zoom request misses the static tree.
Should I generate static tiles or a pyramidal TIFF?
Pick static IIIF tiles when you can serve from object storage or a CDN with no live image server — you trade disk for zero serve-time compute, and the tree caches trivially at the edge. Pick a pyramidal TIFF when a dynamic server (Cantaloupe, IIPImage) must answer arbitrary sizes: it reads the nearest pre-cut level from one file instead of resampling full resolution, and it avoids syncing a directory of millions of tiny JPEGs.
Why does pyvips handle a 500 MP TIFF when Pillow runs out of memory?
Pillow decodes the whole raster into RAM before you can crop a tile, so a gigapixel 16-bit frame needs multiple gigabytes per worker. pyvips is demand-driven: with access="sequential" it streams the source in a small rolling window and computes the pyramid in bounded memory, keeping peak usage in the tens of megabytes regardless of source size.
Do I need overlap for IIIF tiles?
No. Overlap is a Deep Zoom Image (DZI) technique that pads tiles to hide seams; IIIF regions abut exactly and are addressed by precise pixel coordinates. Set overlap=0 — a non-zero value shifts every region boundary and produces off-by-one tile requests that the static tree cannot answer.
Related
- Configuring the IIIF Image API — parent Image API stage
- IIIF Image Delivery and Manifest Generation — delivery pipeline overview
- Caching IIIF Image API Responses at the Edge — fronting tiles with a cache
- Building IIIF Presentation Manifests — pointing a manifest at this service
- Handling Large CSV Batches Without Memory Leaks — the same bounded-memory discipline