Skip to content

Commit

Permalink
Force conversion to sRGB for CMYK images with ICC profile
Browse files Browse the repository at this point in the history
CMYK is not supported by PNG, WEBP, AVIF and HEIC Pillow encoders.

When a CMYK image is encoded, it is converted to RGB but this conversion
results in inaccurate colors because Pillow ignores the ICC profile when
performing the conversion.

As a workaround, we manually force an accurate conversion to RGB _before_
encoding the image. This results in a much more accurate representation
of the original CMYK image.
  • Loading branch information
Stormheg committed Jan 14, 2024
1 parent d53ec64 commit 4660b95
Show file tree
Hide file tree
Showing 2 changed files with 162 additions and 11 deletions.
99 changes: 99 additions & 0 deletions tests/test_pillow.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

no_webp_support = not PillowImage.is_format_supported("WEBP")
no_avif_support = not PillowImage.is_format_supported("AVIF")
no_heif_support = not PillowImage.is_format_supported("HEIF")


class TestPillowOperations(unittest.TestCase):
Expand Down Expand Up @@ -567,6 +568,104 @@ def test_save_as_webp(self):
mock_optimize.assert_not_called()


class TestPillowCMYKImageAutomaticSRGBTransformOnSave(unittest.TestCase):
"""Test that CMYK images with a color profile are converted to sRGB when saved as anything other than JPEG."""

EXPECTED_PROFILE_DESCRIPTION = "sRGB built-in"

def setUp(self):
with open("tests/images/dog_and_lake_cmyk_with_icc_profile.jpg", "rb") as f:
self.image = PillowImage.open(JPEGImageFile(f))

self.assertEqual(self.image.image.mode, "CMYK")

def test_save_as_jpeg(self):
output = io.BytesIO()
self.image.transform_colorspace_to_srgb = mock.Mock(
wraps=self.image.transform_colorspace_to_srgb
)
return_value = self.image.save_as_jpeg(output)

image = PillowImage.open(return_value)

# JPEG is special. It is the only format that Pillow supports saving in CMYK mode.
# Thus we don't need to convert the image to sRGB.
self.assertEqual(image.image.mode, "CMYK")
self.image.transform_colorspace_to_srgb.assert_not_called()

# The JPEG should have the original ICC profile embedded
self.assertEqual(image.get_icc_profile(), self.image.get_icc_profile())

@unittest.skipIf(no_webp_support, "Pillow does not have WebP support")
def test_save_as_webp(self):
self.image.transform_colorspace_to_srgb = mock.Mock(
wraps=self.image.transform_colorspace_to_srgb
)
return_value = self.image.save_as_webp(io.BytesIO())

image = PillowImage.open(return_value)
self.image.transform_colorspace_to_srgb.assert_called_once()
self.assertEqual(image.image.mode, "RGB")

# The expected ICC profile should be embedded
cms_profile = ImageCms.ImageCmsProfile(io.BytesIO(image.get_icc_profile()))
self.assertEqual(
cms_profile.profile.profile_description, self.EXPECTED_PROFILE_DESCRIPTION
)

@unittest.skipIf(no_avif_support, "Pillow does not have AVIF support")
def test_save_as_avif(self):
self.image.transform_colorspace_to_srgb = mock.Mock(
wraps=self.image.transform_colorspace_to_srgb
)
return_value = self.image.save_as_avif(io.BytesIO())

image = PillowImage.open(return_value)

self.image.transform_colorspace_to_srgb.assert_called_once()
self.assertEqual(image.image.mode, "RGB")

# The expected ICC profile should be embedded
cms_profile = ImageCms.ImageCmsProfile(io.BytesIO(image.get_icc_profile()))
self.assertEqual(
cms_profile.profile.profile_description, self.EXPECTED_PROFILE_DESCRIPTION
)

@unittest.skipIf(no_heif_support, "Pillow does not have HEIC support")
def test_save_as_heic(self):
self.image.transform_colorspace_to_srgb = mock.Mock(
wraps=self.image.transform_colorspace_to_srgb
)
return_value = self.image.save_as_heic(io.BytesIO())

image = PillowImage.open(return_value)

self.image.transform_colorspace_to_srgb.assert_called_once()
self.assertEqual(image.image.mode, "RGB")

# The expected ICC profile should be embedded
cms_profile = ImageCms.ImageCmsProfile(io.BytesIO(image.get_icc_profile()))
self.assertEqual(
cms_profile.profile.profile_description, self.EXPECTED_PROFILE_DESCRIPTION
)

def test_save_as_png(self):
self.image.transform_colorspace_to_srgb = mock.Mock(
wraps=self.image.transform_colorspace_to_srgb
)
return_value = self.image.save_as_png(io.BytesIO())
image = PillowImage.open(return_value)

self.image.transform_colorspace_to_srgb.assert_called_once()
self.assertEqual(image.image.mode, "RGB")

# The expected ICC profile should be embedded
cms_profile = ImageCms.ImageCmsProfile(io.BytesIO(image.get_icc_profile()))
self.assertEqual(
cms_profile.profile.profile_description, self.EXPECTED_PROFILE_DESCRIPTION
)


class TestPillowImageOrientation(unittest.TestCase):
def assert_orientation_landscape_image_is_correct(self, image):
# Check that the image is the correct size (and not rotated)
Expand Down
74 changes: 63 additions & 11 deletions willow/plugins/pillow.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,19 +278,32 @@ def save_as_png(self, f, optimize: bool = False, apply_optimizers: bool = True):
:param apply_optimizers: controls whether to run any configured optimizer libraries
:return: PNGImageFile
"""

kwargs = {}
image = self.image
icc_profile = self.get_icc_profile()
if icc_profile is not None:
# If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
# about how we handle the color conversion to RGB. We don't want to retain
# the color profile as-is because it is not meant for RGB images and
# will result in inaccurate colors. The transformation to sRGB should result
# in a more accurate representation of the original image, though
# it will likely not be perfect.
if self.image.mode == "CMYK":
pillow_image = self.transform_colorspace_to_srgb()
image = pillow_image.image
kwargs["icc_profile"] = pillow_image.get_icc_profile()
else:
kwargs["icc_profile"] = icc_profile

if self.image.mode == "CMYK":
image = self.image.convert("RGB")
else:
image = self.image
# Pillow only checks presence of optimize kwarg, not its value
kwargs = {}
if optimize:
kwargs["optimize"] = True

icc_profile = self.get_icc_profile()
if icc_profile is not None:
kwargs["icc_profile"] = icc_profile

exif_data = self.get_exif_data()
if exif_data is not None:
kwargs["exif"] = exif_data
Expand Down Expand Up @@ -340,11 +353,24 @@ def save_as_webp(

kwargs = {"quality": quality, "lossless": lossless}

image = self.image
icc_profile = self.get_icc_profile()
if icc_profile is not None:
kwargs["icc_profile"] = icc_profile
# If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
# about how we handle the color space. WEBP will encode as RGB so we need to do extra
# work to ensure the colors are as accurate as possible. We don't want to retain
# the color profile as-is because it is not meant for RGB images and
# will result in inaccurate colors. The transformation to sRGB should result
# in a more accurate representation of the original image, though
# it will likely not be perfect.
if image.mode == "CMYK":
pillow_image = self.transform_colorspace_to_srgb()
image = pillow_image.image
kwargs["icc_profile"] = pillow_image.get_icc_profile()
else:
kwargs["icc_profile"] = icc_profile

self.image.save(f, "WEBP", **kwargs)
image.save(f, "WEBP", **kwargs)
if apply_optimizers and not lossless:
self.optimize(f, "webp")
return WebPImageFile(f)
Expand All @@ -371,11 +397,24 @@ def save_as_heic(
if lossless:
kwargs = {"quality": -1, "chroma": 444}

image = self.image
icc_profile = self.get_icc_profile()
if icc_profile is not None:
kwargs["icc_profile"] = icc_profile
# If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
# about how we handle the color space. HEIC will encode as RGB so we need to do extra
# work to ensure the colors are as accurate as possible. We don't want to retain
# the color profile as-is because it is not meant for RGB images and
# will result in inaccurate colors. The transformation to sRGB should result
# in a more accurate representation of the original image, though
# it will likely not be perfect.
if image.mode == "CMYK":
pillow_image = self.transform_colorspace_to_srgb()
image = pillow_image.image
kwargs["icc_profile"] = pillow_image.get_icc_profile()
else:
kwargs["icc_profile"] = icc_profile

self.image.save(f, "HEIF", **kwargs)
image.save(f, "HEIF", **kwargs)

if not lossless and apply_optimizers:
self.optimize(f, "heic")
Expand All @@ -388,11 +427,24 @@ def save_as_avif(self, f, quality=80, lossless=False, apply_optimizers=True):
if lossless:
kwargs = {"quality": -1, "chroma": 444}

image = self.image
icc_profile = self.get_icc_profile()
if icc_profile is not None:
kwargs["icc_profile"] = icc_profile
# If the image is in CMYK mode *and* has an ICC profile, we need to be more diligent
# about how we handle the color space. AVIF will encode as RGB so we need to do extra
# work to ensure the colors are as accurate as possible. We don't want to retain
# the color profile as-is because it is not meant for RGB images and
# will result in inaccurate colors. The transformation to sRGB should result
# in a more accurate representation of the original image, though
# it will likely not be perfect.
if image.mode == "CMYK":
pillow_image = self.transform_colorspace_to_srgb()
image = pillow_image.image
kwargs["icc_profile"] = pillow_image.get_icc_profile()
else:
kwargs["icc_profile"] = icc_profile

self.image.save(f, "AVIF", **kwargs)
image.save(f, "AVIF", **kwargs)

if not lossless and apply_optimizers:
self.optimize(f, "heic")
Expand Down

0 comments on commit 4660b95

Please sign in to comment.