Mikhail Korobov avatar Mikhail Korobov committed 39f45eb

experimental support for layer merging

Comments (0)

Files changed (4)

     >>> layer_image = layer.as_pymaging()
     >>> layer_image.save_to_path('layer.png')
 
+Export layer group (experimental)::
+
+    >>> group_image = group2.as_PIL()
+    >>> group_image.save('group.png')
+
 
 Why yet another PSD reader?
 ---------------------------
 * image ICC profile is taken into account;
 * most important (imho) 23 image resource types and 12 tagged block
   types are decoded;
-* there is an optional Cython extension to make the parsing fast.
+* there is an optional Cython extension to make the parsing fast;
+* very basic & experimental layer merging.
 
 Not implemented:
 
 * reading of CMYK, Duotone, LAB, etc. images;
 * many image resource types and tagged blocks are not decoded
   (they are attached to the result as raw bytes);
-* this library can't blend layers together: it is possible to export
-  a single layer and to export a final image, but it is not possible to
-  render e.g. layer group;
+* this library can't reliably blend layers together: it is possible to export
+  a single layer and to export a final image, but rendering of
+  e.g. layer group may produce incorrect results;
 * the decoding of Descriptor structures is very basic;
 * the writing of PSD images is not implemented;
-* only 8bit images can be converted to ``pymaging.Image``.
+* only 8bit images can be converted to ``pymaging.Image``;
+* layer merging currently doesn't work with Pymaging_.
 
 If you need some of unimplemented features then please fire an issue
 or implement it yourself (pull requests are welcome in this case).

src/psd_tools/user_api/pil_support.py

         return None
 
     return icc_profile
+
+def apply_opacity(im, opacity):
+    """
+    Applies opacity to an image.
+    """
+    if im.mode == 'RGB':
+        im.putalpha(opacity)
+        return im
+    elif im.mode == 'RGBA':
+        r, g, b, a = im.split()
+        opacity_scale = opacity / 255
+        a = a.point(lambda i: i*opacity_scale)
+        return Image.merge('RGBA', [r, g, b, a])
+    else:
+        raise NotImplementedError()

src/psd_tools/user_api/psd_image.py

 import weakref              # FIXME: there should be weakrefs in this module
 import psd_tools.reader
 import psd_tools.decoder
-from psd_tools.constants import TaggedBlock, SectionDivider
+from psd_tools.constants import TaggedBlock, SectionDivider, BlendMode
 from psd_tools.user_api.layers import group_layers
 from psd_tools.user_api import pymaging_support
 from psd_tools.user_api import pil_support
         """
         return combined_bbox(self.layers)
 
+
+    def as_PIL(self):
+        """
+        Returns a PIL image for this group.
+        This is highly experimental.
+        """
+        return merge_layers(self.layers, respect_visibility=True)
+
     def _add_layer(self, child):
         self.layers.append(child)
 
         """
         return pil_support.extract_composite_image(self.decoded_data)
 
+    def as_PIL_merged(self):
+        """
+        Returns a PIL image for this PSD file.
+        Image is obtained by merging all layers.
+        This is highly experimental.
+        """
+        bbox = BBox(0, 0, self.header.width, self.header.height)
+        return merge_layers(self.layers, bbox=bbox)
+
     def as_pymaging(self):
         """
         Returns a pymaging.Image for this PSD file.
         return pymaging_support.extract_layer_image(self.decoded_data, index)
 
 
+class _RootGroup(Group):
+    """ A fake group for holding all layers """
+
+    @property
+    def visible(self):
+        return True
+
+    @property
+    def visible_global(self):
+        return True
+
+
 def combined_bbox(layers):
     """
     Returns a bounding box for ``layers`` or None if this is not possible.
     return BBox(min(lefts), min(tops), max(rights), max(bottoms))
 
 
-class _RootGroup(Group):
-    """ A fake group for holding all layers """
+def merge_layers(layers, respect_visibility=True, skip_layer=lambda layer: False, bbox=None):
+    """
+    Merges layers together (the first layer is on top).
 
-    @property
-    def visible(self):
-        return True
+    By default hidden layers are not rendered;
+    pass ``respect_visibility=False`` to render them.
 
-    @property
-    def visible_global(self):
-        return True
+    In order to skip some layers pass ``skip_layer`` function which
+    should take ``layer` as an argument and return True or False.
+
+    If ``crop_bbox`` is not None, it should be a 4-tuple with coordinates;
+    returned image will be restricted to this rectangle.
+
+    This is highly experimental.
+    """
+
+    # FIXME: this currently assumes PIL
+    from PIL import Image
+
+    if bbox is None:
+        bbox = combined_bbox(layers)
+
+    if bbox is None:
+        return None
+
+    result = Image.new(
+        "RGBA",
+        (bbox.width, bbox.height),
+        color=(255, 255, 255, 0)  # fixme: transparency calculation is incorrect
+    )
+
+    for layer in reversed(layers):
+
+        if layer is None:
+            continue
+
+        if skip_layer(layer):
+            continue
+
+        if not layer.visible and respect_visibility:
+            continue
+
+        if isinstance(layer, psd_tools.Group):
+            layer_image = merge_layers(layer.layers, respect_visibility, skip_layer)
+        else:
+            layer_image = layer.as_PIL()
+
+        layer_image = pil_support.apply_opacity(layer_image, layer.opacity)
+
+        x, y = layer.bbox.x1 - bbox.x1, layer.bbox.y1 - bbox.y1
+        w, h = layer_image.size
+
+        if x < 0 or y < 0: # image doesn't fit the bbox
+            x_overflow = - min(x, 0)
+            y_overflow = - min(y, 0)
+            logger.debug("cropping.. (%s, %s)", x_overflow, y_overflow)
+            layer_image = layer_image.crop((x_overflow, y_overflow, w, h))
+            x += x_overflow
+            y += y_overflow
+
+        if w+x > bbox.width or h+y > bbox.height:
+            # FIXME
+            logger.debug("cropping..")
+
+        if layer.blend_mode == BlendMode.NORMAL:
+            if layer_image.mode == 'RGBA':
+                result.paste(layer_image, (x,y), layer_image)
+            elif layer_image.mode == 'RGB':
+                result.paste(layer_image, (x,y))
+            else:
+                logger.warning("layer image mode is unsupported for merging: %s", layer_image.mode)
+                continue
+        else:
+            logger.warning("Blend mode is not implemented: %s", BlendMode.name_of(layer.blend_mode))
+            continue
+
+    return result

tests/test_pixels.py

 PIXEL_COLORS = (
     # filename                  probe point    pixel value
     ('1layer.psd',              (5, 5),       (0x27, 0xBA, 0x0F)),
-    ('2layers.psd',             (70, 30),     (0xF1, 0xF3, 0xC1)), # why gimp shows it as F2F4C2 ?
-    ('clipping-mask.psd',       (182, 68),    (0xDA, 0xE6, 0xF7)), # this is a clipped point
     ('group.psd',               (10, 20),     (0xFF, 0xFF, 0xFF)),
     ('hidden-groups.psd',       (60, 100),    (0xE1, 0x0B, 0x0B)),
     ('hidden-layer.psd',        (0, 0),       (0xFF, 0xFF, 0xFF)),
-    ('history.psd',             (70, 85),     (0x24, 0x26, 0x29)),
-    ('mask.psd',                (87, 7),      (0xFF, 0xFF, 0xFF)), # mask truncates the layer here
 #    ('note.psd',                (30, 30),     (0, 0, 0)), # what is it?
     ('smart-object-slice.psd',  (70, 80),     (0xAC, 0x19, 0x19)), # XXX: what is this test about?
+)
+
+TRANSPARENCY_PIXEL_COLORS = (
     ('transparentbg-gimp.psd',  (14, 14),     (0xFF, 0xFF, 0xFF, 0x13)),
+    ('2layers.psd',             (70, 30),     (0xF1, 0xF3, 0xC1)), # why gimp shows it as F2F4C2 ?
 )
 
+MASK_PIXEL_COLORS = (
+    ('clipping-mask.psd',       (182, 68),    (0xDA, 0xE6, 0xF7)), # this is a clipped point
+    ('mask.psd',                (87, 7),      (0xFF, 0xFF, 0xFF)), # mask truncates the layer here
+)
+
+NO_LAYERS_PIXEL_COLORS = (
+    ('history.psd',             (70, 85),     (0x24, 0x26, 0x29)),
+)
+
+
+PIXEL_COLORS_8BIT = (PIXEL_COLORS + NO_LAYERS_PIXEL_COLORS +
+                     MASK_PIXEL_COLORS + TRANSPARENCY_PIXEL_COLORS)
+
 PIXEL_COLORS_32BIT = (
     ('32bit.psd',               (75, 15),     (136, 139, 145)),
     ('32bit.psd',               (95, 15),     (0, 0, 0)),
 
 
 @pytest.mark.parametrize(["get_color"], BACKENDS)
-@pytest.mark.parametrize(["filename", "point", "color"], PIXEL_COLORS)
+@pytest.mark.parametrize(["filename", "point", "color"], PIXEL_COLORS_8BIT)
 def test_composite(filename, point, color, get_color):
     psd = PSDImage.load(full_name(filename))
     assert color == get_color(psd, point)
     psd = PSDImage.load(full_name(filename))
     layer = psd.layers[layer_num]
     assert color == get_color(layer, point)
+
+
+@pytest.mark.parametrize(["filename", "point", "color"], PIXEL_COLORS + MASK_PIXEL_COLORS + TRANSPARENCY_PIXEL_COLORS)
+def test_layer_merging_size(filename, point, color):
+    psd = PSDImage.load(full_name(filename))
+    merged_image = psd.as_PIL_merged()
+    assert merged_image.size == psd.as_PIL().size
+
+@pytest.mark.parametrize(["filename", "point", "color"], PIXEL_COLORS)
+def test_layer_merging_pixels(filename, point, color):
+    psd = PSDImage.load(full_name(filename))
+    merged_image = psd.as_PIL_merged()
+    assert color[:3] == merged_image.getpixel(point)[:3]
+    assert merged_image.getpixel(point)[3] == 255 # alpha channel
+
+@pytest.mark.xfail
+@pytest.mark.parametrize(["filename", "point", "color"], TRANSPARENCY_PIXEL_COLORS)
+def test_layer_merging_pixels_transparency(filename, point, color):
+    psd = PSDImage.load(full_name(filename))
+    merged_image = psd.as_PIL_merged()
+    assert color == merged_image.getpixel(point)
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.