Within the broader IIIF image delivery and manifest generation stage, the Image API is the pixel-serving foundation everything else stands on. A IIIF Presentation manifest tells a viewer what an object is and how its canvases are ordered; the Image API service tells that same viewer how to fetch any region, at any size, at any rotation, in any supported format from a single high-resolution master. Get the manifest right but the image service wrong and the object opens as a broken thumbnail. This guide specifies how to stand up a spec-compliant IIIF Image API 3.0 service: the URL grammar every request must satisfy, the info.json document that advertises the service’s capabilities, the tiling parameters that make deep zoom responsive, and the compliance level that tells clients exactly which features they may request.
The Image API solves a narrow, unforgiving problem. A museum holds a 400-megapixel TIFF of a manuscript folio; a researcher wants the top-left seal at native resolution while a classroom projector wants the whole page downsampled to fit 1080 pixels. Shipping the master to both is wasteful and slow. The Image API instead exposes one canonical identifier and lets each client compose a URL that names the exact region, size, rotation, and quality it needs, while info.json guarantees in advance that the request will be honoured. The service either serves a pre-generated tile, derives one on the fly, or — when rights gate the asset — returns a degraded placeholder instead.
Workflow Context
A IIIF Image API service sits between the persisted collection record and the viewer. Upstream, ingestion and taxonomy have produced a validated object with a stable identifier and a rights assertion; the Presentation manifest references this image service by URI in each canvas body. Downstream, a viewer such as Mirador or Universal Viewer reads info.json, discovers the tile grid and maximum dimensions, and issues a cascade of image requests as the user pans and zooms. The service’s job is to answer every one of those requests deterministically, within the guarantees info.json advertised, and to enforce the rights policy the record carries.
Two design pressures shape the configuration. The first is conformance: a client trusts info.json, so if the document advertises a maxWidth or a set of tiles the service cannot actually honour, the viewer will request URLs that 404 or time out. The second is performance: naively deriving every region from the master on each request melts the origin under a deep-zoom session that fires dozens of tile requests per pan. The configuration decisions here — tile size, scale factors, compliance level, and the choice between static pre-tiling and dynamic derivation — are what let a single origin survive a reading-room full of zoom.
Prerequisites
Before configuring the service, confirm the following are in place:
- A tile source or image server. Either an on-the-fly server (IIPImage, Cantaloupe) that reads pyramidal TIFF or JPEG 2000 masters, or a static pre-tiling pipeline that writes derivatives to object storage behind a CDN. The static route is covered in serving deep-zoom IIIF tiles with pyvips.
pyvips(2.2+) bound to a recent libvips, for reading master dimensions and generating pyramidal derivatives without loading the whole raster into memory. libvips is the reference engine for this because it streams tiles rather than decoding the full image.- Pyramidal masters. Store archival images as tiled, multi-resolution TIFF or JP2 so a
regionrequest reads only the tiles it needs. A flat baseline JPEG forces a full decode for every crop. - A base URI scheme. Decide the identifier prefix — for example
https://iiif.example.org/iiif/3/{identifier}— and keep it stable, because it is embedded in every manifest and cached by every client. Percent-encode identifiers that contain reserved characters. - Python 3.9+ for the PEP 604
|unions andmatchstatements used to parse request paths below. - Standards references. The normative grammar is the IIIF Image API 3.0 specification; rights values are drawn from RightsStatements.org and Creative Commons URIs, exactly as the manifest layer asserts them.
Schema and Spec Reference
An Image API request is a structured URL, not an opaque endpoint. The path after the identifier is four ordered parameters plus a format extension:
{scheme}://{server}/{prefix}/{identifier}/{region}/{size}/{rotation}/{quality}.{format}Each segment has a fixed grammar. The table below is the subset a museum delivery service must parse and validate on every request.
| Segment | Example values | Meaning |
|---|---|---|
region |
full, square, 125,15,120,140, pct:41,7,40,70 |
Full image, a centred square, absolute x,y,w,h pixels, or a percentage rectangle |
size |
max, ^max, 600,, ,400, pct:50, !200,200 |
Largest allowed, upscaled max, fixed width, fixed height, percentage, or best-fit within a box |
rotation |
0, 90, !0, 22.5 |
Clockwise degrees; a leading ! mirrors before rotating |
quality |
default, color, gray, bitonal |
Colour treatment of the returned bytes |
format |
jpg, png, tif, webp |
Serialization; jpg is the universal default for delivery |
The service advertises what it can actually do through info.json, requested at {identifier}/info.json. A client fetches this first and never requests a feature the document does not promise.
| Property | Type | Role |
|---|---|---|
@context |
string | Must be http://iiif.io/api/image/3/context.json |
id |
string | The service base URI, without a trailing slash |
type |
string | Fixed value ImageService3 |
protocol |
string | Fixed value http://iiif.io/api/image |
profile |
string | Compliance level: level0, level1, or level2 |
width / height |
integer | Pixel dimensions of the full image |
maxWidth / maxHeight / maxArea |
integer | Upper bounds the server will serve, capping expensive derivations |
sizes |
array | Pre-derived {width, height} pairs a client may prefer for thumbnails |
tiles |
array | {width, height, scaleFactors} describing the tile grid for deep zoom |
extraQualities / extraFormats / extraFeatures |
array | Capabilities beyond the declared compliance level |
rights |
string | A rights statement URI, mirrored from the record and manifest |
Compliance levels are a contract about which parameter values a client may use without checking extraFeatures. Level 0 serves only pre-generated derivatives — the exact sizes and tiles listed, nothing arbitrary. Level 1 adds arbitrary region requests and size by width or height. Level 2 adds arbitrary size (including !w,h best-fit and upscaling), rotation by any multiple of 90, and the color/gray/bitonal qualities. A static tile pyramid is naturally level 0; a dynamic server like Cantaloupe reaches level 2.
Step-by-Step Implementation
1. Construct info.json from the master’s dimensions
Read the master’s real width and height, then compute the tile grid and pre-derived sizes so the advertised document matches what the service can serve. Never hand-write dimensions — they must come from the pixels.
from __future__ import annotations
import pyvips
def build_info_json(
identifier_uri: str,
master_path: str,
tile_size: int = 512,
profile: str = "level1",
) -> dict[str, object]:
"""Build a IIIF Image API 3.0 info.json for one master image."""
image = pyvips.Image.new_from_file(master_path, access="sequential")
width, height = image.width, image.height
scale_factors = _scale_factors(width, height, tile_size)
return {
"@context": "http://iiif.io/api/image/3/context.json",
"id": identifier_uri, # no trailing slash
"type": "ImageService3",
"protocol": "http://iiif.io/api/image",
"profile": profile,
"width": width,
"height": height,
"maxWidth": 5000, # cap expensive on-the-fly derivations
"sizes": _preferred_sizes(width, height),
"tiles": [{"width": tile_size, "height": tile_size,
"scaleFactors": scale_factors}],
"extraQualities": ["color", "gray", "bitonal"],
"extraFormats": ["png", "webp"],
"preferredFormats": ["jpg"],
}
def _scale_factors(width: int, height: int, tile_size: int) -> list[int]:
"""Powers of two until the whole image fits inside one tile."""
factors, factor = [1], 1
while (width / factor) > tile_size or (height / factor) > tile_size:
factor *= 2
factors.append(factor)
return factors
def _preferred_sizes(width: int, height: int) -> list[dict[str, int]]:
"""A halving ladder of thumbnail sizes for level-0 clients."""
sizes, w, h = [], width, height
while w > 100 and h > 100:
w, h = w // 2, h // 2
sizes.append({"width": w, "height": h})
return list(reversed(sizes))Edge cases. For a static level-0 deployment, drop extraQualities/extraFormats, set profile to "level0", and make sure every entry in sizes and every scaleFactors value has a corresponding file on disk — a client will request exactly those and nothing else. For a dynamic server, maxWidth/maxArea are your load safety valve; without them a single full/max/0/default.jpg on a 400-megapixel master can exhaust memory.
2. Parse and validate an incoming request URL
Before serving anything, decompose the path into its four parameters and reject grammar the compliance level does not permit. Parsing is also where a malformed or hostile region gets caught before it reaches the pixel engine.
from dataclasses import dataclass
@dataclass(frozen=True)
class ImageRequest:
identifier: str
region: str
size: str
rotation: str
quality: str
fmt: str
def parse_request(path: str) -> ImageRequest:
"""Split a IIIF Image API path into validated components."""
parts = path.strip("/").split("/")
if len(parts) != 5:
raise ValueError("expected identifier/region/size/rotation/quality.format")
identifier, region, size, rot_qual = parts[0], parts[1], parts[2], parts[4]
rotation = parts[3]
quality, _, fmt = rot_qual.partition(".")
if not fmt:
raise ValueError("missing format extension")
_validate_region(region)
if quality not in {"default", "color", "gray", "bitonal"}:
raise ValueError(f"unsupported quality: {quality}")
return ImageRequest(identifier, region, size, rotation, quality, fmt)
def _validate_region(region: str) -> None:
"""Accept only the region forms in the Image API 3.0 grammar."""
match region.split(":", 1):
case ["full"] | ["square"]:
return
case ["pct", coords] | [coords] if coords.count(",") == 3:
nums = coords.split(",")
if all(_is_number(n) for n in nums):
return
raise ValueError(f"malformed region: {region}")
def _is_number(token: str) -> bool:
try:
float(token)
except ValueError:
return False
return TrueEdge cases. A percent-encoded identifier (common when accession numbers carry dots or slashes) must be decoded before it keys into storage, but validated before decoding so an encoded ../ cannot escape the identifier namespace. For a level-0 service, extend _validate_region to reject anything but full and the exact tile regions the pyramid contains.
3. Choose a tile size and scale factors
Tile size and scale factors decide how many round-trips a deep-zoom session costs. A tile that is too small floods the origin with requests; too large and each pan re-downloads more pixels than the viewport shows. 512×512 is the museum-sector default — large enough to keep request counts modest, small enough that a partial viewport does not over-fetch. Scale factors must be powers of two, one per pyramid level, ending when the whole image fits in a single tile; the _scale_factors helper above computes exactly that ladder. The static-tiling deep dive, serving deep-zoom IIIF tiles with pyvips, covers generating the matching pyramid on disk so every advertised scaleFactors value resolves to a real file.
4. Set the compliance level to match the backend
The profile value must not over-promise. Pin it to what the backend can actually serve: level0 for a static pyramid, level2 for a full dynamic server. If you advertise a level but support a feature beyond it — say a static service that also has a gray derivative — declare that in extraFeatures/extraQualities rather than bumping the whole level. Clients read the level as a floor and the extra* arrays as additions, so an honest, minimal profile plus explicit extras is always safer than an inflated level the origin cannot back.
Data Flow
The service answers two request shapes from one identifier: an info.json fetch that advertises capabilities, and a stream of image requests that get parsed and either served from a pre-generated tile or derived on the fly — unless rights gate the asset, in which case a degraded placeholder is returned instead.
The advertise path runs once per object per session and is cheap to cache; the serve path runs dozens of times per zoom and is where tiling, maxWidth caps, and the rights gate earn their keep. Keeping the two paths distinct is what lets an edge cache hold info.json indefinitely while image responses carry their own cache policy — the subject of caching IIIF Image API responses at the edge.
Rights and Access Routing
The Image API is where a rights policy stops being metadata and starts controlling pixels. Every request passes a rights gate that consults the record’s access tier before the pixel engine runs. An object at the public tier serves full-resolution regions; an object whose media is gated — in-copyright, embargoed, or donor-restricted — never returns its master pixels. Instead the gate substitutes a degraded placeholder derivative: a low-resolution, watermarked, or single-sizes-entry image that satisfies the request shape without exposing the asset. The descriptive record and its manifest stay public so the object remains discoverable; only the pixels are withheld.
This tiering is decided upstream, not here. The policy that maps a rights URI and an embargo date to an access tier is specified in routing embargo dates to access tiers; the Image API simply reads the tier the record already carries and routes accordingly. Two rules keep this safe. First, the gate runs before derivation, so a gated master is never decoded into memory only to be discarded. Second, the placeholder must itself be a valid IIIF response — correct region/size dimensions and content type — so the viewer degrades gracefully rather than showing a broken image. The same rights URI that gated the pixels is echoed in the info.json rights property and in the manifest’s requiredStatement, so every layer enforces one consistent policy.
Verification and Testing
Prove the grammar parser and the info.json builder before pointing a viewer at the service. The parser test below exercises a valid tile request, a malformed region, and an unsupported quality; the info.json assertions confirm the advertised tile grid is internally consistent.
def test_request_parsing() -> None:
# 1. A well-formed region/size/rotation/quality request parses.
req = parse_request("folio-001/125,15,120,140/600,/0/default.jpg")
assert req.identifier == "folio-001"
assert req.region == "125,15,120,140"
assert req.fmt == "jpg"
# 2. A malformed region is rejected before the pixel engine runs.
try:
parse_request("folio-001/12,15,120/max/0/default.jpg")
assert False, "three-coordinate region should have raised"
except ValueError:
pass
# 3. An unsupported quality is rejected.
try:
parse_request("folio-001/full/max/0/sepia.jpg")
assert False, "sepia is not a IIIF quality"
except ValueError:
pass
def test_info_scale_factors() -> None:
factors = _scale_factors(4000, 3000, tile_size=512)
# Powers of two until 4000/factor <= 512 -> [1, 2, 4, 8]
assert factors == [1, 2, 4, 8]
assert all(f == 2 ** i for i, f in enumerate(factors))
if __name__ == "__main__":
test_request_parsing()
test_info_scale_factors()
print("image API config OK")Then confirm the live service advertises what you expect. Fetch info.json and check it is valid JSON with the right context, type, and dimensions before any viewer touches it:
curl -s https://iiif.example.org/iiif/3/folio-001/info.json | jq '{context: ."@context", type, profile, width, height, tiles}'A conformant response echoes http://iiif.io/api/image/3/context.json, ImageService3, the declared compliance level, the true pixel dimensions, and a tiles array whose scaleFactors match the pyramid on disk. Any mismatch between advertised and served capabilities is the defect to fix before a client discovers it as a 404 mid-zoom.
Where to Go Deeper
-
Serving Deep-Zoom IIIF Tiles with pyvips — generates the static pyramid that backs a level-0 service, writing one derivative per tile and scale factor so every value the
info.jsontilesarray advertises resolves to a real file. It covers pyramidal TIFF versus DZI layout, tile-boundary math, and streaming generation that keeps a 400-megapixel master off the heap. -
Caching IIIF Image API Responses at the Edge — puts a CDN in front of the serve path so a reading-room deep-zoom session hits the origin once per tile, not once per pan. It separates the long-lived cache policy for immutable tiles from the shorter policy for
info.json, and shows how the rights gate stays authoritative even behind an edge cache.
FAQ
What is the difference between the Image API and the Presentation API?
The Image API serves pixels: it answers region/size/rotation/quality requests for one image and advertises its capabilities in info.json. The Presentation API serves structure: a manifest describing which images make up an object, their order, labels, and rights. A viewer reads the manifest to lay out the object and calls the Image API service each canvas references to fetch the pixels. You need both, and the manifest’s image service URI must point at a live Image API endpoint.
Which compliance level should a museum service advertise?
Advertise the level your backend can actually serve, and no higher. A static tile pyramid on object storage is level0 — it serves only the exact sizes and tiles it pre-generated. A dynamic server that can crop and scale arbitrarily reaches level2. If you inflate the profile to level2 but only have static tiles, clients will request arbitrary regions and get 404s. When you support one feature beyond your level, declare it in extraFeatures rather than raising the whole level.
How do I choose a tile size and scale factors?
512×512 tiles are the sector default: small enough that a partial viewport does not over-fetch, large enough to keep request counts modest during a zoom session. Scale factors must be powers of two, one per pyramid level, running from 1 up to the factor at which the whole image fits inside a single tile. Every scale factor you list in info.json must have a matching pyramid level on disk or from the dynamic server, or a deep-zoom client will request a level that does not exist.
How does the Image API enforce rights on gated assets?
A rights gate runs before the pixel engine and reads the access tier the record already carries. Public-tier objects serve full-resolution regions; gated objects — in-copyright, embargoed, or donor-restricted — get a degraded placeholder derivative instead of their master pixels, so the object stays discoverable while its pixels stay withheld. The tiering decision itself lives in routing embargo dates to access tiers; the Image API only enforces it.
Why does my viewer request URLs that return 404?
Almost always because info.json promises capabilities the backend cannot serve. A maxWidth higher than the largest derivative, a scaleFactors value with no matching pyramid level, or a level2 profile on a static service all make a conformant client compose URLs the origin cannot answer. Regenerate info.json from the master’s real dimensions and the actual files on disk, then diff the advertised tiles and sizes against what the storage layer holds.
Related
- IIIF Image Delivery & Manifest Generation — parent delivery overview
- Serving Deep-Zoom IIIF Tiles with pyvips — static pyramid generation
- Caching IIIF Image API Responses at the Edge — CDN policy for tiles and info.json
- Building IIIF Presentation Manifests — the structure layer that references this service
- Routing Embargo Dates to Access Tiers — the rights policy the gate enforces