From b1472307ee41d4f31499eacd86124a90ca242970 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Tue, 6 Oct 2026 14:15:18 -0500 Subject: [PATCH] Stop using Pillow's deprecated getdata() in tile maps An image layer's transparent color was applied by reading every pixel with Image.getdata(), which Pillow 14 removes (it warns now), and writing them back with putdata(). Arcade still supports Pillow 11.3, which doesn't have the suggested get_flattened_data(), so use Pillow operations instead: a mask of the pixels matching the color in all three bands, and a paste of transparent white through it. The result is byte for byte the same as before, and it's faster: a 1024x600 image went from 76 ms to 4 ms. Adds a test map with a transparent color, since no test covered this. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + arcade/tilemap/tilemap.py | 35 +- .../tilemaps/image_layer_transparent.json | 347 ++++++++++++++++++ tests/unit/tilemap/test_img_layer.py | 41 +++ 4 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/tilemaps/image_layer_transparent.json diff --git a/CHANGELOG.md b/CHANGELOG.md index fbe869b712..bd5c792b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Sped up `arcade.draw_text()`. It no longer flushes OpenGL after every call (an unchanged call went from 76 to 15 µs), and several lines drawn in the same style each keep their own cached label instead of re-laying out one label every call (5 static lines went from 1.24 ms to 0.08 ms per frame). Any number of lines can share a style. A line whose text changes reuses its own label from the previous frame, found by the clock's tick count, instead of making a new one. - Sped up `arcade.Text` updates. Setting a property to the value it already has no longer lays out the text again (for example `font_size`, `bold`, or `width` went from about 165 µs to under 1 µs), and a `with text:` block only lays out the text if something in it needs a new layout. An unchanged `arcade.TextPool` frame with 5 lines went from 760 µs to 6 µs. - Added a "Drawing Text" page to the programming guide: choosing between `draw_text`, `Text`, `TextPool`, and `create_text_sprite`, what's slow and fast to change, batches, fonts (names, font files, and the bundled Kenney and Liberation fonts), and styles. Updated the text section of the performance tips, and the `draw_text` warning and docstring, which overstated how slow it is. +- Tile maps no longer use Pillow's `Image.getdata()`, which is deprecated and will be removed in Pillow 14, to apply an image layer's transparent color. It's done with Pillow operations instead, which is also faster (a 1024x600 image went from 76 ms to 4 ms). ## 4.0.0.dev8 diff --git a/arcade/tilemap/tilemap.py b/arcade/tilemap/tilemap.py index f1415ae86c..447822fe70 100644 --- a/arcade/tilemap/tilemap.py +++ b/arcade/tilemap/tilemap.py @@ -13,10 +13,12 @@ import math import os from collections import OrderedDict -from collections.abc import Callable, Sequence +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any, cast +import PIL.Image +import PIL.ImageChops import pytiled_parser import pytiled_parser.tiled_object from pytiled_parser import Color @@ -32,7 +34,6 @@ hexagon, ) from arcade.hitbox import HitBoxAlgorithm, RotatableHitBox -from arcade.types import RGBA255 from arcade.types import Color as ArcadeColor if TYPE_CHECKING: @@ -110,6 +111,21 @@ def _get_image_source( return None +def _make_color_transparent(image: PIL.Image.Image, color: Color) -> None: + """ + Make the pixels of an RGBA image that have a color transparent, in place. + + Pixels whose red, green and blue match the color become transparent + white. Their alpha doesn't matter, as in Tiled's ``transparentcolor``. + """ + # Pixels that match the color in each band, combined into one mask + mask = None + for band, value in zip(image.split()[:3], color[:3]): + matches = band.point(lambda pixel, value=value: 255 if pixel == value else 0, mode="1") + mask = matches if mask is None else PIL.ImageChops.logical_and(mask, matches) + image.paste((255, 255, 255, 0), mask=mask) + + def _may_be_flip(tile: pytiled_parser.Tile, texture: Texture) -> Texture: if tile.flipped_diagonally: texture = texture.flip_diagonally() @@ -708,20 +724,7 @@ def _process_image_layer( ) if layer.transparent_color: - # The pillow source doesn't annotate a return type for this method, but: - # 1. The docstring does specify the returned object is sequence-like - # 2. We convert to RGBA mode implicitly in load_or_get_texture above - data: Sequence[RGBA255] = my_texture.image.getdata() # type:ignore - - target = layer.transparent_color - new_data = [] - for item in data: - if item[0] == target[0] and item[1] == target[1] and item[2] == target[2]: - new_data.append((255, 255, 255, 0)) - else: - new_data.append(item) - - my_texture.image.putdata(new_data) + _make_color_transparent(my_texture.image, layer.transparent_color) if not custom_class: custom_class = Sprite diff --git a/tests/fixtures/tilemaps/image_layer_transparent.json b/tests/fixtures/tilemaps/image_layer_transparent.json new file mode 100644 index 0000000000..4de77ebe78 --- /dev/null +++ b/tests/fixtures/tilemaps/image_layer_transparent.json @@ -0,0 +1,347 @@ +{ + "compressionlevel": -1, + "height": 15, + "infinite": false, + "layers": [ + { + "id": 4, + "image": "../../../arcade/resources/assets/images/spritesheets/number_sheet.png", + "name": "img", + "opacity": 1, + "type": "imagelayer", + "visible": true, + "x": 0, + "y": 0, + "transparentcolor": "#ffffff" + }, + { + "data": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "height": 15, + "id": 1, + "name": "Tile Layer 1", + "opacity": 1, + "type": "tilelayer", + "visible": true, + "width": 20, + "x": 0, + "y": 0 + } + ], + "nextlayerid": 5, + "nextobjectid": 21, + "orientation": "orthogonal", + "renderorder": "left-up", + "tiledversion": "1.10.1", + "tileheight": 128, + "tilesets": [ + { + "firstgid": 1, + "source": "../../../arcade/resources/assets/tiled_maps/standard_tileset.json" + } + ], + "tilewidth": 128, + "type": "map", + "version": "1.10", + "width": 20 +} diff --git a/tests/unit/tilemap/test_img_layer.py b/tests/unit/tilemap/test_img_layer.py index 74ccd2cfd0..e559067b0c 100644 --- a/tests/unit/tilemap/test_img_layer.py +++ b/tests/unit/tilemap/test_img_layer.py @@ -1,4 +1,8 @@ +import PIL.Image +from pytiled_parser import Color + import arcade +from arcade.tilemap.tilemap import _make_color_transparent def test_image_layer(): @@ -48,3 +52,40 @@ def test_image_layer_with_scaling(): assert image.height == 300 assert image.left == 640 assert image.top == 704 + + +def test_image_layer_transparent_color(): + """Pixels of the layer's transparent color become transparent""" + tile_map = arcade.load_tilemap(":fixtures:tilemaps/image_layer_transparent.json") + image = tile_map.sprite_lists["img"][0].texture.image + original = PIL.Image.open( + arcade.resources.resolve(":resources:images/spritesheets/number_sheet.png") + ).convert("RGBA") + + data = image.tobytes() + original_data = original.tobytes() + white = 0 + for i in range(0, len(data), 4): + pixel = tuple(data[i : i + 4]) + original_pixel = tuple(original_data[i : i + 4]) + if original_pixel[:3] == (255, 255, 255): + white += 1 + assert pixel == (255, 255, 255, 0) + else: + assert pixel == original_pixel + assert white > 0 + + +def test_make_color_transparent(): + image = PIL.Image.new("RGBA", (4, 1)) + image.putpixel((0, 0), (10, 20, 30, 255)) + image.putpixel((1, 0), (10, 20, 30, 128)) # Alpha doesn't matter + image.putpixel((2, 0), (10, 20, 31, 255)) # One band differs + image.putpixel((3, 0), (0, 0, 0, 255)) + _make_color_transparent(image, Color(10, 20, 30, 255)) + assert [image.getpixel((x, 0)) for x in range(4)] == [ + (255, 255, 255, 0), + (255, 255, 255, 0), + (10, 20, 31, 255), + (0, 0, 0, 255), + ]