Skip to content

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 in image-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:

  • Configimage.sharpen, image.sharpen_sigma, image.sharpen_x1 (internal/config: ImageConfig, validated by ImageConfig.Validate, env-overridable as IIIRIS_IMAGE_SHARPEN*). Full table in docs/configuration.md.
  • Pipeline optionsimage.Options.Sharpen / .SharpenSigma / .SharpenX1 (float64) alongside .Supersample (int). Resolved once in NewPipelineWithOptions via newSharpenParams, which maps the operator-facing strength onto libvips' Sharpen(sigma, x1, m2).
  • Cache saltauth.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-icc when applicable) — srgb is inert (zero churn, same digest as before colour policy existed); see color-policy.md. See scaffold.md and auth.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. A max/full render, a 1:1 crop, any upscale, and a distorting forced size that enlarges one axis while shrinking the other (e.g. ^2000,100 on 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. So sharpen: 0.5 lands on libvips' own default m2 of 3.0. This constant (sharpenMaxM2) is part of the published contract: changing it silently alters the output of every deployment that has set image.sharpen. Strength is clamped to 1.0 — config validation already rejects out-of-range values at startup, and the clamp keeps a directly-constructed Pipeline safe.
  • sharpen_sigma / sharpen_x1 are inert when sharpen is 0. They override libvips' defaults (1.5 / 2.0) for the unsharp radius and the flat/jagged threshold; 0 on either means "use the libvips default". This is load-bearing, not merely tidy: auth.RenderQualitySalt returns "" — the bare-urlPath cache key — whenever sharpen == 0 and 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_sharpen works on the L channel of LAB, so sharpening CMYK pixels would force a lossy CMYK→LAB→CMYK detour. sharpenable tests 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.goTestSharpen_ChangesDownscaledOutput plus 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), and TestSharpen_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-range sharpen / unsupported supersample are 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 on r.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.
  • supersample defaulting to 2 — a real, measurable cold-render slowdown existing operators did not ask for. Quality is opt-in.