Skip to content

Commit

Permalink
Merge branch 'main' into feature/ico-support
Browse files Browse the repository at this point in the history
  • Loading branch information
zerolab authored Jan 16, 2024
2 parents 9aee8ba + 9565d94 commit 9f05aec
Show file tree
Hide file tree
Showing 8 changed files with 345 additions and 61 deletions.
45 changes: 23 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,28 +61,29 @@ As neither Pillow nor Wand support detecting faces, Willow would automatically c

[Documentation](https://willow.readthedocs.org/en/latest/reference.html#builtin-operations)

| Operation | Pillow | Wand | OpenCV |
|-----------------------------------------|--------|------|--------|
| `get_size()` ||||
| `get_frame_count()` |** ||** |
| `resize(size)` ||| |
| `crop(rect)` ||| |
| `rotate(angle)` ||| |
| `set_background_color_rgb(color)` ||| |
| `auto_orient()` ||| |
| `save_as_jpeg(file, quality)` ||| |
| `save_as_png(file)` ||| |
| `save_as_gif(file)` ||| |
| `save_as_webp(file, quality)` ||| |
| `save_as_ico(file)` ||| |
| `save_as_heif(file, quality, lossless)` | ✓⁺ | | |
| `save_as_avif(file, quality, lossless)` | ✓⁺ | ✓⁺ | |
| `has_alpha()` |||* |
| `has_animation()` |* ||* |
| `get_pillow_image()` || | |
| `get_wand_image()` | || |
| `detect_features()` | | ||
| `detect_faces(cascade_filename)` | | ||
| Operation | Pillow | Wand | OpenCV |
| ------------------------------------------------ | ------ | ---- | ------ |
| `get_size()` ||||
| `get_frame_count()` |\*\* ||\*\* |
| `resize(size)` ||| |
| `crop(rect)` ||| |
| `rotate(angle)` ||| |
| `set_background_color_rgb(color)` ||| |
| `transform_colorspace_to_srgb(rendering_intent)` || | |
| `auto_orient()` ||| |
| `save_as_jpeg(file, quality)` ||| |
| `save_as_png(file)` ||| |
| `save_as_gif(file)` ||| |
| `save_as_webp(file, quality)` ||| |
| `save_as_heif(file, quality, lossless)` | ✓⁺ | | |
| `save_as_avif(file, quality, lossless)` | ✓⁺ | ✓⁺ | |
| `save_as_ico(file)` ||| |
| `has_alpha()` |||\* |
| `has_animation()` |\* ||\* |
| `get_pillow_image()` || | |
| `get_wand_image()` | || |
| `detect_features()` | | ||
| `detect_faces(cascade_filename)` | | ||

\* Always returns `False`

Expand Down
41 changes: 41 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,47 @@ Here's a full list of operations provided by Willow out of the box:
# Set the background color of the image to white
image = source_image.set_background_color_rgb((255, 255, 255))
.. method:: transform_colorspace_to_srgb(rendering_intent=0)

(Pillow only)

Note: This operation has no effect if the image does not have an embedded ICC color profile.

Transforms the colors of the image to fit inside sRGB color gamut using data
from the embedded ICC profile. The resulting image will always be in RGB format
(or RGBA for images with transparency) and will have a small generic sRGB
ICC profile embedded.

A large number of devices lack the capability to display images
in color spaces other than sRGB and will automatically squash the colors
to fit inside sRGB gamut. In order to do this accurately, the device uses
the embedded ICC profile. You can use this operation to do the same thing
upfront and save on image size by replacing the (large) embedded profile with a
small generic sRGB profile. Keep in mind that this operation is lossy, devices
that *do* support wider color gamuts, like DCI-P3 or Adobe RGB, will not be
able to display the image in its original colors if the original colors were
outside of sRGB gamut.

The ``rendering_intent`` parameter specifies the rendering intent to use.
It defaults to 0 (perceptual). This controls how out-of-gamut colors are handled.

It can be one of the following values:

* ``0`` - Perceptual (default)
* ``1`` - Relative colorimetric
* ``2`` - Saturation
* ``3`` - Absolute colorimetric

.. code-block:: python
image = image.transform_colorspace_to_srgb()
`Read more about rendering intents on Wikipedia
<https://en.wikipedia.org/wiki/Rendering_intent>`_.

`Read more about color spaces on the web in this WebKit blog post
<https://webkit.org/blog/6682/improving-color-on-the-web/>`_.

.. method:: auto_orient()

(Pillow/Wand only)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/images/transparent_with_icc_profile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 0 additions & 22 deletions tests/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
from unittest import mock
from xml.etree.ElementTree import ParseError as XMLParseError

import filetype

from willow.image import (
AvifImageFile,
BMPImageFile,
Expand Down Expand Up @@ -327,23 +325,3 @@ def test_optimize_with_an_actual_file(
self.image.optimize(f, "jpeg")
mock_process.assert_called_with("tests/images/people.jpg")
mock_unlink.assert_not_called()


class TestImghdrJPEGPatch(unittest.TestCase):
def test_detects_photoshop3_jpeg(self):
f = io.BytesIO()
f.write(b"\xff\xd8\xff\xed\x00,Photoshop 3.0\x00")
f.seek(0)

image_format = filetype.guess_extension(f)

self.assertEqual(image_format, "jpg")

def test_junk(self):
f = io.BytesIO()
f.write(b"Not an image")
f.seek(0)

image_format = filetype.guess_extension(f)

self.assertIsNone(image_format)
3 changes: 2 additions & 1 deletion tests/test_opencv.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,5 @@ def test_detect_faces(self):
self.assertIsInstance(faces, list)
# There are two faces in the image
self.assertEqual(len(faces), 2)
assert_allclose(faces, self.expected_faces, atol=2)
# Sort out list as there is no preferred order of the faces
assert_allclose(sorted(faces), sorted(self.expected_faces), atol=2)
163 changes: 162 additions & 1 deletion tests/test_pillow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import filetype
from PIL import Image as PILImage
from PIL import ImageChops
from PIL import ImageChops, ImageCms, ImageStat

from willow.image import (
AvifImageFile,
Expand All @@ -22,6 +22,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 @@ -125,6 +126,68 @@ def test_set_background_color_rgb_color_argument_check(self):
str(e.exception), "the 'color' argument must be a 3-element tuple or list"
)

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

transformed_image = image.transform_colorspace_to_srgb()

# Statistics about color values should be the same - it should be a no-op after all
stat = ImageStat.Stat(image.image)
stat_transformed = ImageStat.Stat(transformed_image.image)
self.assertEqual(stat.sum, stat_transformed.sum)

self.assertEqual(transformed_image.image.mode, "RGB")

def test_transform_colorspace_to_srgb_preserve_transparency(self):
with open("tests/images/transparent_with_icc_profile.png", "rb") as f:
image = PillowImage.open(PNGImageFile(f))

# The sample image is in RGBA mode, it contains transparent pixels
self.assertEqual(image.image.mode, "RGBA")

transformed_image = image.transform_colorspace_to_srgb()
# Image remains in RGBA mode
self.assertEqual(transformed_image.image.mode, "RGBA")

# Check that the alpha of pixel 1,1 is 0
self.assertEqual(transformed_image.image.convert("RGBA").getpixel((1, 1))[3], 0)

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

# The sample image should originally be in CMYK mode
self.assertEqual(image.image.mode, "CMYK")
self.assertIsNotNone(image.get_icc_profile())
cms_profile = ImageCms.ImageCmsProfile(io.BytesIO(image.get_icc_profile()))

# The original embedded profile should be called "ISO Coated v2 (built-in)"
self.assertEqual(
cms_profile.profile.profile_description, "ISO Coated v2 (built-in)"
)

image_srgb = image.transform_colorspace_to_srgb()

# The image should now be in RGB mode as a result of the operation
self.assertEqual(image_srgb.image.mode, "RGB")

# We verify the result by comparing the sum of all color values in the image
stat = ImageStat.Stat(image_srgb.image)
expected_sum = [8617671.0, 8074576.0, 6869829.0]

for actual, expected in zip(stat.sum, expected_sum):
self.assertEqual(
actual,
expected,
msg="The colors in the transformed image don't match with expectations. Did the sample image change or is there a bug?",
)

# The image should now have a embedded sRGB profile
self.assertIsNotNone(image_srgb.get_icc_profile())
cms_profile = ImageCms.ImageCmsProfile(io.BytesIO(image_srgb.get_icc_profile()))
self.assertEqual(cms_profile.profile.profile_description, "sRGB built-in")

def test_save_as_jpeg(self):
# Remove alpha channel from image
image = self.image.set_background_color_rgb((255, 255, 255))
Expand Down Expand Up @@ -518,6 +581,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
Loading

0 comments on commit 9f05aec

Please sign in to comment.