Render quality: sharpening¶
The contract for iiiris's post-decode unsharp mask — the pipeline stage that restores apparent acutance to derivatives that were scaled down.
Render quality has two halves, tuned by two independent operator settings:
image.sharpen— the unsharp mask. Post-decode pipeline stage. This spec owns it.image.supersample— the minimum linear decode headroom above the output, which guarantees a real Lanczos resample actually runs. That is a decode behaviour, so its contract lives inimage-decode.md("Conservative reduction", "Supersampling changes pixels, never dimensions").
Both are off by default and both feed the same render cache-key salt, so
this spec covers the parts they share (defaults, restart-only, rekeying) and
defers the decode half to image-decode.md. Operator-facing setup — YAML
keys, env vars, ranges, measured costs — lives in
docs/configuration.md "Render quality".
What it does¶
A downscale discards high-frequency detail, so a reduced derivative reads
soft even when the resample itself is clean. When image.sharpen is set,
Pipeline.Execute runs libvips' unsharp mask (vips_sharpen) on the
rendered image, between the size stage and rotation. It fires only on renders
that genuinely shrank, and never on pixels where sharpening would do harm
rather than good. A default deployment renders exactly the pixels it rendered
before the feature existed.
Surface¶
No new route, no new public Go interface. Three surfaces:
- Config —
image.sharpen,image.sharpen_sigma,image.sharpen_x1(internal/config:ImageConfig, validated byImageConfig.Validate, env-overridable asIIIRIS_IMAGE_SHARPEN*). Full table indocs/configuration.md. - Pipeline options —
image.Options.Sharpen/.SharpenSigma/.SharpenX1(float64) alongside.Supersample(int). Resolved once inNewPipelineWithOptionsvianewSharpenParams, which maps the operator-facing strength onto libvips'Sharpen(sigma, x1, m2). - Cache salt —
auth.RenderQualitySalt(sharpen, sigma, x1, supersample, colorPolicy) string, wired into the render cache key as the append-only|q=<hash>segment. The salt also carries the effective colour policy (|q=folds in;co=respect-iccwhen applicable) —srgbis inert (zero churn, same digest as before colour policy existed); seecolor-policy.md. Seescaffold.mdandauth.md"Cache contract" for the key shape.
Internal helpers (internal/image/sharpen.go): newSharpenParams,
sharpenable, applySharpen, isDownscale.
Contracts¶
- Sharpening applies only when neither axis was enlarged and at least one
axis shrank (
isDownscale, against the crop's size in oriented source pixels). A pure downscale is sharpened. Amax/fullrender, a 1:1 crop, any upscale, and a distorting forced size that enlarges one axis while shrinking the other (e.g.^2000,100on an 800×600 region) are all left untouched — an enlarged axis is interpolated, and sharpening it amplifies resample ringing rather than restoring detail. - The strength mapping is frozen:
m2 = sharpen × 6.0, linear. Sosharpen: 0.5lands on libvips' own defaultm2of3.0. This constant (sharpenMaxM2) is part of the published contract: changing it silently alters the output of every deployment that has setimage.sharpen. Strength is clamped to1.0— config validation already rejects out-of-range values at startup, and the clamp keeps a directly-constructedPipelinesafe. sharpen_sigma/sharpen_x1are inert whensharpenis 0. They override libvips' defaults (1.5 / 2.0) for the unsharp radius and the flat/jagged threshold;0on either means "use the libvips default". This is load-bearing, not merely tidy:auth.RenderQualitySaltreturns""— the bare-urlPathcache key — wheneversharpen == 0and supersample is off, on the assumption that sigma/x1 alone cannot change a single pixel. If that ever stopped holding, renders would be silently mis-keyed.- Sharpening never round-trips colour: an image that is still CMYK when the
stage runs is left untouched.
vips_sharpenworks on the L channel of LAB, so sharpening CMYK pixels would force a lossy CMYK→LAB→CMYK detour.sharpenabletests the image's interpretation at the sharpen stage and skips it.
The contract is about CMYK pixels, not CMYK sources, because the
decode path decides which arrive: the full-decode and reduced-region paths
preserve the source's colour space, so a CMYK master genuinely reaches the
stage in CMYK and is skipped, while the full-region thumbnail path
decodes through libvips' thumbnail operator, which converts to sRGB during
shrink-on-load — so the same master arrives as sRGB and is sharpened.
That is correct, not a leak: the sharpen runs on sRGB pixels and no CMYK
round-trip occurs. Nothing may reintroduce one — in particular the decode
must not convert a CMYK image to sRGB purely to satisfy the supersample
dimension pin (see image-decode.md, supersamplePin),
or which sources get sharpened would start depending on image.supersample.
- Overlays are never sharpened — for both target=source and
target=output. This is enforced solely by stage ordering: the sharpen
stage runs after size and before both overlay composites in
Pipeline.Execute. Any reordering that moves a composite ahead of it
breaks the contract, so the order is load-bearing rather than incidental.
- Defaults reproduce prior behaviour exactly. sharpen: 0 (off) and
supersample: 1 (off) render the same bytes, at the same cost, as a build
without either feature. The zero values of image.Options also mean off,
so a directly-constructed Pipeline is unchanged.
- Both settings are restart-only. The cache salt is computed once at
wiring (server.Deps.RenderQuality) and the resolved sharpen params are
fixed at Pipeline construction; neither is hot-reloadable, and neither is
exposed in the admin config editor's live fields.
- Changing either setting rekeys the render cache, never serves stale
renders. A non-default value contributes the append-only |q=<hash>
segment, so retuning lands renders in a fresh namespace and stale entries
age out through normal eviction. A deployment that never touches these
knobs keeps bare-urlPath keys and evicts nothing. No flush, no
cacheKeyVersion bump. InfoKey is untouched — these settings change
pixels, not dimensions.
Test coverage¶
internal/image/quality_test.go—TestSharpen_ChangesDownscaledOutputplus the four negative cases that pin the fire/don't-fire boundary (_SkipsFullSizeRender,_SkipsUpscale,_SkipsDistortingForcedSize,_SkipsCMYK),TestSharpen_SigmaX1InertWhenOff(the assumption the empty salt rests on), andTestSharpen_LeavesOverlayUntouched(the stage-order contract).internal/auth/cachekey_test.go— the empty-for-default salt, its stability, the preserved legacy key shape, and the|q=append order relative to|o=.internal/config— out-of-rangesharpen/ unsupportedsupersampleare startup errors.
Out of scope¶
- Per-request sharpen override (
?sharpen=0.7) — puts a non-standard parameter on IIIF URLs, and the render cache keys onr.URL.Path. - Per-source / per-identifier sharpening — needs a new hook field and the largest config surface; revisit if collections genuinely need separate tuning.
- Reduction-scaled sharpen strength — a strength ramp keyed on how far the render shrank is a magic curve operators cannot predict, tune, or be promised in a contract.
- Raw libvips parameters as the primary config (
{sigma, x1, m2}) — would leak the implementation into a surface iiiris then owes backwards compatibility on. They remain available as the advanced overrides. supersampledefaulting to 2 — a real, measurable cold-render slowdown existing operators did not ask for. Quality is opt-in.