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:
- Resolution-aware decode — decode at (or near) the output resolution instead of decoding full-res and shrinking.
- Memory-mapped source I/O —
mmapfilesystem sources so libvips faults in only the pages it reads. - Header-only dimension probe — read
info.jsonwidth/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; callsdecode.Pipeline.Probe(ctx, source)/image.Probe(source)—info.jsondimension 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) andsupersamplePin/reducedReferenceDims(reduce.go, arbitrary-region path). - Source I/O (
sourcebytes.go+ build-taggedmmap_unix.go/mmap_windows.go/mmap_other.go):sourceBytes(r) ([]byte, cleanup, error)— mmaps an eligible file, elseio.ReadAll. - Header dimensions (
dimensions.go):dimensions(buf) (w, h, ok)—image.DecodeConfig(JPEG/PNG/GIF), TIFF IFD walk, JP2ihdrbox / raw-codestreamSIZ(the latter viajp2box.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:
cleanReductionfor the arbitrary-region path,thumbnailPlan.matchesfor 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) path —
loadThumbnailFast. The referenceW×Hcomes from a real un-inflatedthumbnailcall, and the inflated decode is forced onto exactly those dimensions withvips.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 path —
supersamplePin. Raisingsupersamplelowers the reductionR, andRis baked into the remapped crop (remapRegionroundsW/Honto theRgrid) 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 differentR, and reduced → full when the headroom demand drivesRto1and 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: thesupersample: 1output dimensions are substituted for the request's size as a forcedw,h, so whichever path the supersampled decode lands on, one residual Lanczos puts it on the reference dimensions. Path selection is pinned too — ifsupersample: 1would 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 inreductionForand the source clamp inthumbnailPlan.inflate).quality_test.go—TestSupersample_NeverChangesDimensionspins the dimension guarantee acrosssupersample: 1 | 2 | 4on both decode paths, andTestSupersample_ReducedPathDimensionSweepsweeps 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) andTestSupersample_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.go—BenchmarkExecuteSupersample/BenchmarkExecuteSupersampleTilemeasure the cost of the headroom.sourcebytes_test.go— file-vs-reader byte-equivalence, idempotent mmap cleanup, raw-J2K fallback, and end-to-endExecuteequivalence (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— theExecute/Probeconcurrency 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.