Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

Three color checkpoints for HEIC to JPEG, and why sips -g profile isn't one

Why do some HEIC-to-JPEG conversions come out washed out? The measurements I ran on 2026-11-06 pointed to three checks rather than to a library. Is there an ICC profile after decode, does the encoder carry it or convert

Why do some HEIC-to-JPEG conversions come out washed out? The measurements I ran on 2026-11-06 pointed to three checks rather than to a library. Is there an ICC profile after decode, does the encoder carry it or convert the pixels, and what does your inspector actually read. The third is the easiest to get wrong.

Scope first. Three HEIC files were encoded with macOS sips from a synthetic chart and a CC0 Wikimedia Commons photo; the two used here are tagged Display P3 with a 536-byte ICC. They are not iPhone originals: no HDR gain map, no 10-bit, no nclx. One Apple M4 Mac, single runs, Pillow 11.3, sharp 0.34.5, Chromium 149 and WebKit 26.5. Color difference is CIEDE2000 on 12 patches and the full photo; "below 1 is hard to see, 2 to 3 shows side by side" is a rule of thumb, not something I measured.

The decoder decides what you are holding

After decode you have P3 numbers plus a profile, P3 numbers with nothing attached, or pixels already converted to sRGB, and mixing those up is how a file ends up washed out. Browser canvases convert at decode time. WebKit 26.5 decoded the HEIC through the macOS decoder and handed over sRGB pixels; Chromium 149 couldn't decode HEIC at all, and given a P3 PNG it converted to sRGB too. sharp 0.34.5 from npm read the metadata fine (ICC 536 bytes, EXIF present), then failed on the pixels with "Support for this compression format has not been built in", since the prebuilt binary only ships AVIF decoding. The risky path is raw values out of libheif or a WASM build: P3 numbers, and the profile is your job. I didn't rerun libheif this time; a September run showed exactly that.

The encoder either carries the profile or converts the pixels

Pillow's plain save() writes the JPEG without the ICC and leaves the numbers alone. That was the only place the washed-out look showed up. The P3 skin patch [189,124,88], shown as sRGB, lands ΔE2000 3.24 off (a correct conversion gives [199,120,81]). Sky blue came out at 2.32, deep green at 3.33, saturated P3 pure red at 6.98. On the photo, all inside sRGB, the mean was 1.94 and the 95th percentile 3.39.

The two correct options part ways at the gamut edge. Carrying P3 (sips default, Pillow with icc_profile passed, sharp keepIccProfile()) kept in-gamut patches at 0.31 or below and saturated ones at 0.40 or below. Converting to sRGB kept in-gamut patches at 0.37 or below, but saturated P3 colors still read up to 7.55 off because sRGB can't hold them. That's clipping, not a bug.

sharp's switches also decide metadata. The default converts to sRGB and drops ICC and EXIF; keepIccProfile() keeps P3, drops EXIF; keepMetadata() keeps P3, EXIF and GPS; withMetadata() converts to sRGB and embeds a 480-byte sRGB profile plus EXIF. Canvas exports in both engines lost EXIF and GPS.

The inspector may be reporting a default

sips --matchTo with the system sRGB profile produced correctly converted pixels, and sips -g profile said sRGB IEC61966-2.1. The file carried no ICC at all; sips had set ColorSpace to 1 in EXIF, and its query shows sRGB when nothing is embedded. sharp's default output reads the same way. Harmless for those two, since browsers treat untagged JPEG as sRGB. Pillow's untagged P3 file, the broken one, got the same sRGB IEC61966-2.1 line, so one answer covered a correct file and a wrong one. And sips -d profile, which sounds like it strips the ICC, produced output byte-identical to the default, P3 profile still inside.

Reading the bytes avoids both traps. This is a stripped-down sketch written for this post; the numbers above came from the full measurement run.

const fs = require('fs');
function embeddedIcc(jpegPath) {
  const buf = fs.readFileSync(jpegPath);
  let pos = 2, iccSize = 0;
  while (pos + 4 < buf.length && buf[pos] === 0xFF) {
    const marker = buf[pos + 1], segLen = buf.readUInt16BE(pos + 2);
    if (marker === 0xDA) break; // start of scan
    if (marker === 0xE2 && buf.toString('latin1', pos + 4, pos + 15) === 'ICC_PROFILE')
      iccSize += segLen - 2 - 14; // length field, id string, chunk bytes
    pos += 2 + segLen;
  }
  return iccSize;
}
for (const f of process.argv.slice(2)) console.log(f, embeddedIcc(f));

536 means P3 was carried, 480 is sharp's sRGB tag, and 0 means the file can't tell you what its pixels are.

For comparison I used ImgIng https://imging.ai/ in a desktop browser on the same Mac, one HEIC at a time. Three conversions went through with no upload request, and the JPEGs kept the Display P3 profile, capture time and GPS, all 12 patches at 0.31 or below. How it decodes I can't say; that converter isn't a part I worked on. The sips default landed in the same place.

A real iPhone file would test the first check harder

What's missing is an iPhone original. Those often describe color with nclx rather than an embedded profile, and HDR gain maps and 10-bit data make the first check harder in ways these sips-made files can't show. Until one goes through the same run, the byte check is the cheapest of the three to add to a pipeline.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.