The same Display P3 image went through Pillow 11.3 and sharp 0.34.5 with no color options set, and the two JPEGs went wrong in opposite directions. Pillow's copy came out washed out. sharp's copy was right for everyday colors and quietly flattened the most saturated ones. Neither library has a bug here. Each one simply defaults to a different target, and an image pipeline inherits that target unless somebody picks one on purpose.
The test material was two HEIC files made with macOS sips, not iPhone originals: a synthetic chart with 12 color patches, and a CC0 photo from Wikimedia Commons converted into Display P3. Both embed a 536-byte P3 profile. Everything ran once on an Apple M4 Mac, both libraries encoded JPEG at quality 90, color difference is CIEDE2000, and sizes are plain bytes. Neither library decoded the HEIC itself. Pillow has no HEIC reader without the pillow-heif plugin (an earlier run on 2026-09-15 got "cannot identify image file"). The prebuilt sharp could read the HEIC metadata but failed at the decode step. So both libraries got the same PNG decoded by sips, carrying P3 pixel values and the P3 profile.
Pillow keeps the numbers and loses the profile
A plain save() without icc_profile writes the P3 numbers into the JPEG unchanged and leaves the profile out. A browser treats an untagged JPEG as sRGB, and that is how every ΔE below was computed. A skin patch stored as P3 [189,124,88] should become [199,120,81] in sRGB. Shown as-is it lands 3.24 away, sky blue 2.32, and deep green 3.33 (P3 [55,108,65], which should be [30,109,61]). The saturated P3 patches drift further, up to 6.98 on pure red. On the photo the average was 1.94 and the 95th percentile 3.39, a milder shift because every color in that photo sits inside sRGB. The default also drops EXIF, so capture time and GPS are gone as well.
sharp converts to sRGB and drops the tag
sharp's default does the color work first. Pixels are converted to sRGB, then the profile and EXIF are both left out. Because an untagged JPEG is assumed to be sRGB anyway, every in-gamut patch stayed within 0.37. The cost sits outside sRGB: the worst saturated patch, P3 orange, ended 7.55 away. That is clipping, not a conversion error. sRGB has no room for those colors, so the only way to keep them is to keep P3.
Pick the target before you pick the option
Below is a condensed sketch of the calls being compared, with placeholder file names. It is not the script that produced the numbers; every figure in this post comes from the full measurement runs.
import PIL.Image as Image, PIL.ImageCms as ImageCms
import io
img = Image.open("decoded_p3.png")
p3 = img.info.get("icc_profile")
img.save("keep_p3.jpg", quality=90, icc_profile=p3)
srgb = ImageCms.createProfile("sRGB")
web = ImageCms.profileToProfile(img, ImageCms.ImageCmsProfile(io.BytesIO(p3)), srgb, outputMode="RGB")
web.save("tagged_srgb.jpg", quality=90, icc_profile=ImageCms.ImageCmsProfile(srgb).tobytes())
import sharp from 'sharp';
await sharp('decoded_p3.png').keepIccProfile().jpeg({ quality: 90 }).toFile('keep_p3.jpg');
await sharp('decoded_p3.png').withMetadata().jpeg({ quality: 90 }).toFile('tagged_srgb.jpg');
If the JPEG ends up in browsers or other color-managed viewers, keeping P3 is the better default: Pillow with icc_profile=, sharp with keepIccProfile(). That path stayed within 0.31 in gamut and 0.40 on the saturated patches. It is also cheap. Pillow at quality 90 wrote 443,673 bytes with the profile and 443,119 without it on the chart, 536,468 and 535,914 on the photo, so the profile adds 554 bytes in both files.
If the JPEG goes somewhere that might ignore or strip profiles, converting to sRGB is the safer target, and tagging it costs little. sharp's withMetadata() does that with a 480-byte sRGB profile. Watch what else it brings back, though. withMetadata() restores EXIF including GPS, and so does keepMetadata(), which keeps P3 plus EXIF with the GPS block. keepIccProfile() keeps the color and leaves EXIF out, which is the combination I'd want for anything user-uploaded.
For one-off files with no code at all, ImgIng https://imging.ai/ converts HEIC to JPEG in a desktop browser, one file at a time. In this test its three conversions sent no upload request, and the JPEGs kept the Display P3 profile, capture time and GPS, with every patch within 0.31. That puts it on the keep-P3 path. How it decodes internally I can't see from outside.
Measured here and only inferred
Measured: the two defaults above, the non-default options, the byte sizes and the ΔE values, on two sips-made files, one run each. Also measured: the prebuilt sharp failed to decode HEIC here. Pillow's "cannot identify image file" comes from the earlier 2026-09-15 run, not from this one.
Inferred, not tested: an iPhone original may carry its color space as nclx rather than as an ICC block, and its pixel depth and HDR data differ from these files, so the Pillow path could behave differently on it. A photo with real out-of-sRGB colors would likely show a bigger Pillow-default shift than this photo did, since all of its colors sat inside sRGB. Whether a particular CDN or upload service strips profiles is something I didn't test, which is exactly why the tagged-sRGB choice exists.
Top comments (0)