Skip to content

Image decode + source I/O

The contract for how internal/image reads a source and decodes only the resolution and bytes a request needs. Three transparent, output-equivalent optimisations sit on the decode/probe path, each with a guaranteed fallback to the plain full-decode behaviour:

  1. Resolution-aware decode — decode at (or near) the output resolution instead of decoding full-res and shrinking.
  2. Memory-mapped source I/Ommap filesystem sources so libvips faults in only the pages it reads.
  3. Header-only dimension probe — read info.json width/height from the file header in pure Go, without constructing a libvips image.

Operator-facing performance notes live in the package's behaviour; this is the durable contract a re-implementation must satisfy.

What it does

A render or info request carries its region and size up front, and only needs a fraction of a large master. Rather than io.ReadAll the whole compressed source and decode every pixel, the pipeline:

  • picks a decode-time reduction the source codec supports (JPEG DCT shrink-on-load, JP2/HTJ2K and pyramidal-TIFF level reduction) and remaps the crop onto that reduced grid;
  • memory-maps the source file so only the touched pages are resident;
  • for info.json, parses dimensions from the header (no decode).

All three are invisible to callers: identical bytes are served, the same cache keys are used, and any case the fast path doesn't cleanly handle takes the previous full-decode / io.ReadAll / libvips-probe path.

Surface

The optimisations themselves are internal to internal/image — they add no public API and no route. One operator-facing knob tunes the reduction they choose: image.supersample (IIIRIS_IMAGE_SUPERSAMPLE; 1 = off, the default, or 2 / 4), the minimum linear headroom the decode must leave above the output. See docs/configuration.md. The other half of render quality — the post-decode unsharp mask (image.sharpen) — is contracted in render-quality.md.

Entry points and helpers:

  • Pipeline.Execute(ctx, req, source) — render path; calls decode.
  • Pipeline.Probe(ctx, source) / image.Probe(source)info.json dimension path.
  • Resolution-aware decode (reduce.go): planThumbnail / loadThumbnail / thumbnailPlan.inflate (full-region downscale), loadReducedRegion / loadReduced + remapRegion / remapSize + reductionFor (arbitrary-region downscale), cleanReduction (the proportional-reduction guard), normalizeSupersample (coerces an unset/invalid factor to 1).
  • Supersample dimension guards: loadThumbnailFast (pipeline.go, full-region path) and supersamplePin / reducedReferenceDims (reduce.go, arbitrary-region path).
  • Source I/O (sourcebytes.go + build-tagged mmap_unix.go / mmap_windows.go / mmap_other.go): sourceBytes(r) ([]byte, cleanup, error) — mmaps an eligible file, else io.ReadAll.
  • Header dimensions (dimensions.go): dimensions(buf) (w, h, ok)image.DecodeConfig (JPEG/PNG/GIF), TIFF IFD walk, JP2 ihdr box / raw-codestream SIZ (the latter via jp2box.go).

Contracts

  • Output equivalence. Rendered output is byte-for-byte identical to the full-decode path, within resampling tolerance for shrink-on-load (both Lanczos). Render and info cache keys are unchanged — a cached result must not depend on which path produced it. These are optimisations, not behaviour changes. (This describes the default supersample: 1; raising it is an explicit operator opt-in to different — more resampled — pixels, and rekeys the render cache accordingly. See below.)
  • Loader results are verified, not trusted. Both reduced paths check what the loader actually returned before using it: cleanReduction for the arbitrary-region path, thumbnailPlan.matches for the full-region thumbnail path. A decode that ignored the reduction, clamped oddly, or selected an unrelated pyramid level fails the check and falls back to the full decode — slower, but the requested resolution.

This is not defensive decoration. libvips' buffer thumbnail selects the smallest pyramid level for a pyramidal TIFF or JP2 (confirmed 8.14.1 and 8.16.0; 8.18.2 is correct), and because SizeDown only shrinks, that level is returned untouched — a 9021×7122 master answering !1024,1024 with a 70×55 image, HTTP 200, valid JPEG, no error. The file-based thumbnail is correct at the same version, so the buffer path iiiris must use for HTTP and S3 sources is the exposed one. Explicit w,h and max were unaffected. - Conservative reduction. Never decode below the resolution the output needs: the reduction keeps the region at least image.supersample × as large as the output (source / R ≥ supersample × target); the reduction factor is the largest power-of-two ≤ a cap. At the default supersample: 1 this is exactly the original guarantee — the region stays at least as large as the output. cleanReduction requires the loader to return a clean proportional power-of-two reduction, else the request falls back to the full decode. - Supersampling changes pixels, never dimensions. Output W×H is identical for every request across supersample: 1 | 2 | 4, so info.json never misreports what the server produces. Only the pixels differ: with headroom above the target, the residual Lanczos reduce actually runs instead of degenerating into a no-op on an exact power-of-two request.

This does not fall out of the arithmetic — both decode paths have to enforce it, and each does so the same way: take the supersample: 1 dimensions as the reference and render onto them. Neither reference is computed arithmetically; each comes from running the supersample: 1 pipeline as a lazy libvips pipeline that is closed without ever rendering a pixel (a header parse plus pipeline construction — not a second decode). Only the forcing primitive differs.

  • Full-region (thumbnail) pathloadThumbnailFast. The reference W×H comes from a real un-inflated thumbnail call, and the inflated decode is forced onto exactly those dimensions with vips.SizeForce; the size is never recomputed against the inflated image. The inflated target is also clamped to the source dimensions (thumbnailPlan.inflate), so the intermediate never exceeds full source resolution and a forced size can never trigger an enlarge-then-reduce round-trip.
  • Arbitrary-region pathsupersamplePin. Raising supersample lowers the reduction R, and R is baked into the remapped crop (remapRegion rounds W/H onto the R grid) and the remapped percent size (remapSize); a crop that rounds to a different aspect ratio moves any derived output axis ({w},, ,{h}, pct:, !w,h) by a pixel. Two ways it bites: reduced → reduced at a different R, and reduced → full when the headroom demand drives R to 1 and the request leaves the reduced path altogether (the reduced and full paths disagree by a pixel on some requests — see below). The pin closes both: the supersample: 1 output dimensions are substituted for the request's size as a forced w,h, so whichever path the supersampled decode lands on, one residual Lanczos puts it on the reference dimensions. Path selection is pinned too — if supersample: 1 would not have reduced, the supersampled decode does not reduce either.

The pin deliberately forces via the size stage (applySize's forced w,h branch) rather than vips_thumbnail_image: the latter's colour management converts CMYK to sRGB, which would make sharpenable's CMYK exclusion depend on image.supersample. See render-quality.md.

The reference is what supersample: 1 produces — not what the full-decode path produces. The reduced and full paths already disagree by a pixel on some requests at the default supersample: 1; that discrepancy predates supersampling and is out of this contract's scope. Re-pointing the reduced path at the full path's answer would change output dimensions for every existing default-config deployment. supersample: 1 output is byte-identical to a build without the feature, and stays that way.

A non-default supersample changes the rendered bytes for an unchanged URL, so it is folded into the render cache key via the render-quality salt (|q=<hash>, append-only) — see scaffold.md "Cache contract". The default rekeys nothing. - Header parse equals libvips raw. The pure-Go header parser (dimensions()) must return exactly what vips.NewImageFromBuffer(...).Width()/Height() would on the raw header — no EXIF autorotate applied. Guarded by a corpus equivalence test; any unrecognised format or unparseable header falls back to the libvips probe. This is an internal helper invariant and is independent of the metadata policy below. - Probe reports the dimensions the render will produce. Pipeline.Probe feeds info.json, so it must agree with what the render pipeline outputs for the same identifier: - image.metadata: preserve — no autorotate; Probe returns raw header dimensions via the fast path (equal to libvips raw, as above). - image.metadata: strip (default) — the render pipeline bakes a non-normal EXIF orientation into the pixels (autorotate), so Probe returns oriented dimensions (width/height swapped for the 90°/270° orientations 5–8). In this mode Probe reads the orientation via libvips (img.GetOrientation()) rather than the orientation-blind fast path, so it resolves orientation the same way the render side does. info.json is cached, so the cold-path cost is amortized.

Probe and the render must never disagree on dimensions for a given identifier, or IIIF region/tile math breaks. - mmap eligibility + lifetime. mmap applies only to a regular on-disk file (the filesystem source's *os.File) whose bytes prepareBuffer won't rewrite (not a raw J2K codestream), on a platform with a real mmap (unix, windows). The mapping stays valid until after encode — the image may re-read the source during downstream ops — so cleanup is deferred to run after the image is closed. - Fallback is always safe, never the only path. Unreducible format / full-res / upscale → full decode. Non-file source (HTTP/S3 stream, the Cached wrapper's bytes.Reader), mmap failure, or non-Unix/Windows platform → io.ReadAll. Unknown format / bad header → libvips probe. - Decode-only. No encoder, output format, or format-support change; the htj2k decode contract is intact. - Bounded concurrency. Execute and Probe are each gated by a separate semaphore sized to image.max_concurrent, so an info.json burst can't starve renders (and vice-versa) or hold an unbounded number of in-flight source buffers.

Test coverage

In internal/image/:

  • reduce_test.go — pixel-equivalence of shrink-on-load and reduced-region decode against a full-decode reference; reduction math (including the supersample headroom in reductionFor and the source clamp in thumbnailPlan.inflate).
  • quality_test.goTestSupersample_NeverChangesDimensions pins the dimension guarantee across supersample: 1 | 2 | 4 on both decode paths, and TestSupersample_ReducedPathDimensionSweep sweeps the arbitrary-region shape space (odd/even regions, offset origins, every size form) that a hand-picked case list is too weak to cover — the drift the pin closes hit 13.5% of a 990-request sweep while a single even, square reduced case passed. TestSupersample_ChangesPixelsOnPowerOfTwoDownscale (thumbnail path) and TestSupersample_ChangesPixelsOnReducedTile (arbitrary-region path) pin that it still changes the pixels it is supposed to — the pin fixes dimensions without flattening the feature into a no-op.
  • pipeline_bench_test.goBenchmarkExecuteSupersample / BenchmarkExecuteSupersampleTile measure the cost of the headroom.
  • sourcebytes_test.go — file-vs-reader byte-equivalence, idempotent mmap cleanup, raw-J2K fallback, and end-to-end Execute equivalence (mmap vs ReadAll source).
  • dimensions_test.go — header-parsed dims match libvips across jpeg/png/tiff/jp2/jph; garbage input falls back to the libvips probe.
  • pipeline_test.go — the Execute / Probe concurrency gates.

Out of scope

  • Pre-generating pyramids for flat sources (a flat JPEG/PNG with no pyramid). iiiris reads the resolutions a source already provides; JPEG shrink-on-load already helps.
  • Region/tile-level spatial decode. Not needed — libvips already reads only the tiles covering a crop from a tiled source on demand.
  • mmap / partial I/O for non-filesystem sources (HTTP, S3) and for the origin cache. Network sources are latency-bound and keep buffering; a filesystem origin-cache mmap is a possible later win.
  • Encoder choices (baseline vs progressive JPEG, quality). Those are config and live in docs/configuration.md.