Skip to content

Commit f273639

Browse files
authored
Bump pillow to 10.4.0 and mode check PyMunkHitBoxAlgorithm (#2375)
* Bump pillow to 10.4.0 * Replace the use of deprecated / optional attributes * Use getattr for is_animated attribute per the pillow doc * Use getattr for the n_frames attribute as well * Annotate and document load_animated_gif * Use str | Path annotation * Add an Args block documenting resource_name * Cross-ref the resource handles page in the programming guide * Use 10.4.0 transpose constants * Fix PymunkHitBoxAlgorithm.trace_image types + doc * Add ValueError when given non-RGBA image * Add a test for the ValueError * Annotate trace_image's argument * Correct pyright issues inside it * Elaborate on the doc and fix issues with it * Fix pyright issue with tilemap loading * Explain the tile map changes * Silence mypy's issue with Image.Image.open returning ImageFile instead of Image * Run ./make.py format
1 parent d0eda54 commit f273639

File tree

9 files changed

+109
-26
lines changed

9 files changed

+109
-26
lines changed

arcade/context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ def load_texture(
492492

493493
path = resolve(path)
494494

495-
image = Image.open(str(path))
495+
image: Image.Image = Image.open(str(path)) # type: ignore
496496

497497
if flip:
498498
image = image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)

arcade/hitbox/pymunk.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
simplify_curves,
1010
)
1111

12-
from arcade.types import Point2, Point2List
12+
from arcade.types import RGBA255, Point2, Point2List
1313

1414
from .base import HitBoxAlgorithm
1515

@@ -92,18 +92,40 @@ def to_points_list(self, image: Image, line_set: list[Vec2d]) -> Point2List:
9292

9393
def trace_image(self, image: Image) -> PolylineSet:
9494
"""
95-
Trace the image and return a list of line sets.
95+
Trace the image and return a :py:class:~collections.abc.Sequence` of line sets.
9696
97-
These line sets represent the outline of the image or the outline of the
98-
holes in the image. If more than one line set is returned it's important
99-
to pick the one that covers the most of the image.
97+
.. important:: The image :py:attr:`~PIL.Image.Image.mode` must be ``"RGBA"``!
98+
99+
* This method raises a :py:class:`TypeError` when it isn't
100+
* Use :py:meth:`convert("RGBA") <PIL.Image.Image.convert>` to
101+
convert
102+
103+
The returned object will be a :py:mod:`pymunk`
104+
:py:class:`~pymunk.autogeometry.PolylineSet`. Each
105+
:py:class:`list` inside it will contain points as
106+
:py:class:`pymunk.vec2d.Vec2d` instances. These lists
107+
may represent:
108+
109+
* the outline of the image's contents
110+
* the holes in the image
111+
112+
When this method returns more than one line set,
113+
it's important to pick the one which covers the largest
114+
portion of the image.
100115
101116
Args:
102-
image: Image to trace.
117+
image: A :py:class:`PIL.Image.Image` to trace.
118+
119+
Returns:
120+
A :py:mod:`pymunk` object which is a :py:class:`~collections.abc.Sequence`
121+
of :py:class:`~pymunk.autogeometry.PolylineSet` of line sets.
103122
"""
123+
if image.mode != "RGBA":
124+
raise ValueError("Image's mode!='RGBA'! Try using image.convert(\"RGBA\").")
104125

105126
def sample_func(sample_point: Point2) -> int:
106-
"""Method used to sample image."""
127+
"""Function used to sample image."""
128+
# Return 0 when outside of bounds
107129
if (
108130
sample_point[0] < 0
109131
or sample_point[1] < 0
@@ -113,7 +135,8 @@ def sample_func(sample_point: Point2) -> int:
113135
return 0
114136

115137
point_tuple = int(sample_point[0]), int(sample_point[1])
116-
color = image.getpixel(point_tuple)
138+
color: RGBA255 = image.getpixel(point_tuple) # type: ignore
139+
117140
return 255 if color[3] > 0 else 0
118141

119142
# Do a quick check if it is a full tile

arcade/sprite/__init__.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from pathlib import Path
4+
35
import PIL.Image
46

57
from arcade.texture import Texture
@@ -22,9 +24,9 @@
2224
)
2325

2426

25-
def load_animated_gif(resource_name) -> TextureAnimationSprite:
27+
def load_animated_gif(resource_name: str | Path) -> TextureAnimationSprite:
2628
"""
27-
Attempt to load an animated GIF as an :class:`TextureAnimationSprite`.
29+
Attempt to load an animated GIF as a :class:`TextureAnimationSprite`.
2830
2931
.. note::
3032
@@ -33,16 +35,27 @@ def load_animated_gif(resource_name) -> TextureAnimationSprite:
3335
the format better, loading animated GIFs will be pretty buggy. A
3436
good workaround is loading GIFs in another program and exporting them
3537
as PNGs, either as sprite sheets or a frame per file.
38+
39+
Args:
40+
resource_name: A path to a GIF as either a :py:class:`pathlib.Path`
41+
or a :py:class:`str` which may include a
42+
:ref:`resource handle <resource_handles>`.
43+
3644
"""
3745

3846
file_name = resolve(resource_name)
3947
image_object = PIL.Image.open(file_name)
40-
if not image_object.is_animated:
48+
49+
# Pillow doc recommends testing for the is_animated attribute as of 10.0.0
50+
# https://pillow.readthedocs.io/en/stable/deprecations.html#categories
51+
if not getattr(image_object, "is_animated", False) or not (
52+
n_frames := getattr(image_object, "n_frames", 0)
53+
):
4154
raise TypeError(f"The file {resource_name} is not an animated gif.")
4255

4356
sprite = TextureAnimationSprite()
4457
keyframes = []
45-
for frame in range(image_object.n_frames):
58+
for frame in range(n_frames):
4659
image_object.seek(frame)
4760
frame_duration = image_object.info["duration"]
4861
image = image_object.convert("RGBA")

arcade/texture/loading.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from pathlib import Path
44

5-
import PIL.Image
5+
from PIL import Image
66

77
from arcade.hitbox import HitBoxAlgorithm
88
from arcade.resources import resolve
@@ -52,7 +52,7 @@ def load_texture(
5252
if isinstance(file_path, str):
5353
file_path = resolve(file_path)
5454

55-
im = PIL.Image.open(file_path)
55+
im: Image.Image = Image.open(file_path) # type: ignore
5656
if im.mode != "RGBA":
5757
im = im.convert("RGBA")
5858

@@ -66,7 +66,7 @@ def load_image(
6666
file_path: str | Path,
6767
*,
6868
mode: str = "RGBA",
69-
) -> PIL.Image.Image:
69+
) -> Image.Image:
7070
"""
7171
Load a Pillow image from disk (no caching).
7272
@@ -86,9 +86,10 @@ def load_image(
8686
if isinstance(file_path, str):
8787
file_path = resolve(file_path)
8888

89-
im = PIL.Image.open(file_path)
89+
im: Image.Image = Image.open(file_path) # type: ignore
9090
if im.mode != mode:
9191
im = im.convert(mode)
92+
9293
return im
9394

9495

@@ -103,7 +104,7 @@ def load_spritesheet(file_name: str | Path) -> SpriteSheet:
103104
if isinstance(file_name, str):
104105
file_name = resolve(file_name)
105106

106-
im = PIL.Image.open(file_name)
107+
im: Image.Image = Image.open(file_name)
107108
if im.mode != "RGBA":
108109
im = im.convert("RGBA")
109110

arcade/texture/spritesheet.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import TYPE_CHECKING, Literal
55

66
from PIL import Image
7+
from PIL.Image import Transpose
78

89
from arcade.resources import resolve
910

@@ -91,14 +92,14 @@ def flip_left_right(self) -> None:
9192
"""
9293
Flips the internal image left to right.
9394
"""
94-
self._image = self._image.transpose(Image.FLIP_LEFT_RIGHT)
95+
self._image = self._image.transpose(Transpose.FLIP_LEFT_RIGHT)
9596
self._flip_flags = (not self._flip_flags[0], self._flip_flags[1])
9697

9798
def flip_top_bottom(self) -> None:
9899
"""
99100
Flip the internal image top to bottom.
100101
"""
101-
self._image = self._image.transpose(Image.FLIP_TOP_BOTTOM)
102+
self._image = self._image.transpose(Transpose.FLIP_TOP_BOTTOM)
102103
self._flip_flags = (self._flip_flags[0], not self._flip_flags[1])
103104

104105
def get_image(

arcade/texture_atlas/atlas_default.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import PIL.Image
1616
from PIL import Image, ImageDraw
17+
from PIL.Image import Resampling
1718
from pyglet.image.atlas import (
1819
Allocator,
1920
AllocatorException,
@@ -504,10 +505,10 @@ def write_image(self, image: PIL.Image.Image, x: int, y: int) -> None:
504505

505506
# Resize the strips to the border size if larger than 1
506507
if self._border > 1:
507-
strip_top = strip_top.resize((image.width, self._border), Image.NEAREST)
508-
strip_bottom = strip_bottom.resize((image.width, self._border), Image.NEAREST)
509-
strip_left = strip_left.resize((self._border, image.height), Image.NEAREST)
510-
strip_right = strip_right.resize((self._border, image.height), Image.NEAREST)
508+
strip_top = strip_top.resize((image.width, self._border), Resampling.NEAREST)
509+
strip_bottom = strip_bottom.resize((image.width, self._border), Resampling.NEAREST)
510+
strip_left = strip_left.resize((self._border, image.height), Resampling.NEAREST)
511+
strip_right = strip_right.resize((self._border, image.height), Resampling.NEAREST)
511512

512513
tmp.paste(strip_top, (self._border, 0))
513514
tmp.paste(strip_bottom, (self._border, tmp.height - self._border))

arcade/tilemap/tilemap.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import math
1414
import os
1515
from collections import OrderedDict
16+
from collections.abc import Sequence
1617
from pathlib import Path
1718
from typing import TYPE_CHECKING, Any, Callable, cast
1819

@@ -30,6 +31,7 @@
3031
get_window,
3132
)
3233
from arcade.hitbox import HitBoxAlgorithm, RotatableHitBox
34+
from arcade.types import RGBA255
3335
from arcade.types import Color as ArcadeColor
3436

3537
if TYPE_CHECKING:
@@ -699,7 +701,10 @@ def _process_image_layer(
699701
)
700702

701703
if layer.transparent_color:
702-
data = my_texture.image.getdata()
704+
# The pillow source doesn't annotate a return type for this method, but:
705+
# 1. The docstring does specify the returned object is sequence-like
706+
# 2. We convert to RGBA mode implicitly in load_or_get_texture above
707+
data: Sequence[RGBA255] = my_texture.image.getdata() # type:ignore
703708

704709
target = layer.transparent_color
705710
new_data = []

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ dependencies = [
2424
# "pyglet@git+https://github.com/pyglet/pyglet.git@development#egg=pyglet",
2525
# Expected future dev preview release on PyPI (not yet released)
2626
'pyglet==2.1.dev5',
27-
"pillow~=10.2.0",
27+
"pillow~=10.4.0",
2828
"pymunk~=6.6.0",
2929
"pytiled-parser~=2.2.5",
3030
]

tests/unit/physics_engine/test_pymunk.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import pymunk.autogeometry
12
import pytest
3+
from PIL import Image
4+
25
import arcade
6+
from arcade.hitbox import PymunkHitBoxAlgorithm
37

48

59
def test_pymunk():
@@ -55,3 +59,38 @@ def test_pymunk_add_sprite_moment_backwards_compatibility(moment_of_inertia_arg_
5559
set_moment = physics_engine.get_physics_object(sprite).body.moment
5660

5761
assert set_moment == arcade.PymunkPhysicsEngine.MOMENT_INF
62+
63+
64+
def test_pymunk_hitbox_algorithm_trace_image_only_takes_rgba():
65+
"""Test whether non-RGBA modes raise a ValueError.
66+
67+
We expect the hitbox algo to take RGBA image because the alpha
68+
channel is how we determine whether a pixel is empty. See the
69+
pillow doc for more on the modes offered:
70+
https://pillow.readthedocs.io/en/stable/handbook/concepts.html#modes
71+
"""
72+
73+
algo = PymunkHitBoxAlgorithm()
74+
def mode(m: str) -> Image.Image:
75+
return Image.new(
76+
m, # type: ignore
77+
(10, 10), 0)
78+
79+
with pytest.raises(ValueError):
80+
algo.trace_image(mode("1"))
81+
82+
with pytest.raises(ValueError):
83+
algo.trace_image(mode("L"))
84+
85+
with pytest.raises(ValueError):
86+
algo.trace_image(mode("P"))
87+
88+
with pytest.raises(ValueError):
89+
algo.trace_image(mode("RGB"))
90+
91+
with pytest.raises(ValueError):
92+
algo.trace_image(mode("HSV"))
93+
94+
assert isinstance(
95+
algo.trace_image(mode("RGBA")), pymunk.autogeometry.PolylineSet)
96+

0 commit comments

Comments
 (0)