DEV Community

Cover image for Stripping metadata without breaking colour: keep the ICC profile
Sahadat Husain
Sahadat Husain

Posted on

Stripping metadata without breaking colour: keep the ICC profile

Strip EXIF, XMP and IPTC, but never drop the ICC profile unless you convert the pixels to sRGB first. A phone photo in Display P3 that loses its profile is read as sRGB, and the colour goes flat. In my test, Pillow's default save() cut average saturation from 55.3 to 49.4. sharp's default converts to sRGB before it strips, so it stays close at 54.2.

Why does dropping the profile change the colour?

An ICC profile tells the viewer what the numbers in each pixel mean. The same RGB triple is a more saturated green in Display P3 than in sRGB. When the profile goes, every browser falls back to sRGB and reads the P3 numbers as sRGB numbers. Nothing is "lost" in the bytes. The meaning is.

Phone photos often carry a Display P3 profile, like the one below. A "strip all metadata" step that treats the profile like GPS data quietly dulls every one of them.

Two crops of the same leaf photo: the left with its profile kept looks greener, the right with the profile dropped looks duller and yellower

What does each library do by default?

I took a 2250 × 4000 phone photo whose embedded profile is Apple's "Display P3" (548 bytes), resized it to 1200 px wide, and saved it as a quality-80 JPEG in several ways.

sharp first. Its output docs say all metadata is removed by default, and keepIccProfile() keeps the input profile:

import sharp from 'sharp';
import { writeFile } from 'node:fs/promises';

const src = 'p3.jpg';
const base = () => sharp(src).resize({ width: 1200 });
const variants = {
  default: base().jpeg({ quality: 80 }),
  keepIcc: base().keepIccProfile().jpeg({ quality: 80 }),
  srgbTagged: base().withIccProfile('srgb').jpeg({ quality: 80 }),
  ignore: sharp(src, { ignoreIcc: true }).resize({ width: 1200 }).jpeg({ quality: 80 }),
};
for (const [name, pipe] of Object.entries(variants)) {
  const buf = await pipe.toBuffer();
  await writeFile(`${name}.jpg`, buf);
  const m = await sharp(buf).metadata();
  console.log(name.padEnd(11), String(buf.length).padStart(7), 'bytes  icc:', m.icc ? m.icc.length + ' bytes' : 'none');
}
Enter fullscreen mode Exit fullscreen mode
default      323884 bytes  icc: none
keepIcc      321937 bytes  icc: 548 bytes
srgbTagged   324382 bytes  icc: 480 bytes
ignore       321371 bytes  icc: none
Enter fullscreen mode Exit fullscreen mode

Then Pillow. Its JPEG docs are plain about it: without icc_profile, the image is saved with no profile attached. Pillow does not convert the pixels either.

from PIL import Image

im = Image.open("p3.jpg")
icc = im.info.get("icc_profile")
im = im.resize((1200, round(im.height * 1200 / im.width)), Image.LANCZOS)
im.save("pil_default.jpg", quality=80)                    # profile gone
im.save("pil_keep.jpg", quality=80, icc_profile=icc)      # profile kept
Enter fullscreen mode Exit fullscreen mode
pil_default.jpg 325604 bytes  icc: none
pil_keep.jpg    326170 bytes  icc: 548
Enter fullscreen mode Exit fullscreen mode

How do you measure "flat"?

I rendered each file the way a colour-managed viewer does: apply its profile, land in sRGB. Then I took the mean of each channel and an average saturation (max channel minus min channel, per pixel).

import sharp from 'sharp';

async function look(input) {
  const { data } = await input.toColourspace('srgb').raw().toBuffer({ resolveWithObject: true });
  let r = 0, g = 0, b = 0, sat = 0;
  const n = data.length / 3;
  for (let i = 0; i < data.length; i += 3) {
    r += data[i]; g += data[i + 1]; b += data[i + 2];
    sat += Math.max(data[i], data[i + 1], data[i + 2]) - Math.min(data[i], data[i + 1], data[i + 2]);
  }
  return `R ${(r / n).toFixed(1)}  G ${(g / n).toFixed(1)}  B ${(b / n).toFixed(1)}  saturation ${(sat / n).toFixed(1)}`;
}

console.log('source     ', await look(sharp('p3.jpg').resize({ width: 1200 })));
for (const f of ['default', 'keepIcc', 'pil_keep', 'pil_default', 'pil_srgb', 'ignore'])
  console.log(f.padEnd(11), await look(sharp(`${f}.jpg`)));
Enter fullscreen mode Exit fullscreen mode
source      R 102.5  G 123.5  B 71.5  saturation 55.3
default     R 102.5  G 123.5  B 72.7  saturation 54.2
keepIcc     R 102.5  G 123.5  B 71.6  saturation 55.3
pil_keep    R 102.6  G 123.6  B 71.7  saturation 55.4
pil_default R 107.7  G 123.1  B 77.0  saturation 49.4
pil_srgb    R 102.6  G 123.6  B 72.7  saturation 54.3
ignore      R 107.6  G 123.0  B 76.9  saturation 49.4
Enter fullscreen mode Exit fullscreen mode
Pipeline Bytes Profile in file Saturation
Source, resized only 55.3
sharp keepIccProfile() 321,937 Display P3, 548 B 55.3
Pillow icc_profile=icc 326,170 Display P3, 548 B 55.4
sharp default (converts to sRGB) 323,884 none 54.2
Pillow, ImageCms to sRGB 328,215 none 54.3
Pillow save() default 325,604 none 49.4
sharp ignoreIcc: true 321,371 none 49.4

The broken files sit at 49.4, about 11% less saturated, with red and blue pushed up. The sharp default lands at 54.2, a touch below the source. Its conversion to sRGB clips the P3 greens that sRGB cannot hold. That is the honest cost of sRGB, not a bug.

Bar chart of average saturation: 55.3 for the source and both keep-profile saves, 54.2 and 54.3 for the two sRGB conversions, 49.4 for Pillow's default save

Note: The profile costs 548 bytes here, about 0.2% of a 322 KB JPEG. Keeping it is never the reason a file is too big. Stripping it to "save space" is the mistake.

Which fix should you pick?

You have two correct options. Both remove EXIF, XMP and IPTC.

Keep the profile. Wide-gamut screens show the full P3 colour. In sharp, add keepIccProfile(). In Pillow, pass icc_profile=im.info.get("icc_profile") to save().

Convert to sRGB, then drop it. Every screen shows the same colour, and the file needs no profile. sharp's default already does this. In Pillow you do it yourself with ImageCms:

import io
from PIL import Image, ImageCms

im = Image.open("p3.jpg")
icc = im.info.get("icc_profile")
im = im.resize((1200, round(im.height * 1200 / im.width)), Image.LANCZOS)
if icc:
    src = ImageCms.ImageCmsProfile(io.BytesIO(icc))
    dst = ImageCms.createProfile("sRGB")
    im = ImageCms.profileToProfile(im, src, dst, outputMode="RGB")
im.save("pil_srgb.jpg", quality=80)  # no profile needed: the pixels are sRGB now
Enter fullscreen mode Exit fullscreen mode

That file printed 328,215 bytes and saturation 54.3, level with sharp's default.

The one pipeline to avoid is the one that drops the profile and keeps the numbers. In sharp you only get there on purpose, with { ignoreIcc: true }. In Pillow it is the default.

Where does this bite in practice?

  • An upload handler in Python that re-saves every image "to strip GPS" with a bare save().
  • A sharp pipeline someone "fixed" with ignoreIcc: true to silence a colour-space warning.
  • A thumbnail job that copies EXIF by hand and forgets the profile, which lives in its own segment.

A quick check for any pipeline: read the output with sharp(buf).metadata(). If the input had icc and the output has none, the pixels must have been converted. Compare a saturation number like the one above to be sure.

I run the same kind of before-and-after check on every number Blinkimg publishes; how we measure lists the method.

How I measured

  • Machine: Apple M1 Pro, macOS, 2026-10-17.
  • Node v24.22.0, sharp 0.35.4, libvips 8.18.6, lcms 2.19.1.
  • Python 3.9.6, Pillow 11.3.0 in a fresh venv.
  • Source: a 2250 × 4000 phone JPEG from the Blinkimg image library, embedded profile "Display P3" (Apple, 548 bytes), no EXIF.
  • Every output: resized to 1200 px wide, JPEG quality 80. Saturation is computed after applying each file's profile and converting to sRGB with sharp.

What I did not test

PNG, WebP and AVIF outputs, 16-bit sources, and CMYK profiles. I did not measure how browsers on non-P3 screens render the P3 file.


I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

Top comments (0)