Skip to content

Commit b602748

Browse files
datasageClaude Opus 5 (1M context)
andcommitted
transform: optionally carry the source JPEG's ICC color profile
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 #160 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ca6da34 commit b602748

7 files changed

Lines changed: 357 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,30 @@ However, you can use the `scaleUp` command-line flag to allow this to happen:
346346
imageproxy -scaleUp true
347347
```
348348

349+
### ICC color profiles
350+
351+
Go's `image/jpeg` package does not read or write APP2 marker segments, so a
352+
JPEG's embedded ICC color profile is lost when imageproxy decodes and
353+
re-encodes it. The result is then rendered as sRGB, which visibly desaturates
354+
images authored in a wider gamut such as Display P3 or Adobe RGB.
355+
356+
The `passthroughICC` flag carries the source profile over to the transformed
357+
image:
358+
359+
```sh
360+
imageproxy -passthroughICC true
361+
```
362+
363+
It is off by default because the profile's bytes are added to every response
364+
that carries one — commonly 0.5-4KB, which is a large relative cost on a small
365+
thumbnail.
366+
367+
The profile is only carried over when the decoded pixels are still in the space
368+
it describes: both the source and the output must be JPEG, the profile's data
369+
color space must be RGB, and the decoded image must not be CMYK or grayscale
370+
(the decoder converts those, so the source profile no longer describes the
371+
result). Profiles larger than 64KB are dropped rather than attached.
372+
349373
### WebP and TIFF support
350374

351375
Imageproxy can proxy remote webp images, but they will be served in either jpeg

‎cmd/imageproxy/main.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ var passResponseHeaders = flag.String("passResponseHeaders", "Cache-Control,Last
4343
var cache tieredCache
4444
var signatureKeys signatureKeyList
4545
var scaleUp = flag.Bool("scaleUp", false, "allow images to scale beyond their original dimensions")
46+
var passthroughICC = flag.Bool("passthroughICC", false, "carry the source JPEG's embedded ICC color profile over to the transformed image")
4647
var timeout = flag.Duration("timeout", 0, "time limit for requests served by this proxy")
4748
var verbose = flag.Bool("verbose", false, "print verbose logging messages")
4849
var _ = flag.Bool("version", false, "Deprecated: this flag does nothing")
@@ -95,6 +96,7 @@ func main() {
9596
p.FollowRedirects = *followRedirects
9697
p.Timeout = *timeout
9798
p.ScaleUp = *scaleUp
99+
p.PassthroughICC = *passthroughICC
98100
p.Verbose = *verbose
99101
p.UserAgent = *userAgent
100102
p.MinimumCacheDuration = *minCacheDuration

‎data.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const (
2828
optSignaturePrefix = "s"
2929
optSizeDelimiter = "x"
3030
optScaleUp = "scaleUp"
31+
optPassthroughICC = "passthroughICC"
3132
optCropX = "cx"
3233
optCropY = "cy"
3334
optCropWidth = "cw"
@@ -74,6 +75,11 @@ type Options struct {
7475
// will always be overwritten by the value of Proxy.ScaleUp.
7576
ScaleUp bool
7677

78+
// Carry the source image's embedded ICC color profile over to the
79+
// transformed image. This value will always be overwritten by the value
80+
// of Proxy.PassthroughICC.
81+
PassthroughICC bool
82+
7783
// Desired image format. Valid values are "jpeg", "png", "tiff".
7884
Format string
7985

@@ -116,6 +122,9 @@ func (o Options) String() string {
116122
if o.ScaleUp {
117123
opts = append(opts, optScaleUp)
118124
}
125+
if o.PassthroughICC {
126+
opts = append(opts, optPassthroughICC)
127+
}
119128
if o.Format != "" {
120129
opts = append(opts, o.Format)
121130
}
@@ -278,6 +287,8 @@ func ParseOptions(str string) Options {
278287
options.FlipHorizontal = true
279288
case opt == optScaleUp: // this option is intentionally not documented above
280289
options.ScaleUp = true
290+
case opt == optPassthroughICC: // this option is intentionally not documented above
291+
options.PassthroughICC = true
281292
case opt == optFormatJPEG, opt == optFormatPNG, opt == optFormatTIFF:
282293
options.Format = opt
283294
case opt == optSmartCrop:

‎icc.go‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright 2013 The imageproxy authors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package imageproxy
5+
6+
import (
7+
"bytes"
8+
"encoding/binary"
9+
"image"
10+
)
11+
12+
// ICC_PROFILE marker segments are identified by this null-terminated string,
13+
// followed by a 1-based chunk number and the total chunk count (ITU-T T.872 B.4).
14+
const iccIdentifier = "ICC_PROFILE\x00"
15+
16+
const (
17+
iccChunkHeaderLen = len(iccIdentifier) + 2 // identifier + seq + count
18+
// A JPEG marker segment payload is limited by its 16-bit length field,
19+
// which counts itself.
20+
maxSegmentPayload = 0xFFFF - 2
21+
maxICCChunk = maxSegmentPayload - iccChunkHeaderLen
22+
23+
// Profiles larger than this are dropped rather than attached: a 500 KB
24+
// LUT profile on an 80px thumbnail costs far more than the color it
25+
// preserves.
26+
maxICCProfileSize = 64 << 10
27+
28+
// Length of the ICC profile header (ICC.1:2010 section 7.2).
29+
iccHeaderSize = 128
30+
)
31+
32+
// extractICCProfile returns the ICC profile embedded in the APP2 segments of
33+
// the JPEG in img, or nil if it carries none. Chunks are reassembled in the
34+
// order declared by their sequence numbers.
35+
func extractICCProfile(img []byte) []byte {
36+
chunks := map[byte][]byte{}
37+
var count byte
38+
39+
forEachJPEGSegment(img, func(marker byte, payload []byte) {
40+
if marker != 0xE2 || len(payload) <= iccChunkHeaderLen {
41+
return
42+
}
43+
if !bytes.HasPrefix(payload, []byte(iccIdentifier)) {
44+
return
45+
}
46+
seq := payload[len(iccIdentifier)]
47+
total := payload[len(iccIdentifier)+1]
48+
if seq == 0 || total == 0 || seq > total {
49+
return
50+
}
51+
if count == 0 {
52+
count = total
53+
} else if total != count {
54+
return // inconsistent chunk set; refuse to guess
55+
}
56+
chunks[seq] = payload[iccChunkHeaderLen:]
57+
})
58+
59+
if count == 0 || len(chunks) != int(count) {
60+
return nil
61+
}
62+
63+
// Chunks are still sub-slices of img at this point. A crafted source can
64+
// declare 255 of them, so measure before allocating rather than build a
65+
// ~16 MB profile for preservesSourceColorSpace to throw away.
66+
size := 0
67+
for _, c := range chunks {
68+
size += len(c)
69+
}
70+
if size > maxICCProfileSize {
71+
return nil
72+
}
73+
74+
profile := make([]byte, 0, size)
75+
for i := byte(1); i <= count; i++ {
76+
profile = append(profile, chunks[i]...)
77+
}
78+
return profile
79+
}
80+
81+
// embedICCProfile returns the JPEG in img with profile written into APP2
82+
// segments immediately after the SOI marker. The rest of img is carried over
83+
// untouched, so the caller must only pass a JPEG that has no ICC segments of
84+
// its own — two chunk sets would leave the profile unreadable. Output from
85+
// Go's jpeg encoder, which writes no APP segments at all, satisfies that.
86+
func embedICCProfile(img, profile []byte) []byte {
87+
if len(profile) == 0 || len(img) < 2 {
88+
return img
89+
}
90+
91+
chunkCount := (len(profile) + maxICCChunk - 1) / maxICCChunk
92+
if chunkCount > 255 {
93+
return img
94+
}
95+
96+
out := make([]byte, 0, len(img)+len(profile)+chunkCount*(iccChunkHeaderLen+4))
97+
out = append(out, img[:2]...) // SOI
98+
99+
for i := 0; i < chunkCount; i++ {
100+
chunk := profile[i*maxICCChunk:]
101+
if len(chunk) > maxICCChunk {
102+
chunk = chunk[:maxICCChunk]
103+
}
104+
out = append(out, 0xFF, 0xE2)
105+
out = binary.BigEndian.AppendUint16(out, uint16(2+iccChunkHeaderLen+len(chunk)))
106+
out = append(out, iccIdentifier...)
107+
out = append(out, byte(i+1), byte(chunkCount))
108+
out = append(out, chunk...)
109+
}
110+
111+
return append(out, img[2:]...)
112+
}
113+
114+
// forEachJPEGSegment walks the marker segments of a JPEG, calling fn for each
115+
// one that carries a payload. It stops at the start of scan, after which the
116+
// entropy-coded data begins.
117+
func forEachJPEGSegment(img []byte, fn func(marker byte, payload []byte)) {
118+
if len(img) < 2 || img[0] != 0xFF || img[1] != 0xD8 {
119+
return
120+
}
121+
122+
for i := 2; i+1 < len(img); {
123+
if img[i] != 0xFF {
124+
return // not at a marker boundary; give up rather than resync
125+
}
126+
marker := img[i+1]
127+
i += 2
128+
129+
switch {
130+
case marker == 0xFF:
131+
i-- // fill byte; the next byte may be the marker
132+
continue
133+
case marker == 0x01, marker >= 0xD0 && marker <= 0xD9:
134+
continue // standalone markers carry no payload
135+
case marker == 0xDA:
136+
return // start of scan
137+
}
138+
139+
if i+2 > len(img) {
140+
return
141+
}
142+
length := int(binary.BigEndian.Uint16(img[i:]))
143+
if length < 2 || i+length > len(img) {
144+
return
145+
}
146+
fn(marker, img[i+2:i+length])
147+
i += length
148+
}
149+
}
150+
151+
// preservesSourceColorSpace reports whether the pixels decoded from a source
152+
// image still live in the color space its embedded profile describes, so that
153+
// re-attaching the profile to the output is correct rather than merely present.
154+
//
155+
// Go's JPEG decoder converts YCbCr to RGB with the fixed JPEG matrix, which is
156+
// color-space agnostic — the resulting triplets stay in the source's primaries.
157+
// A CMYK or YCCK source is different: the decoder converts it to RGB with a
158+
// naive inversion, so the CMYK profile no longer describes the result.
159+
func preservesSourceColorSpace(m image.Image, profile []byte) bool {
160+
// Every ICC profile opens with a 128-byte header, so a shorter blob is
161+
// malformed however plausible its color-space field looks.
162+
if len(profile) < iccHeaderSize || len(profile) > maxICCProfileSize {
163+
return false
164+
}
165+
// Bytes 16..20 of the ICC header hold the data color space signature.
166+
if !bytes.Equal(profile[16:20], []byte("RGB ")) {
167+
return false
168+
}
169+
switch m.(type) {
170+
case *image.CMYK, *image.Gray, *image.Gray16:
171+
return false
172+
}
173+
return true
174+
}

‎icc_test.go‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package imageproxy
2+
3+
import (
4+
"bytes"
5+
"image"
6+
"image/jpeg"
7+
"testing"
8+
)
9+
10+
// syntheticProfile builds an ICC blob of n bytes whose header declares the
11+
// given data color space.
12+
func syntheticProfile(n int, space string) []byte {
13+
p := make([]byte, n)
14+
if n >= 20 {
15+
copy(p[16:20], space)
16+
}
17+
for i := 20; i < n; i++ {
18+
p[i] = byte(i % 251)
19+
}
20+
return p
21+
}
22+
23+
func TestICCRoundTrip(t *testing.T) {
24+
// a minimal JPEG skeleton: SOI, a DQT-ish segment, SOS, EOI
25+
base := []byte{0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x04, 0xAA, 0xBB, 0xFF, 0xDA, 0x00, 0x02, 0xFF, 0xD9}
26+
27+
for _, size := range []int{1, iccHeaderSize, maxICCChunk, maxICCChunk + 1, maxICCProfileSize} {
28+
profile := syntheticProfile(size, "RGB ")
29+
embedded := embedICCProfile(base, profile)
30+
got := extractICCProfile(embedded)
31+
if !bytes.Equal(got, profile) {
32+
t.Errorf("size %d: round trip lost the profile (got %d bytes, want %d)", size, len(got), len(profile))
33+
}
34+
if !bytes.HasSuffix(embedded, base[2:]) {
35+
t.Errorf("size %d: original segments were not preserved", size)
36+
}
37+
}
38+
}
39+
40+
func TestExtractICCProfileRejectsMalformed(t *testing.T) {
41+
tests := map[string][]byte{
42+
"not a jpeg": {0x00, 0x01, 0x02},
43+
"truncated": {0xFF, 0xD8, 0xFF, 0xE2, 0x00},
44+
"length past end": {0xFF, 0xD8, 0xFF, 0xE2, 0xFF, 0xFF, 0x01},
45+
"no icc segment": {0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x02, 0xFF, 0xD9},
46+
}
47+
for name, img := range tests {
48+
if got := extractICCProfile(img); got != nil {
49+
t.Errorf("%s: expected no profile, got %d bytes", name, len(got))
50+
}
51+
}
52+
}
53+
54+
func TestExtractICCProfileRejectsIncompleteChunkSet(t *testing.T) {
55+
base := []byte{0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x02, 0xFF, 0xD9}
56+
full := embedICCProfile(base, syntheticProfile(maxICCChunk+1, "RGB "))
57+
58+
// drop the first APP2 segment, leaving chunk 2 claiming a set of 2
59+
partial := append([]byte{0xFF, 0xD8}, full[2+4+iccChunkHeaderLen+maxICCChunk:]...)
60+
if got := extractICCProfile(partial); got != nil {
61+
t.Errorf("expected no profile from an incomplete chunk set, got %d bytes", len(got))
62+
}
63+
}
64+
65+
func TestExtractICCProfileRejectsOversizedProfile(t *testing.T) {
66+
base := []byte{0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x02, 0xFF, 0xD9}
67+
68+
if got := extractICCProfile(embedICCProfile(base, syntheticProfile(maxICCProfileSize+1, "RGB "))); got != nil {
69+
t.Errorf("expected an oversized profile to be refused before reassembly, got %d bytes", len(got))
70+
}
71+
if got := extractICCProfile(embedICCProfile(base, syntheticProfile(maxICCProfileSize, "RGB "))); len(got) != maxICCProfileSize {
72+
t.Errorf("a profile exactly at the cap should survive, got %d bytes", len(got))
73+
}
74+
}
75+
76+
func TestPreservesSourceColorSpace(t *testing.T) {
77+
rgb := image.NewRGBA(image.Rect(0, 0, 1, 1))
78+
tests := []struct {
79+
name string
80+
m image.Image
81+
p []byte
82+
want bool
83+
}{
84+
{"rgb profile on rgb pixels", rgb, syntheticProfile(512, "RGB "), true},
85+
{"cmyk profile", rgb, syntheticProfile(512, "CMYK"), false},
86+
{"gray source", image.NewGray(image.Rect(0, 0, 1, 1)), syntheticProfile(512, "RGB "), false},
87+
{"cmyk source", image.NewCMYK(image.Rect(0, 0, 1, 1)), syntheticProfile(512, "RGB "), false},
88+
{"oversized profile", rgb, syntheticProfile(maxICCProfileSize+1, "RGB "), false},
89+
{"short of a full icc header", rgb, syntheticProfile(iccHeaderSize-1, "RGB "), false},
90+
{"exactly one icc header", rgb, syntheticProfile(iccHeaderSize, "RGB "), true},
91+
{"no profile", rgb, nil, false},
92+
{"runt profile", rgb, []byte{1, 2, 3}, false},
93+
}
94+
for _, tt := range tests {
95+
if got := preservesSourceColorSpace(tt.m, tt.p); got != tt.want {
96+
t.Errorf("%s: got %v, want %v", tt.name, got, tt.want)
97+
}
98+
}
99+
}
100+
101+
func TestTransformPassthroughICC(t *testing.T) {
102+
profile := syntheticProfile(256, "RGB ")
103+
104+
var buf bytes.Buffer
105+
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 32, 32)), nil); err != nil {
106+
t.Fatal(err)
107+
}
108+
img := embedICCProfile(buf.Bytes(), profile)
109+
if got := extractICCProfile(img); !bytes.Equal(got, profile) {
110+
t.Fatalf("fixture lost its profile before Transform ran")
111+
}
112+
113+
off, err := Transform(img, Options{Width: 16})
114+
if err != nil {
115+
t.Fatal(err)
116+
}
117+
if got := extractICCProfile(off); got != nil {
118+
t.Errorf("PassthroughICC off: expected no profile, got %d bytes", len(got))
119+
}
120+
121+
on, err := Transform(img, Options{Width: 16, PassthroughICC: true})
122+
if err != nil {
123+
t.Fatal(err)
124+
}
125+
if got := extractICCProfile(on); !bytes.Equal(got, profile) {
126+
t.Errorf("PassthroughICC on: got %d profile bytes, want %d", len(got), len(profile))
127+
}
128+
}

0 commit comments

Comments
 (0)