Skip to content

transform: optionally carry the source JPEG's ICC color profile - #505

Open
datasage wants to merge 1 commit into
willnorris:mainfrom
datasage:icc-profile-passthrough
Open

transform: optionally carry the source JPEG's ICC color profile#505
datasage wants to merge 1 commit into
willnorris:mainfrom
datasage:icc-profile-passthrough

Conversation

@datasage

@datasage datasage commented Sep 2, 2026

Copy link
Copy Markdown

Fixes #160.

I fixed this issue downstream in my own project by injecting a patch into my source build. Making this available upstream if there is interest.

The problem

image/jpeg sends every APPn marker except APP0 (JFIF) and APP14 (Adobe) to decoder.ignore, and Encode writes no APP segments at all. So a JPEG's embedded ICC profile cannot survive DecodetransformImageEncode: transformed output begins ff d8 ff db — SOI straight into DQT.

An untagged JPEG is rendered as sRGB, so anything authored in a wider gamut comes back desaturated. Measured against a correct color-managed render at /x80/:

source RMSE, today RMSE, with the flag on
Display P3 3.64% 0.30%
Adobe RGB 4.93% 0.31%

The residual ~0.3% is JPEG quantization noise. Sample pixel from the Adobe RGB source: correct 148,0,133, currently served as 126,0,128, and 148,0,132 with the flag on.

The change

A passthroughICC flag, off by default, following the same ProxyOptions path scaleUp already uses (Proxy.PassthroughICCreq.Options.PassthroughICC, with a matching option token that is likewise not documented in the options list). envy gives it IMAGEPROXY_PASSTHROUGHICC for free.

Off by default because the profile's bytes land on every response that carries one — commonly 0.5–4KB, which is a large relative cost on a small thumbnail (+33% on an x80, ~+3% at x600). That trade-off felt like the operator's call rather than a default, which is why this is a flag rather than unconditional behavior.

Implementation is icc.go: walk the source's marker segments, reassemble the ICC_PROFILE APP2 chunks, and splice them back in after SOI on the encoded output. Pure stdlib, no new dependencies, no cgo.

The guard is the interesting part

Reattaching a profile is only correct when the decoded pixels are still in the space it describes. YCbCr → RGB uses the fixed JPEG matrix, which is color-space agnostic, so an RGB source's triplets stay in its own primaries and the profile still describes them. A CMYK/YCCK source is different — the decoder converts it by naive inversion, so its profile no longer describes the result and copying it over would make things worse, not better.

So passthrough requires all of: source and output both JPEG, profile data color space RGB , decoded image not *image.CMYK/*image.Gray, profile at least a full 128-byte ICC header and no larger than 64KB.

The 64KB ceiling does double duty — it keeps a large LUT profile off a thumbnail, and it bounds reassembly, since a crafted source can declare 255 chunks of 65519 bytes. extractICCProfile sums the chunk lengths and bails before allocating rather than building ~16MB for the guard to discard. Happy to change that number or make it configurable if you'd rather.

This deliberately does not address #213 (CMYK), which needs real color conversion rather than passthrough, or PNG output, which would need an iCCP chunk.

Testing

go test ./... passes. Six tests added covering the helpers and the flag end to end:

  • round trip, including profiles large enough to span multiple APP2 segments
  • malformed input: not-a-JPEG, truncated segment, length running past the buffer, no ICC segment
  • incomplete and inconsistent chunk sets
  • the oversized-profile bail, at the exact cap boundary
  • the guard matrix (CMYK profile, CMYK source, Gray source, short-of-a-header, oversized, absent)
  • Transform itself with the flag off and on, asserting the profile is dropped and preserved respectively

I mutation-tested the new guards rather than trusting coverage — removing the flag check, the size cap, or the header-length check each makes the suite fail rather than pass silently.

Verified end to end through a built container against a local origin as well: Display P3 and Adobe RGB sources keep their profile, and CMYK, grayscale, and profile-less sources come back byte-identical to current output.

Process notes

docs/contributing.md asks that new functionality be discussed in an issue first. I took #160 as that discussion — it describes this exact defect and has been open a while — but this is a PR against an 8-year-old bug rather than a feature nobody asked for, so if you'd rather hash out the flag name, the default, or the 64KB cap before reviewing code, I'm happy to move it back to the issue.

I've left docs/changelog.md alone: entries there carry merge commit SHAs and PR numbers, so it reads as something you curate at release time rather than per-PR. Say the word and I'll add an [Unreleased] entry.

golangci-lint v2.11.4 (the version pinned in linter.yml, with the repo's .golangci.yml) reports 0 issues, and go vet ./... and go test ./... are clean.

image/jpeg sends every APPn marker but APP0 and APP14 to decoder.ignore, and
Encode writes no APP segments at all, so a JPEG's embedded ICC profile does
not survive the decode/encode round trip. The output is then rendered as
sRGB, which visibly desaturates images authored in a wider gamut: measured
against a correct color-managed render at /x80/, a Display P3 source is
3.64% RMSE off and an Adobe RGB source 4.93%. With the profile carried over
both drop to ~0.3%, which is JPEG quantization noise.

Add a passthroughICC flag, off by default, following the same
Proxy -> Options path as scaleUp. It is opt-in because the profile's bytes
are added to every response that carries one -- commonly 0.5-4KB, which is
a large relative cost on a small thumbnail.

The profile is only reattached where the decoded pixels are still in the
space it describes. YCbCr is converted to RGB with the fixed JPEG matrix,
which is color-space agnostic, so an RGB source's triplets stay in its own
primaries; a CMYK or YCCK source is converted by naive inversion, so its
profile no longer describes the result. Hence the guard: source and output
both JPEG, profile data color space RGB, decoded image not CMYK or Gray,
and profile no larger than 64KB.

Fixes willnorris#160

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@datasage datasage mentioned this pull request Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Color Loss

1 participant