diff --git a/tmxlib/canvas.py b/tmxlib/canvas.py index f5bdbc3..e4ca125 100644 --- a/tmxlib/canvas.py +++ b/tmxlib/canvas.py @@ -43,7 +43,7 @@ class Canvas(PilImage): def __init__(self, size=(0, 0), commands=()): self.size = size - self.pil_image = Image.new('RGBA', size) + self.pil_image = Image.new('RGBA', size, color=(0, 0, 0, 0)) for command in commands: command.draw(self) @@ -55,12 +55,23 @@ def to_image(self): """ return PilImage(data=self._repr_png_()) + @property + def trans(self): + return None + + @trans.setter + def trans(self, new_trans): + if new_trans is not None: + raise ValueError('Canvas does not support trans') + def _parent_info(self): return 0, 0, self.to_image() - def draw_image(self, image, pos=(0, 0)): + def draw_image(self, image, pos=(0, 0), opacity=1): """Paste the given image at the given position """ + if not opacity: + return x, y = pos try: @@ -73,10 +84,30 @@ def draw_image(self, image, pos=(0, 0)): pil_image = parent.pil_image except AttributeError: input = BytesIO(parent._repr_png_()) - pil_image = Image.open(input) + pil_image = Image.open(input).convert('RGBA') if crop: pil_image = pil_image.crop((image.x, image.y, image.x + image.width, image.y + image.height)) - self.pil_image.paste(pil_image, (x, y)) + if opacity == 1: + alpha_channel = pil_image + self.pil_image.paste(pil_image, (x, y), mask=alpha_channel) + else: + # Create temporary image the same size as the canvas + bigger_image = Image.new('RGBA', + (self.width, self.height), + color=(0, 0, 0, 0)) + # Blit into it + bigger_image.paste(pil_image, (x, y)) + # Reduce its alpha + bands = bigger_image.split() + alpha_channel = bands[3] + alpha_channel = alpha_channel.point( + lambda x: int(x * opacity)) + bigger_image = Image.merge('RGBA', bands[:3] + (alpha_channel, )) + # Finally, blit it to the canvas + self.pil_image = Image.alpha_composite( + self.pil_image, + bigger_image) + # Thanks, PIL, for making this so easy! diff --git a/tmxlib/draw.py b/tmxlib/draw.py index 9a17734..5da88cd 100644 --- a/tmxlib/draw.py +++ b/tmxlib/draw.py @@ -35,9 +35,11 @@ class DrawImageCommand(DrawCommand): """ x, y = helpers.unpacked_properties('pos') - def __init__(self, image, pos=(0, 0)): + def __init__(self, image, pos=(0, 0), opacity=1): self.image = image self.pos = pos + self.opacity = opacity def draw(self, canvas): - canvas.draw_image(self.image, self.pos) + canvas.draw_image(self.image, self.pos, + opacity=self.opacity) diff --git a/tmxlib/image.py b/tmxlib/image.py index 067bfc2..e955ae1 100644 --- a/tmxlib/image.py +++ b/tmxlib/image.py @@ -30,8 +30,14 @@ def open(filename, trans=None, size=None): :param filename: Name of the file to load the image from :param trans: - Optional color that should be rendered as transparent - (this is not implemented yet) + Optional color that should be loaded as transparent + + .. note:: + + Currently, loading images that use color-key transparency + is very inefficient. + If possible, use the alpha channel instead. + :param size: Optional (width, height) tuple. If specified, the file will not be read from disk when the image size diff --git a/tmxlib/image_base.py b/tmxlib/image_base.py index 2c1bd8b..663d1ac 100644 --- a/tmxlib/image_base.py +++ b/tmxlib/image_base.py @@ -106,7 +106,13 @@ class Image(ImageBase, fileio.ReadWriteBase): .. attribute:: trans - A color key used for transparency (currently not implemented) + A color key used for transparency + + .. note:: + + Currently, loading images that use color-key transparency + is very inefficient. + If possible, use the alpha channel instead. Images support indexing (``img[x, y]``); see :meth:`tmxlib.image_base.ImageBase.__getitem__` @@ -119,10 +125,10 @@ class Image(ImageBase, fileio.ReadWriteBase): top_left = 0, 0 def __init__(self, data=None, trans=None, size=None, source=None): - self.trans = trans self._data = data self.source = source self._size = size + self.trans = trans @property def size(self): diff --git a/tmxlib/image_pil.py b/tmxlib/image_pil.py index 2df9552..277ef7e 100644 --- a/tmxlib/image_pil.py +++ b/tmxlib/image_pil.py @@ -19,16 +19,17 @@ def load_image(self): """Load the image from self.data, and set self.size """ try: - self._pil_image + self._pil_image_original return self.size except AttributeError: - self._pil_image = Image.open(BytesIO(self.data)) - self._pil_image = self._pil_image.convert('RGBA') - w, h = self._pil_image.size + pil_image = Image.open(BytesIO(self.data)) + pil_image = pil_image.convert('RGBA') + w, h = pil_image.size if self._size: assert (w, h) == self._size else: self._size = w, h + self._pil_image_original = pil_image return w, h @property @@ -37,8 +38,34 @@ def pil_image(self): return self._pil_image except AttributeError: self.load_image() + pil_image = self._pil_image_original + if self.trans: + pil_image = pil_image.copy() + datas = pil_image.getdata() + new_data = [] + xtrans = tuple(int(n * 255) for n in self.trans) + for item in datas: + itpl = tuple(item) + if itpl[:3] == xtrans: + new_data.append(itpl[:3] + (0,)) + else: + new_data.append(item) + pil_image.putdata(new_data) + self._pil_image = pil_image return self._pil_image + @property + def trans(self): + return self._trans + + @trans.setter + def trans(self, new_trans): + self._trans = new_trans + try: + del self._pil_image + except AttributeError: + pass + def get_pixel(self, x, y): x, y = self._wrap_coords(x, y) return tuple(v / 255 for v in self.pil_image.getpixel((x, y))) diff --git a/tmxlib/image_png.py b/tmxlib/image_png.py index cdca6f3..077c886 100644 --- a/tmxlib/image_png.py +++ b/tmxlib/image_png.py @@ -1,25 +1,39 @@ from __future__ import division -from six import BytesIO +import itertools +import six +from six import BytesIO import png +from array import array import tmxlib import tmxlib.image_base +def _grouper(iterable, n): + "Collect data into fixed-length chunks, ignoring extras at the end" + # grouper('ABCDEFG', 3) --> ABC DEF + args = [iter(iterable)] * n + if six.PY3: + izip = zip + else: + izip = itertools.izip + return izip(*args) + + class PngImage(tmxlib.image_base.Image): def load_image(self): """Load the image from self.data, and set self.size """ try: - self._image_data + self._image_data_original return self.size except AttributeError: reader = png.Reader(bytes=self.data).asRGBA8() w, h, data, meta = reader - self._image_data = tuple(data) + self._image_data_original = tuple(data) if self._size: assert (w, h) == self._size else: @@ -32,8 +46,33 @@ def image_data(self): return self._image_data except AttributeError: self.load_image() + data = self._image_data_original + if self.trans: + xtrans = tuple(int(n * 255) for n in self.trans[:3]) + new_data = [] + for line in data: + new_data.append(array( + 'B', + itertools.chain.from_iterable( + v[:3] + (0,) if tuple(v[:3]) == xtrans else v + for v in _grouper(line, 4)))) + self._image_data = new_data + else: + self._image_data = data return self._image_data + @property + def trans(self): + return self._trans + + @trans.setter + def trans(self, new_trans): + self._trans = new_trans + try: + del self._image_data + except AttributeError: + pass + def get_pixel(self, x, y): x, y = self._wrap_coords(x, y) return tuple(v / 255 for v in self.image_data[y][x * 4:(x + 1) * 4]) @@ -43,7 +82,9 @@ def _repr_png_(self, _crop_box=None): See: http://ipython.org/ipython-doc/stable/config/integrating.html """ - if _crop_box: + if _crop_box or self.trans: + if not _crop_box: + _crop_box = 0, 0, self.width, self.height left, up, right, low = _crop_box data = [l[left * 4:right * 4] for l in self.image_data[up:low]] out = BytesIO() diff --git a/tmxlib/layer.py b/tmxlib/layer.py index 5e717ee..ddaba48 100644 --- a/tmxlib/layer.py +++ b/tmxlib/layer.py @@ -248,6 +248,7 @@ def generate_draw_commands(self): yield draw.DrawImageCommand( image=tile.image, pos=(tile.pixel_x, tile.pixel_y - tile.pixel_height), + opacity=self.opacity, ) def _repr_png_(self): diff --git a/tmxlib_test/data/colorcorners-mid-alpha.png b/tmxlib_test/data/colorcorners-mid-alpha.png new file mode 100644 index 0000000..5b1e6fd Binary files /dev/null and b/tmxlib_test/data/colorcorners-mid-alpha.png differ diff --git a/tmxlib_test/data/colorcorners-mid-nored.png b/tmxlib_test/data/colorcorners-mid-nored.png new file mode 100644 index 0000000..dad8e52 Binary files /dev/null and b/tmxlib_test/data/colorcorners-mid-nored.png differ diff --git a/tmxlib_test/data/colorcorners-mid-noyellow.png b/tmxlib_test/data/colorcorners-mid-noyellow.png new file mode 100644 index 0000000..42c4cf3 Binary files /dev/null and b/tmxlib_test/data/colorcorners-mid-noyellow.png differ diff --git a/tmxlib_test/image_to_term.py b/tmxlib_test/image_to_term.py index d5fe2fc..7d1b3a8 100644 --- a/tmxlib_test/image_to_term.py +++ b/tmxlib_test/image_to_term.py @@ -1,4 +1,4 @@ -from __future__ import division +from __future__ import division, unicode_literals def term256color(r, g, b, a): @@ -10,9 +10,10 @@ def f(v): return 5 else: return round(v) - r = f(r / a * 6) - g = f(g / a * 6) - b = f(b / a * 6) + term = a / 256 / 256 * 6 + r = f(r * term) + g = f(g * term) + b = f(b * term) return int(16 + r * 36 + g * 6 + b) @@ -33,10 +34,10 @@ def image_to_term256(pil_image): for y in range(height // 2): try: for x in range(width): - result.append(u'\033[48;5;%dm\033[38;5;%dm' % ( + result.append('\033[48;5;%dm\033[38;5;%dm' % ( term256color(*im.getpixel((x, y * 2))), term256color(*im.getpixel((x, y * 2 + 1))))) - result.append(u'\N{LOWER HALF BLOCK}') + result.append('\N{LOWER HALF BLOCK}') finally: result.append('\033[0m\n') return ''.join(result) diff --git a/tmxlib_test/test_image.py b/tmxlib_test/test_image.py index c9c8326..dec6251 100644 --- a/tmxlib_test/test_image.py +++ b/tmxlib_test/test_image.py @@ -3,11 +3,13 @@ import os import warnings from six import BytesIO +import collections import pytest import tmxlib import tmxlib.image_base +from tmxlib.compatibility import ord_ from tmxlib_test import get_test_filename, file_contents, assert_color_tuple_eq @@ -175,13 +177,23 @@ def basic_color(request): return request.param -def test_trans(image_class, basic_color): +def test_trans_property(image_class, basic_color): filename = get_test_filename('colorcorners.png') image = image_class(source=filename, trans=basic_color) assert image.trans == basic_color assert image[:5, :5].trans == basic_color +def test_no_canvas_trans(canvas_mod): + with pytest.raises((ValueError, TypeError)): + canvas_mod.Canvas(trans=(1, 0, 1)) + canvas = canvas_mod.Canvas() + assert canvas.trans is None + with pytest.raises(ValueError): + canvas.trans = 1, 0, 1 + assert canvas.trans is None + + class _KeyMaker(object): def __getitem__(self, key): return key @@ -282,7 +294,7 @@ def test_region_hierarchy(colorcorners_image, colorcorners_image_type): assert region3.size == (13, 13) -def assert_png_repr_equal(image, filename): +def assert_png_repr_equal(image, filename, epsilon=0): data = image._repr_png_() a = pil_image_open(get_test_filename(filename)) b = pil_image_open(BytesIO(data)) @@ -291,17 +303,56 @@ def assert_png_repr_equal(image, filename): bbytes = b.convert('RGBA').tobytes() if abytes != bbytes: from tmxlib_test.image_to_term import image_to_term256 + from PIL import ImageChops, ImageOps print("Expected: ({im.size[0]}x{im.size[1]})".format(im=a)) print(image_to_term256(a)) print("Got: ({im.size[0]}x{im.size[1]})".format(im=b)) print(image_to_term256(b)) - assert abytes == bbytes + + diff = ImageChops.difference(a, b).convert('RGB') + diff = ImageOps.autocontrast(diff) + print('Difference:') + print(image_to_term256(diff)) + + assert len(abytes) == len(bbytes), 'unequal image size' + + max_pixel_delta = 0 + try: + Counter = collections.Counter + except AttributeError: # pragma: no cover -- Python 2.6 + counters = None + else: + counters = [Counter() for i in range(4)] + for i, (ba, bb) in enumerate(zip(abytes, bbytes)): + pixel_delta = ord_(ba) - ord_(bb) + max_pixel_delta = max(abs(pixel_delta), max_pixel_delta) + if counters: + counters[i % 4][pixel_delta] += 1 + + if counters: + print("Pixel deltas:") + for band_index, counter in enumerate(counters): + print(' {0}:'.format('RGBA'[band_index])) + for delta, count in sorted(counter.items()): + print(' {0:4}: {1}x'.format(delta, count)) + + print('Max |pixel delta|:', max_pixel_delta) + assert max_pixel_delta <= epsilon def test_repr_png(colorcorners_image): assert_png_repr_equal(colorcorners_image, 'colorcorners.png') +def test_trans_image(image_class): + image = load_image(image_class, 'colorcorners-mid.png') + assert_png_repr_equal(image, 'colorcorners-mid.png') + image.trans = 1, 0, 0 + assert_png_repr_equal(image, 'colorcorners-mid-nored.png') + image.trans = 1, 1, 0 + assert_png_repr_equal(image, 'colorcorners-mid-noyellow.png') + + def test_canvas_draw_image(colorcorners_image, canvas_mod): canvas = canvas_mod.Canvas((32, 32)) canvas.draw_image(colorcorners_image) @@ -312,6 +363,33 @@ def test_canvas_draw_image(colorcorners_image, canvas_mod): assert_png_repr_equal(canvas, 'colorcorners-x4.png') +def test_canvas_draw_image_alpha(image_class, colorcorners_image, canvas_mod): + canvas = canvas_mod.Canvas((32, 32)) + scribble = load_image(image_class, 'scribble.png') + canvas.draw_image(scribble, opacity=0.5) + canvas.draw_image(colorcorners_image, pos=(8, 8), opacity=0.5) + + assert_png_repr_equal(canvas, 'colorcorners-mid-alpha.png', epsilon=1) + + canvas.draw_image(scribble, opacity=0) + assert_png_repr_equal(canvas, 'colorcorners-mid-alpha.png', epsilon=1) + + +def test_canvas_command_img_alpha(image_class, colorcorners_image, canvas_mod): + canvas = canvas_mod.Canvas((32, 32)) + scribble = load_image(image_class, 'scribble.png') + for command in ( + tmxlib.draw.DrawImageCommand(scribble, opacity=0.5), + tmxlib.draw.DrawImageCommand(colorcorners_image, pos=(8, 8), + opacity=0.5)): + command.draw(canvas) + + assert_png_repr_equal(canvas, 'colorcorners-mid-alpha.png', epsilon=1) + + tmxlib.draw.DrawImageCommand(scribble, opacity=0).draw(canvas) + assert_png_repr_equal(canvas, 'colorcorners-mid-alpha.png', epsilon=1) + + def test_canvas_draw_overlap(image_class, canvas_mod): canvas = canvas_mod.Canvas((32, 32)) canvas.draw_image(load_image(image_class, 'scribble.png'))