Skip to content

Build1 publisher3 min readPublished

Pillow 11.3 drops the ICC profile on JPEG and WebP saves at any quality setting

A single test on a Display P3 photo found the stripped copy looked duller with every pixel value unchanged, and re-saving at quality 100 recovered nothing; the frame mean moved under 3 ΔE while the most saturated pixels moved 5.4.

The Engineer · Build desk

Illustration accompanying Pillow 11.3 drops the ICC profile on JPEG and WebP saves at any quality setting

What happened

  • The test wrote three quality-92 JPEGs from one CC0 iPhone 6 photo resized to 2048x1536, tagging one sRGB and converting the others to Display P3 and Adobe RGB (1998) with Pillow 11.3's ImageCms.
  • ffmpeg 7.1 kept the profile converting to JPEG or PNG and dropped it converting to WebP, and macOS sips kept it through PNG, -Z resizing and a quality 60 re-save.
  • The Playwright-bundled Firefox 151 build rendered tagged and stripped files the same and exported an untagged JPEG, while Chromium 149 and WebKit 26.5 honored the profile and converted canvas pixels to sRGB.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • cost A team that answers fade by raising the quality knob pays extra bytes on every thumbnail it will ever serve and recovers none of the saturation, because compression did not take it.
  • constraint With a Pillow resize into WebP as the default recipe, color management in that pipeline exists only where someone wrote icc_profile= by hand.
  • exposure An audit built on sips -g profile will sign off a pipeline that is stripping profiles, because the command reports sRGB for files that embed nothing.
  • decision Pass-through or convert-to-sRGB is still an open choice: one preserves the source gamut, the other survives the next tool that strips tags, and a sample that fits inside sRGB cannot separate them.

A viewer with no profile to read assumes sRGB [5]. Stripping the tag does not change the RGB numbers, so P3 or Adobe RGB values read as sRGB come out less saturated [5]. Compression damage looks different. It shows up as soft edges, banding across smooth sky, and 8x8 blocks when you zoom in [6]. The split is diagnostic: blocky or blurred output implicates compression, and a uniform fade with detail intact implicates the profile [7].

The frame average understates the damage. Measured in CIELAB, the mean ΔE76 between each tagged file and the same pixels read as sRGB came in under 3, and grays, whites and shadows barely moved [8]. The most saturated 5 percent of pixels moved 5.4 with Display P3 and 7.2 with Adobe RGB [9]. Divide by that mean ceiling and the tail moves at least 1.8 times as far with P3, and at least 2.4 times with Adobe RGB [10]. In this photo the shift lands on the red and green tail livery and the blue sky going pale [9].

Pillow 11.3 drops the profile when it saves JPEG or WebP, and calling `resize()` or `thumbnail()` first does not change that; PNG output keeps it, and quality 100 changes nothing [11]. ffmpeg 7.1 keeps the profile converting to JPEG or PNG and drops it converting to WebP [12]. macOS `sips` kept it through PNG, `-Z` resizing and a quality 60 re-save [13]. A Pillow resize into WebP output, which the author calls probably the most common thumbnail recipe, hits the problem twice [14].

Four lines close the Pillow path. Read `icc = thumb.info.get("icc_profile")` before the resize, then pass `icc_profile=icc` into `save()`; the same call returns None when the source is untagged [15]. Run against the P3 sample, both the JPEG and the WebP output read back as Display P3 [16]. The other route is converting to sRGB with `ImageCms.profileToProfile` before saving, which survives any later step that strips tags, and the author has not settled which to use for thumbnails and has no wide-gamut data to judge what the conversion clips [17].

Verification has its own trap. `sips -g profile` reports sRGB IEC61966-2.1 for a JPEG with no embedded profile at all, which is a helpful answer to a question nobody asked. It cannot separate untagged from genuinely sRGB, so the author checks `Image.open(path).info.get("icc_profile")` instead [18].

On macOS 26.5 with Playwright's bundled engines, Chromium 149 and WebKit 26.5 honored the profile in `<img>`, and pixels read back from a canvas were already converted to sRGB, with mean differences from the sRGB source of 0.66 and 0.69 [19]. Chromium embeds an sRGB profile in its `toBlob('image/jpeg')` output, and WebKit's JPEG has none, which is correct because the pixels are already sRGB [20]. The Firefox 151 build bundled with Playwright rendered the tagged and stripped files the same, mean screenshot difference 0.03, read back unconverted P3 values, and exported an untagged JPEG that looks washed out [21]. The write-up calls that one build with default settings, not a statement about release Firefox [21]. A has-profile check on browser exports can therefore mislead, since WebKit's untagged JPEG is fine [22].

The evidence here does not show that fade is usually a stripped profile. The first question in the author's checklist is what the source carries, because if it is sRGB or absent then a dropped profile cannot explain the fade and compression is where to look [23]. Apple's developer documentation notes that iPhone 7 and later can capture in P3 [24], so a consumer upload path will see tagged files. One locally converted image whose colors sit inside sRGB is not a base rate [4]. Quality settings and client-side export become suspects only after both source and output check out as tagged [25].

What to watch

  • A wide-gamut sample would show what ImageCms.profileToProfile clips before saving; the author does not have one.
  • Whether release Firefox behaves like the Playwright-bundled 151 build, which is the only Firefox in this test.
  • A Pillow release that carries icc_profile through save() by default would retire the four-line workaround.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories