Repository: psd-tools/psd-tools
Pull request #319 (2022-06-14 06:15:44)

Improvements for non-rgb modes and transparency
# Context
Proposing a number of improvements to better support other image modes (mainly using CMYK) in `layer.topil` and `layer.composite`.  As listed below:
- Apply icc profile to non-rgb images (by converting them to RGB image mode). As stated in the [pillow documentation](https://pillow.readthedocs.io/en/stable/_modules/PIL/ImageCms.html#profileToProfile), the output image mode is by default the same as the input image mode. When applying the ICC profile on a CMYK image to convert it to sRGB profile, we need to explicitly set the output image mode to something that is RGB.
- Enable transparency for images not in RGB or L image mode (i.e. CMYK) in layer.topil() when the icc_profile is enabled. This is done by changing the order of operation: apply the ICC profile first without transparency, then apply the transparency on the result of the ICC transformation (RGB image).

This PR also brings all these improvements to layer.composite() by re-using the `post-process` logic.
- Fixing inverted colors in CMYK mode for composite layer.
- Adding ability to apply the ICC profile to composite layer.
- Working transparency for CMYK images when icc_profile is enabled.

This is a test PSD file, using created using the CMYK 8-bit image mode (I had to zip because github doesn't allow PSD file upload): [CMYK-psd-tools-demo.psd.zip](https://github.com/psd-tools/psd-tools/files/8896627/CMYK-psd-tools-demo.psd.zip)

The test file is has the following layers: 
<img width="680" alt="Screen Shot 2022-06-14 at 3 26 20 pm" src="https://user-images.githubusercontent.com/25138293/173499926-6d5c0142-93d2-430a-8738-af5a7a2202c5.png">

This is what layer.topil() returns on the "cat layer" (pixel layer)
![MAC7uHBSFBM](https://user-images.githubusercontent.com/25138293/173498771-76de52fc-5799-4394-8ab4-4e3a3ed60f39.png)

This is what layer.composite() returns for the shape layer that clips the pixel layer of a dog. It's hard to see in github, but there is some proper transparency on this image, not just a white background:
![MAC7uGHaeRU](https://user-images.githubusercontent.com/25138293/173498756-b10e245e-76a1-45dd-b241-5d6966629e09.png).

Let me know if you have any feedback or suggestions, I am happy to take that onboard


Patch:
diff --git a/src/psd_tools/api/layers.py b/src/psd_tools/api/layers.py
index 2b600b2..7660fb8 100644
--- a/src/psd_tools/api/layers.py
+++ b/src/psd_tools/api/layers.py
@@ -397,7 +397,8 @@ class Layer(object):
         force=False,
         color=1.0,
         alpha=0.0,
-        layer_filter=None
+        layer_filter=None,
+        apply_icc=False
     ):
         """
         Composite layer and masks (mask, vector mask, and clipping layers).
@@ -415,7 +416,7 @@ class Layer(object):
         :return: :py:class:`PIL.Image`.
         """
         from psd_tools.composite import composite_pil
-        return composite_pil(self, color, alpha, viewport, layer_filter, force)
+        return composite_pil(self, color, alpha, viewport, layer_filter, force, apply_icc=apply_icc)
 
     def has_clip_layers(self):
         """
@@ -655,7 +656,8 @@ class Group(GroupMixin, Layer):
         force=False,
         color=1.0,
         alpha=0.0,
-        layer_filter=None
+        layer_filter=None,
+        apply_icc=False
     ):
         """
         Composite layer and masks (mask, vector mask, and clipping layers).
@@ -674,7 +676,7 @@ class Group(GroupMixin, Layer):
         """
         from psd_tools.composite import composite_pil
         return composite_pil(
-            self, color, alpha, viewport, layer_filter, force, as_layer=True
+            self, color, alpha, viewport, layer_filter, force, as_layer=True, apply_icc=apply_icc
         )
 
 
diff --git a/src/psd_tools/api/pil_io.py b/src/psd_tools/api/pil_io.py
index b3c7239..2669377 100644
--- a/src/psd_tools/api/pil_io.py
+++ b/src/psd_tools/api/pil_io.py
@@ -91,7 +91,7 @@ def convert_image_data_to_pil(psd, channel, apply_icc):
     if not image:
         return None
 
-    image = _post_process(image, alpha, icc)
+    image = post_process(image, alpha, icc)
     return _remove_white_background(image)
 
 
@@ -110,22 +110,21 @@ def convert_layer_to_pil(layer, channel, apply_icc):
     if not image or (channel is not None and channel < 0):
         return image  # Return None, alpha or mask.
 
-    return _post_process(image, alpha, icc)
+    return post_process(image, alpha, icc)
 
 
-def _post_process(image, alpha, icc_profile):
+def post_process(image, alpha, icc_profile):
     # Fix inverted CMYK.
     if image.mode == 'CMYK':
         from PIL import ImageChops
         image = ImageChops.invert(image)
 
-    # In Pillow, alpha channel is only available in RGB or L.
-    if alpha and image.mode in ('RGB', 'L'):
-        image.putalpha(alpha)
-
     if icc_profile:
         image = _apply_icc(image, icc_profile)
 
+    # In Pillow, alpha channel is only available in RGB or L.
+    if alpha and image.mode in ('RGB', 'L'):
+        image.putalpha(alpha)
     return image
 
 
@@ -149,7 +148,7 @@ def convert_pattern_to_pil(pattern):
     else:
         image = Image.merge(mode, channels[:channel_size])
 
-    return _post_process(image, alpha, None)  # TODO: icc support?
+    return post_process(image, alpha, None)  # TODO: icc support?
 
 
 def convert_thumbnail_to_pil(thumbnail, mode='RGB'):
@@ -245,15 +244,11 @@ def _apply_icc(image, icc_profile):
             'ICC profile found but not supported. Install little-cms.'
         )
         return image
-
-    if image.mode not in ('RGB', ):
-        logger.debug('%s ICC profile is not supported.' % image.mode)
-        return image
-
     try:
         in_profile = ImageCms.ImageCmsProfile(BytesIO(icc_profile))
         out_profile = ImageCms.createProfile('sRGB')
-        return ImageCms.profileToProfile(image, in_profile, out_profile)
+        outputMode = image.mode if image.mode in ('L', 'LA', 'RGBA') else 'RGB'
+        return ImageCms.profileToProfile(image, in_profile, out_profile, outputMode=outputMode)
     except ImageCms.PyCMSError as e:
         logger.warning('PyCMSError: %s' % (e))
 
diff --git a/src/psd_tools/api/psd_image.py b/src/psd_tools/api/psd_image.py
index 28faa0f..4934838 100644
--- a/src/psd_tools/api/psd_image.py
+++ b/src/psd_tools/api/psd_image.py
@@ -178,6 +178,7 @@ class PSDImage(GroupMixin):
         alpha=0.0,
         layer_filter=None,
         ignore_preview=False,
+        apply_icc=False
     ):
         """
         Composite the PSD image.
@@ -198,8 +199,8 @@ class PSDImage(GroupMixin):
         """
         from psd_tools.composite import composite_pil
         if not (ignore_preview or force or layer_filter) and self.has_preview():
-            return self.topil()
-        return composite_pil(self, color, alpha, viewport, layer_filter, force)
+            return self.topil(apply_icc=apply_icc)
+        return composite_pil(self, color, alpha, viewport, layer_filter, force, apply_icc=apply_icc)
 
     def is_visible(self):
         """
diff --git a/src/psd_tools/composite/__init__.py b/src/psd_tools/composite/__init__.py
index c2d90ef..847c8d7 100644
--- a/src/psd_tools/composite/__init__.py
+++ b/src/psd_tools/composite/__init__.py
@@ -1,9 +1,13 @@
 import numpy as np
-from psd_tools.constants import Tag, BlendMode, ColorMode
+from psd_tools.api.psd_image import PSDImage
+from psd_tools.constants import Tag, BlendMode, ColorMode, Resource
 from psd_tools.api.layers import AdjustmentLayer, Layer
 from psd_tools.api.numpy_io import EXPECTED_CHANNELS
+from psd_tools.api.pil_io import post_process
 
 import logging
+
+from psd_tools.psd import image_resources
 from .blend import BLEND_FUNC, normal
 from .vector import (
     create_fill, create_fill_desc, draw_vector_mask, draw_stroke,
@@ -15,7 +19,7 @@ logger = logging.getLogger(__name__)
 
 
 def composite_pil(
-    layer, color, alpha, viewport, layer_filter, force, as_layer=False
+    layer, color, alpha, viewport, layer_filter, force, as_layer=False, apply_icc = False
 ):
     from PIL import Image
     from psd_tools.api.pil_io import get_pil_mode
@@ -43,8 +47,9 @@ def composite_pil(
     if mode == 'P':
         mode = 'RGB'
     # Skip only when there is a preview image and it has no alpha.
+    delay_alpha_application = color_mode not in (ColorMode.GRAYSCALE, ColorMode.RGB)
     skip_alpha = not force and (
-        color_mode not in (ColorMode.GRAYSCALE, ColorMode.RGB) or (
+        delay_alpha_application or (
             layer.kind == 'psdimage' and layer.has_preview() and
             not has_transparency(layer)
         )
@@ -57,7 +62,15 @@ def composite_pil(
         color = color[:, :, 0]
     if color.shape[0] == 0 or color.shape[1] == 0:
         return None
-    return Image.fromarray((255 * color).astype(np.uint8), mode)
+    image = Image.fromarray((255 * color).astype(np.uint8), mode)
+    alpha_as_image = None
+    if not force and delay_alpha_application:
+        alpha_as_image = Image.fromarray((255 * np.squeeze(alpha, axis=2)).astype(np.uint8), "L")
+    icc = None
+    image_resources = layer.image_resources if isinstance(layer, PSDImage) else layer._psd.image_resources
+    if (apply_icc and Resource.ICC_PROFILE in image_resources):
+        icc = image_resources.get_data(Resource.ICC_PROFILE)
+    return post_process(image, alpha_as_image, icc)
 
 
 def composite(


Test patch:
diff --git a/tests/psd_tools/composite/test_composite.py b/tests/psd_tools/composite/test_composite.py
index 3c22a0d..64fb88a 100644
--- a/tests/psd_tools/composite/test_composite.py
+++ b/tests/psd_tools/composite/test_composite.py
@@ -142,36 +142,39 @@ def test_composite_viewport():
 
 
 @pytest.mark.parametrize(
-    'colormode, depth, mode, ignore_preview', [
-        ('bitmap', 1, '1', False),
-        ('cmyk', 8, 'CMYK', False),
-        ('duotone', 8, 'L', False),
-        ('grayscale', 8, 'L', False),
-        ('index_color', 8, 'P', False),
-        ('rgb', 8, 'RGB', False),
-        ('rgba', 8, 'RGB', False),  # Extra alpha is not transparency
-        ('lab', 8, 'LAB', False),
-        ('multichannel', 16, 'L', False),
-        ('bitmap', 1, '1', True),
-        ('cmyk', 8, 'CMYK', True),
-        ('duotone', 8, 'L', True),
-        ('grayscale', 8, 'L', True),
-        ('index_color', 8, 'RGB', True),
-        ('rgb', 8, 'RGB', True),
-        ('rgba', 8, 'RGB', True),  # Extra alpha is not transparency
-        ('lab', 8, 'LAB', True),
-        ('multichannel', 16, 'L', True),
+    'colormode, depth, mode, ignore_preview, apply_icc', [
+        ('bitmap', 1, '1', False, False),
+        ('cmyk', 8, 'CMYK', False, False),
+        ('duotone', 8, 'L', False, False),
+        ('grayscale', 8, 'L', False, False),
+        ('index_color', 8, 'P', False, False),
+        ('rgb', 8, 'RGB', False, False),
+        ('rgba', 8, 'RGB', False, False),  # Extra alpha is not transparency
+        ('lab', 8, 'LAB', False, False),
+        ('multichannel', 16, 'L', False, False),
+        ('bitmap', 1, '1', True, False),
+        ('cmyk', 8, 'CMYK', True, False),
+        ('duotone', 8, 'LA', True, False),
+        ('grayscale', 8, 'L', True, False),
+        ('index_color', 8, 'RGBA', True, False),
+        ('rgb', 8, 'RGB', True, False),
+        ('rgba', 8, 'RGB', True, False),  # Extra alpha is not transparency
+        ('lab', 8, 'LAB', True, False),
+        ('multichannel', 16, 'LA', True, False),
+        ('cmyk', 8, 'RGBA', True, True),
+        ('rgb', 8, 'RGB', False, True),
+        ('duotone', 8, 'L', False, True),
     ]
 )
-def test_composite_pil(colormode, depth, mode, ignore_preview):
+def test_composite_pil(colormode, depth, mode, ignore_preview, apply_icc):
     from PIL import Image
     filename = 'colormodes/4x4_%gbit_%s.psd' % (depth, colormode)
     psd = PSDImage.open(full_name(filename))
-    image = psd.composite(ignore_preview=ignore_preview)
+    image = psd.composite(ignore_preview=ignore_preview, apply_icc=apply_icc)
     assert isinstance(image, Image.Image)
     assert image.mode == mode
     for layer in psd:
-        assert isinstance(layer.composite(), Image.Image)
+        assert isinstance(layer.composite(apply_icc=apply_icc), Image.Image)
 
 
 def test_composite_layer_filter():