DEV Community

yue xing
yue xing

Posted on

Why HEIC to JPG can look washed out: same numbers, wrong color space

Converting a format is supposed to re-encode the pixels, not repaint them, yet a HEIC converted to JPG can come out looking duller than the original. So where would a color shift even come from? On 2026-11-06 I built a small test on an Apple M4 Mac to find out: two HEIC files made with macOS sips, one from a synthetic image with 12 color swatches and one from a CC0 photo on Wikimedia Commons, both tagged as Display P3. Every number below is from that machine, one run each.

An RGB triple is not a color yet

The part I was missing is that a JPG mostly stores numbers. Three bytes per pixel say "189, 124, 88", and something else has to say what those numbers mean. That something is the ICC profile, a small block inside the file (536 bytes in my test HEIC) that tells the viewer which color space the numbers live in. Display P3 has a wider range than sRGB, so the same visible color gets different numbers in each space. In my synthetic file the skin-tone swatch is stored as P3 [189,124,88]. Converted properly to sRGB, the same color becomes [199,120,81].

Now imagine the profile gets lost on the way to JPG while the numbers stay as they were. A browser that finds no profile assumes sRGB, so it paints [189,124,88] as an sRGB color. That color is less red and less saturated than the skin tone in the original. This is the whole washed-out effect: right numbers, wrong label.

Three ways to write the JPG gave three results

To put a size on it, I wrote each test file out three ways and compared every swatch against the original with CIEDE2000, a color-difference formula where a common rule of thumb says values below about 1 are hard to see and 2 to 3 start to show when two images sit side by side. That rule is a convention I'm borrowing, not something I measured.

Way What the JPG carries sRGB-range swatches, max ΔE2000 P3-only saturated swatches, max ΔE2000
A. Keep the P3 profile P3 numbers + P3 profile 0.31 0.40
B. Drop the profile, don't convert P3 numbers, no profile 3.33 6.98
C. Convert to sRGB sRGB numbers 0.37 7.55

Way B is the faded one. Sky blue moved by 2.32, skin by 3.24 and deep green by 3.33, all colors that sRGB could have shown exactly. On the photo, where every color already fits inside sRGB, the average pixel moved by 1.94 and 95% of pixels stayed within 3.39. The row that surprised me was C. Converting to sRGB looks like the "safe" choice, and for normal colors it is, but the saturated P3 orange still ended up 7.55 away and the P3 red 6.91. That is not a bug in the conversion. sRGB simply has no number for those colors, so they get clipped to the nearest edge. If keeping them matters, only way A does it.

Where way B sneaks into real code

Pillow 11.3 can't open HEIC without an extra plugin, so I fed it a PNG decoded by sips that still carried the P3 profile. Its default save() writes the JPG without the profile and without converting, which is exactly way B. The fix is one argument. Below is a simplified sketch of the two calls; the numbers in the table come from the full test script:

import PIL.Image as Image
pic = Image.open("from_heic.png")          # P3 numbers + P3 profile
pic.save("faded.jpg", quality=90)          # profile dropped, way B
pic.save("kept.jpg", quality=90, icc_profile=pic.info.get("icc_profile"))
Enter fullscreen mode Exit fullscreen mode

Default save() also drops EXIF, so capture time and GPS are gone unless you pass them back too. sharp 0.34.5 behaves differently: by default it converts to sRGB and then drops the profile, which is way C, so ordinary colors come out right. keepIccProfile() keeps P3, keepMetadata() keeps P3 plus EXIF and GPS, and withMetadata() converts to sRGB and embeds a 480-byte sRGB profile. Browsers were the calm case: Chromium 149 couldn't decode HEIC at all and WebKit 26.5 could, but both color-manage while decoding, so a canvas export never faded in this test. In an earlier test on 2026-09-25, libheif handed back raw P3 numbers with no color management, and writing those without a profile gave 2.36 to 3.33, close to way B's 2.32 to 3.33.

For one-off conversions in a desktop browser I use ImgIng https://imging.ai/ , which reads the HEIC locally and converts one file at a time. Its JPG in this test kept the Display P3 profile, the capture time and the GPS, and the swatches stayed within 0.31. macOS sips with its default settings landed in the same place.

What my test files did not cover

My two HEIC files embed a full ICC profile because sips wrote them that way. Real iPhone photos often describe their color with a shorter nclx tag instead, and they can be 10-bit with an HDR gain map. I had no straight-from-iPhone file to try, so I honestly don't know whether every tool above treats nclx the same way it treated my embedded profile. When a converted photo looks grayer than the original, a missing profile is the first thing worth checking, but it is a clue rather than proof: sharp's default JPG carries no profile either, and its colors are right because the numbers were already converted. Comparing one known swatch against the source settles it.

Top comments (0)