From e85700fe483f014ffcbaf91e81456a78f6303337 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 28 Dec 2025 00:54:10 +1100 Subject: [PATCH 1/9] Test PyQt6 on Python 3.14 on Windows (#9353) --- .github/workflows/test-windows.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 7afafe07c..e864763da 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -84,7 +84,7 @@ jobs: python3 -m pip install --upgrade pip - name: Install CPython dependencies - if: "!contains(matrix.python-version, 'pypy') && !contains(matrix.python-version, '3.14') && matrix.architecture != 'x86'" + if: "!contains(matrix.python-version, 'pypy') && matrix.architecture != 'x86'" run: | python3 -m pip install PyQt6 From 4be5b8a2fb0aad9777dc00db9ae353605e745129 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 28 Dec 2025 07:34:57 +1100 Subject: [PATCH 2/9] Use unsigned long for DWORD (#9352) --- src/libImaging/FliDecode.c | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/libImaging/FliDecode.c b/src/libImaging/FliDecode.c index 44994823e..9b494dfa2 100644 --- a/src/libImaging/FliDecode.c +++ b/src/libImaging/FliDecode.c @@ -18,9 +18,9 @@ #define I16(ptr) ((ptr)[0] + ((int)(ptr)[1] << 8)) -#define I32(ptr) \ - ((ptr)[0] + ((INT32)(ptr)[1] << 8) + ((INT32)(ptr)[2] << 16) + \ - ((INT32)(ptr)[3] << 24)) +#define I32(ptr) \ + ((ptr)[0] + ((unsigned long)(ptr)[1] << 8) + ((unsigned long)(ptr)[2] << 16) + \ + ((unsigned long)(ptr)[3] << 24)) #define ERR_IF_DATA_OOB(offset) \ if ((data + (offset)) > ptr + bytes) { \ @@ -31,8 +31,8 @@ int ImagingFliDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t bytes) { UINT8 *ptr; - int framesize; - int c, chunks, advance; + unsigned long framesize, advance; + int c, chunks; int l, lines; int i, j, x = 0, y, ymax; From 66e3d65a72e100e0919b757460099c916d79b5d3 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 29 Dec 2025 02:04:44 +1100 Subject: [PATCH 3/9] Update harfbuzz to 12.3.0 (#9355) --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index e1477634d..e1586b7c5 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -95,7 +95,7 @@ if [[ -n "$IOS_SDK" ]]; then else FREETYPE_VERSION=2.14.1 fi -HARFBUZZ_VERSION=12.2.0 +HARFBUZZ_VERSION=12.3.0 LIBPNG_VERSION=1.6.53 JPEGTURBO_VERSION=3.1.3 OPENJPEG_VERSION=2.5.4 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 30fe26d14..3377d952c 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -116,7 +116,7 @@ V = { "BROTLI": "1.2.0", "FREETYPE": "2.14.1", "FRIBIDI": "1.0.16", - "HARFBUZZ": "12.2.0", + "HARFBUZZ": "12.3.0", "JPEGTURBO": "3.1.3", "LCMS2": "2.17", "LIBAVIF": "1.3.0", From faa843e9c2ba5be8ddf42bede72f63c93ac75158 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:01:23 +1100 Subject: [PATCH 4/9] Simplify WebP code (#9329) --- src/PIL/WebPImagePlugin.py | 41 +++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 2847fed20..e20e40d91 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -45,33 +45,30 @@ class WebPImageFile(ImageFile.ImageFile): def _open(self) -> None: # Use the newer AnimDecoder API to parse the (possibly) animated file, # and access muxed chunks like ICC/EXIF/XMP. + assert self.fp is not None self._decoder = _webp.WebPAnimDecoder(self.fp.read()) # Get info from decoder - self._size, loop_count, bgcolor, frame_count, mode = self._decoder.get_info() - self.info["loop"] = loop_count - bg_a, bg_r, bg_g, bg_b = ( - (bgcolor >> 24) & 0xFF, - (bgcolor >> 16) & 0xFF, - (bgcolor >> 8) & 0xFF, - bgcolor & 0xFF, + self._size, self.info["loop"], bgcolor, self.n_frames, self.rawmode = ( + self._decoder.get_info() + ) + self.info["background"] = ( + (bgcolor >> 16) & 0xFF, # R + (bgcolor >> 8) & 0xFF, # G + bgcolor & 0xFF, # B + (bgcolor >> 24) & 0xFF, # A ) - self.info["background"] = (bg_r, bg_g, bg_b, bg_a) - self.n_frames = frame_count self.is_animated = self.n_frames > 1 - self._mode = "RGB" if mode == "RGBX" else mode - self.rawmode = mode + self._mode = "RGB" if self.rawmode == "RGBX" else self.rawmode # Attempt to read ICC / EXIF / XMP chunks from file - icc_profile = self._decoder.get_chunk("ICCP") - exif = self._decoder.get_chunk("EXIF") - xmp = self._decoder.get_chunk("XMP ") - if icc_profile: - self.info["icc_profile"] = icc_profile - if exif: - self.info["exif"] = exif - if xmp: - self.info["xmp"] = xmp + for key, chunk_name in { + "icc_profile": "ICCP", + "exif": "EXIF", + "xmp": "XMP ", + }.items(): + if value := self._decoder.get_chunk(chunk_name): + self.info[key] = value # Initialize seek state self._reset(reset=False) @@ -129,9 +126,7 @@ class WebPImageFile(ImageFile.ImageFile): self._seek(self.__logical_frame) # We need to load the image data for this frame - data, timestamp, duration = self._get_next() - self.info["timestamp"] = timestamp - self.info["duration"] = duration + data, self.info["timestamp"], self.info["duration"] = self._get_next() self.__loaded = self.__logical_frame # Set tile From a04c9806b12723b8882536325b69b4a1f96733aa Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:03:47 +1100 Subject: [PATCH 5/9] Return LUT from LutBuilder build_default_lut() (#9350) --- Tests/test_imagemorph.py | 5 +++++ src/PIL/ImageMorph.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index ca192a809..12423ebf6 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -281,6 +281,11 @@ def test_pattern_syntax_error(pattern: str) -> None: lb.build_lut() +def test_build_default_lut() -> None: + lb = ImageMorph.LutBuilder(op_name="corner") + assert lb.build_default_lut() == lb.lut + + def test_load_invalid_mrl() -> None: # Arrange invalid_mrl = "Tests/images/hopper.png" diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py index bd70aff7b..4d9517d02 100644 --- a/src/PIL/ImageMorph.py +++ b/src/PIL/ImageMorph.py @@ -92,10 +92,11 @@ class LutBuilder: def add_patterns(self, patterns: list[str]) -> None: self.patterns += patterns - def build_default_lut(self) -> None: + def build_default_lut(self) -> bytearray: symbols = [0, 1] m = 1 << 4 # pos of current pixel self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE)) + return self.lut def get_lut(self) -> bytearray | None: return self.lut From 2ebb3e9964bcfb46e2b8dcaec1917caf67730a7d Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:09:46 +1100 Subject: [PATCH 6/9] Use different variables for Image and ImageFile instances (#9316) --- Tests/helper.py | 7 ++-- Tests/test_bmp_reference.py | 18 +++++----- Tests/test_file_apng.py | 18 +++++----- Tests/test_file_bmp.py | 4 +-- Tests/test_file_dds.py | 8 ++--- Tests/test_file_eps.py | 32 ++++++++--------- Tests/test_file_gif.py | 68 ++++++++++++++++++------------------ Tests/test_file_libtiff.py | 14 ++++---- Tests/test_file_png.py | 37 ++++++++++---------- Tests/test_imageops.py | 6 ++-- Tests/test_pickle.py | 14 ++++---- src/PIL/IptcImagePlugin.py | 7 ++-- src/PIL/SpiderImagePlugin.py | 4 +-- 13 files changed, 118 insertions(+), 119 deletions(-) diff --git a/Tests/helper.py b/Tests/helper.py index dbdd30b42..b93828f58 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -104,10 +104,9 @@ def assert_image_equal_tofile( msg: str | None = None, mode: str | None = None, ) -> None: - with Image.open(filename) as img: - if mode: - img = img.convert(mode) - assert_image_equal(a, img, msg) + with Image.open(filename) as im: + converted_im = im.convert(mode) if mode else im + assert_image_equal(a, converted_im, msg) def assert_image_similar( diff --git a/Tests/test_bmp_reference.py b/Tests/test_bmp_reference.py index 3cd0fbb2d..8fbd73748 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -95,16 +95,16 @@ def test_good() -> None: for f in get_files("g"): try: with Image.open(f) as im: - im.load() with Image.open(get_compare(f)) as compare: - compare.load() - if im.mode == "P": - # assert image similar doesn't really work - # with paletized image, since the palette might - # be differently ordered for an equivalent image. - im = im.convert("RGBA") - compare = compare.convert("RGBA") - assert_image_similar(im, compare, 5) + # assert image similar doesn't really work + # with paletized image, since the palette might + # be differently ordered for an equivalent image. + im_converted = im.convert("RGBA") if im.mode == "P" else im + compare_converted = ( + compare.convert("RGBA") if im.mode == "P" else compare + ) + + assert_image_similar(im_converted, compare_converted, 5) except Exception as msg: # there are three here that are unsupported: diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index d918a24a7..644b7807a 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -278,25 +278,25 @@ def test_apng_mode() -> None: assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) - im = im.convert("RGB") - assert im.getpixel((0, 0)) == (0, 255, 0) - assert im.getpixel((64, 32)) == (0, 255, 0) + im_rgb = im.convert("RGB") + assert im_rgb.getpixel((0, 0)) == (0, 255, 0) + assert im_rgb.getpixel((64, 32)) == (0, 255, 0) with Image.open("Tests/images/apng/mode_palette_alpha.png") as im: assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) - im = im.convert("RGBA") - assert im.getpixel((0, 0)) == (0, 255, 0, 255) - assert im.getpixel((64, 32)) == (0, 255, 0, 255) + im_rgba = im.convert("RGBA") + assert im_rgba.getpixel((0, 0)) == (0, 255, 0, 255) + assert im_rgba.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im: assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) - im = im.convert("RGBA") - assert im.getpixel((0, 0)) == (0, 0, 255, 128) - assert im.getpixel((64, 32)) == (0, 0, 255, 128) + im_rgba = im.convert("RGBA") + assert im_rgba.getpixel((0, 0)) == (0, 0, 255, 128) + assert im_rgba.getpixel((64, 32)) == (0, 0, 255, 128) def test_apng_chunk_errors() -> None: diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index c1c430aa5..28e863459 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -165,9 +165,9 @@ def test_rgba_bitfields() -> None: with Image.open("Tests/images/rgb32bf-rgba.bmp") as im: # So before the comparing the image, swap the channels b, g, r = im.split()[1:] - im = Image.merge("RGB", (r, g, b)) + im_rgb = Image.merge("RGB", (r, g, b)) - assert_image_equal_tofile(im, "Tests/images/bmp/q/rgb32bf-xbgr.bmp") + assert_image_equal_tofile(im_rgb, "Tests/images/bmp/q/rgb32bf-xbgr.bmp") # This test image has been manually hexedited # to change the bitfield compression in the header from XBGR to ABGR diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 60d0c09bc..931ff02f1 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -57,7 +57,7 @@ TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA = "Tests/images/uncompressed_rgb.dds" def test_sanity_dxt1_bc1(image_path: str) -> None: """Check DXT1 and BC1 images can be opened""" with Image.open(TEST_FILE_DXT1.replace(".dds", ".png")) as target: - target = target.convert("RGBA") + target_rgba = target.convert("RGBA") with Image.open(image_path) as im: im.load() @@ -65,7 +65,7 @@ def test_sanity_dxt1_bc1(image_path: str) -> None: assert im.mode == "RGBA" assert im.size == (256, 256) - assert_image_equal(im, target) + assert_image_equal(im, target_rgba) def test_sanity_dxt3() -> None: @@ -520,9 +520,9 @@ def test_save_dx10_bc5(tmp_path: Path) -> None: im.save(out, pixel_format="BC5") assert_image_similar_tofile(im, out, 9.56) - im = hopper("L") + im_l = hopper("L") with pytest.raises(OSError, match="only RGB mode can be written as BC5"): - im.save(out, pixel_format="BC5") + im_l.save(out, pixel_format="BC5") @pytest.mark.parametrize( diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index b50915f28..d4e8db4f4 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -265,9 +265,9 @@ def test_bytesio_object() -> None: img.load() with Image.open(FILE1_COMPARE) as image1_scale1_compare: - image1_scale1_compare = image1_scale1_compare.convert("RGB") - image1_scale1_compare.load() - assert_image_similar(img, image1_scale1_compare, 5) + image1_scale1_compare_rgb = image1_scale1_compare.convert("RGB") + image1_scale1_compare_rgb.load() + assert_image_similar(img, image1_scale1_compare_rgb, 5) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @@ -301,17 +301,17 @@ def test_render_scale1() -> None: with Image.open(FILE1) as image1_scale1: image1_scale1.load() with Image.open(FILE1_COMPARE) as image1_scale1_compare: - image1_scale1_compare = image1_scale1_compare.convert("RGB") - image1_scale1_compare.load() - assert_image_similar(image1_scale1, image1_scale1_compare, 5) + image1_scale1_compare_rgb = image1_scale1_compare.convert("RGB") + image1_scale1_compare_rgb.load() + assert_image_similar(image1_scale1, image1_scale1_compare_rgb, 5) # Non-zero bounding box with Image.open(FILE2) as image2_scale1: image2_scale1.load() with Image.open(FILE2_COMPARE) as image2_scale1_compare: - image2_scale1_compare = image2_scale1_compare.convert("RGB") - image2_scale1_compare.load() - assert_image_similar(image2_scale1, image2_scale1_compare, 10) + image2_scale1_compare_rgb = image2_scale1_compare.convert("RGB") + image2_scale1_compare_rgb.load() + assert_image_similar(image2_scale1, image2_scale1_compare_rgb, 10) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @@ -324,18 +324,16 @@ def test_render_scale2() -> None: assert isinstance(image1_scale2, EpsImagePlugin.EpsImageFile) image1_scale2.load(scale=2) with Image.open(FILE1_COMPARE_SCALE2) as image1_scale2_compare: - image1_scale2_compare = image1_scale2_compare.convert("RGB") - image1_scale2_compare.load() - assert_image_similar(image1_scale2, image1_scale2_compare, 5) + image1_scale2_compare_rgb = image1_scale2_compare.convert("RGB") + assert_image_similar(image1_scale2, image1_scale2_compare_rgb, 5) # Non-zero bounding box with Image.open(FILE2) as image2_scale2: assert isinstance(image2_scale2, EpsImagePlugin.EpsImageFile) image2_scale2.load(scale=2) with Image.open(FILE2_COMPARE_SCALE2) as image2_scale2_compare: - image2_scale2_compare = image2_scale2_compare.convert("RGB") - image2_scale2_compare.load() - assert_image_similar(image2_scale2, image2_scale2_compare, 10) + image2_scale2_compare_rgb = image2_scale2_compare.convert("RGB") + assert_image_similar(image2_scale2, image2_scale2_compare_rgb, 10) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @@ -345,8 +343,8 @@ def test_render_scale2() -> None: def test_resize(filename: str) -> None: with Image.open(filename) as im: new_size = (100, 100) - im = im.resize(new_size) - assert im.size == new_size + im_resized = im.resize(new_size) + assert im_resized.size == new_size @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index acf79374e..2615f5a60 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -327,14 +327,13 @@ def test_loading_multiple_palettes(path: str, mode: str) -> None: im.seek(1) assert im.mode == mode - if mode == "RGBA": - im = im.convert("RGB") + im_rgb = im.convert("RGB") if mode == "RGBA" else im # Check a color only from the old palette - assert im.getpixel((0, 0)) == original_color + assert im_rgb.getpixel((0, 0)) == original_color # Check a color from the new palette - assert im.getpixel((24, 24)) not in first_frame_colors + assert im_rgb.getpixel((24, 24)) not in first_frame_colors def test_headers_saving_for_animated_gifs(tmp_path: Path) -> None: @@ -354,16 +353,16 @@ def test_palette_handling(tmp_path: Path) -> None: # see https://github.com/python-pillow/Pillow/issues/513 with Image.open(TEST_GIF) as im: - im = im.convert("RGB") + im_rgb = im.convert("RGB") - im = im.resize((100, 100), Image.Resampling.LANCZOS) - im2 = im.convert("P", palette=Image.Palette.ADAPTIVE, colors=256) + im_rgb = im_rgb.resize((100, 100), Image.Resampling.LANCZOS) + im_p = im_rgb.convert("P", palette=Image.Palette.ADAPTIVE, colors=256) - f = tmp_path / "temp.gif" - im2.save(f, optimize=True) + f = tmp_path / "temp.gif" + im_p.save(f, optimize=True) with Image.open(f) as reloaded: - assert_image_similar(im, reloaded.convert("RGB"), 10) + assert_image_similar(im_rgb, reloaded.convert("RGB"), 10) def test_palette_434(tmp_path: Path) -> None: @@ -383,35 +382,36 @@ def test_palette_434(tmp_path: Path) -> None: with roundtrip(im, optimize=True) as reloaded: assert_image_similar(im, reloaded, 1) - im = im.convert("RGB") - # check automatic P conversion - with roundtrip(im) as reloaded: - reloaded = reloaded.convert("RGB") - assert_image_equal(im, reloaded) + im_rgb = im.convert("RGB") + + # check automatic P conversion + with roundtrip(im_rgb) as reloaded: + reloaded = reloaded.convert("RGB") + assert_image_equal(im_rgb, reloaded) @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") def test_save_netpbm_bmp_mode(tmp_path: Path) -> None: with Image.open(TEST_GIF) as img: - img = img.convert("RGB") + img_rgb = img.convert("RGB") - tempfile = str(tmp_path / "temp.gif") - b = BytesIO() - GifImagePlugin._save_netpbm(img, b, tempfile) - with Image.open(tempfile) as reloaded: - assert_image_similar(img, reloaded.convert("RGB"), 0) + tempfile = str(tmp_path / "temp.gif") + b = BytesIO() + GifImagePlugin._save_netpbm(img_rgb, b, tempfile) + with Image.open(tempfile) as reloaded: + assert_image_similar(img_rgb, reloaded.convert("RGB"), 0) @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") def test_save_netpbm_l_mode(tmp_path: Path) -> None: with Image.open(TEST_GIF) as img: - img = img.convert("L") + img_l = img.convert("L") tempfile = str(tmp_path / "temp.gif") b = BytesIO() - GifImagePlugin._save_netpbm(img, b, tempfile) + GifImagePlugin._save_netpbm(img_l, b, tempfile) with Image.open(tempfile) as reloaded: - assert_image_similar(img, reloaded.convert("L"), 0) + assert_image_similar(img_l, reloaded.convert("L"), 0) def test_seek() -> None: @@ -1038,9 +1038,9 @@ def test_webp_background(tmp_path: Path) -> None: im.save(out) # Test non-opaque WebP background - im = Image.new("L", (100, 100), "#000") - im.info["background"] = (0, 0, 0, 0) - im.save(out) + im2 = Image.new("L", (100, 100), "#000") + im2.info["background"] = (0, 0, 0, 0) + im2.save(out) def test_comment(tmp_path: Path) -> None: @@ -1048,16 +1048,16 @@ def test_comment(tmp_path: Path) -> None: assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0" out = tmp_path / "temp.gif" - im = Image.new("L", (100, 100), "#000") - im.info["comment"] = b"Test comment text" - im.save(out) + im2 = Image.new("L", (100, 100), "#000") + im2.info["comment"] = b"Test comment text" + im2.save(out) with Image.open(out) as reread: - assert reread.info["comment"] == im.info["comment"] + assert reread.info["comment"] == im2.info["comment"] - im.info["comment"] = "Test comment text" - im.save(out) + im2.info["comment"] = "Test comment text" + im2.save(out) with Image.open(out) as reread: - assert reread.info["comment"] == im.info["comment"].encode() + assert reread.info["comment"] == im2.info["comment"].encode() # Test that GIF89a is used for comments assert reread.info["version"] == b"GIF89a" diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index e36b5f39e..e05563c6a 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -516,12 +516,12 @@ class TestFileLibTiff(LibTiffTestCase): # and save to compressed tif. out = tmp_path / "temp.tif" with Image.open("Tests/images/pport_g4.tif") as im: - im = im.convert("L") + im_l = im.convert("L") - im = im.filter(ImageFilter.GaussianBlur(4)) - im.save(out, compression="tiff_adobe_deflate") + im_l = im_l.filter(ImageFilter.GaussianBlur(4)) + im_l.save(out, compression="tiff_adobe_deflate") - assert_image_equal_tofile(im, out) + assert_image_equal_tofile(im_l, out) def test_compressions(self, tmp_path: Path) -> None: # Test various tiff compressions and assert similar image content but reduced @@ -1087,8 +1087,10 @@ class TestFileLibTiff(LibTiffTestCase): data = data[:102] + b"\x02" + data[103:] with Image.open(io.BytesIO(data)) as im: - im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) - assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png") + im_transposed = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + assert_image_equal_tofile( + im_transposed, "Tests/images/old-style-jpeg-compression.png" + ) def test_open_missing_samplesperpixel(self) -> None: with Image.open( diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 7f163a4d6..e9830cd3d 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -101,12 +101,13 @@ class TestFilePng: assert im.get_format_mimetype() == "image/png" for mode in ["1", "L", "P", "RGB", "I;16", "I;16B"]: - im = hopper(mode) - im.save(test_file) + im1 = hopper(mode) + im1.save(test_file) with Image.open(test_file) as reloaded: - if mode == "I;16B": - reloaded = reloaded.convert(mode) - assert_image_equal(reloaded, im) + converted_reloaded = ( + reloaded.convert(mode) if mode == "I;16B" else reloaded + ) + assert_image_equal(converted_reloaded, im1) def test_invalid_file(self) -> None: invalid_file = "Tests/images/flower.jpg" @@ -225,11 +226,11 @@ class TestFilePng: test_file = "Tests/images/pil123p.png" with Image.open(test_file) as im: assert_image(im, "P", (162, 150)) - im = im.convert("RGBA") - assert_image(im, "RGBA", (162, 150)) + im_rgba = im.convert("RGBA") + assert_image(im_rgba, "RGBA", (162, 150)) # image has 124 unique alpha values - colors = im.getchannel("A").getcolors() + colors = im_rgba.getchannel("A").getcolors() assert colors is not None assert len(colors) == 124 @@ -239,11 +240,11 @@ class TestFilePng: assert im.info["transparency"] == (0, 255, 52) assert_image(im, "RGB", (64, 64)) - im = im.convert("RGBA") - assert_image(im, "RGBA", (64, 64)) + im_rgba = im.convert("RGBA") + assert_image(im_rgba, "RGBA", (64, 64)) # image has 876 transparent pixels - colors = im.getchannel("A").getcolors() + colors = im_rgba.getchannel("A").getcolors() assert colors is not None assert colors[0][0] == 876 @@ -262,11 +263,11 @@ class TestFilePng: assert len(im.info["transparency"]) == 256 assert_image(im, "P", (162, 150)) - im = im.convert("RGBA") - assert_image(im, "RGBA", (162, 150)) + im_rgba = im.convert("RGBA") + assert_image(im_rgba, "RGBA", (162, 150)) # image has 124 unique alpha values - colors = im.getchannel("A").getcolors() + colors = im_rgba.getchannel("A").getcolors() assert colors is not None assert len(colors) == 124 @@ -285,13 +286,13 @@ class TestFilePng: assert im.info["transparency"] == 164 assert im.getpixel((31, 31)) == 164 assert_image(im, "P", (64, 64)) - im = im.convert("RGBA") - assert_image(im, "RGBA", (64, 64)) + im_rgba = im.convert("RGBA") + assert_image(im_rgba, "RGBA", (64, 64)) - assert im.getpixel((31, 31)) == (0, 255, 52, 0) + assert im_rgba.getpixel((31, 31)) == (0, 255, 52, 0) # image has 876 transparent pixels - colors = im.getchannel("A").getcolors() + colors = im_rgba.getchannel("A").getcolors() assert colors is not None assert colors[0][0] == 876 diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 63cd0e4d4..35fe3bb8a 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -457,9 +457,9 @@ def test_exif_transpose() -> None: assert 0x0112 not in transposed_im.getexif() # Orientation set directly on Image.Exif - im = hopper() - im.getexif()[0x0112] = 3 - transposed_im = ImageOps.exif_transpose(im) + im1 = hopper() + im1.getexif()[0x0112] = 3 + transposed_im = ImageOps.exif_transpose(im1) assert 0x0112 not in transposed_im.getexif() diff --git a/Tests/test_pickle.py b/Tests/test_pickle.py index fc76f81e9..2447ae67a 100644 --- a/Tests/test_pickle.py +++ b/Tests/test_pickle.py @@ -19,30 +19,28 @@ def helper_pickle_file( # Arrange with Image.open(test_file) as im: filename = tmp_path / "temp.pkl" - if mode: - im = im.convert(mode) + converted_im = im.convert(mode) if mode else im # Act with open(filename, "wb") as f: - pickle.dump(im, f, protocol) + pickle.dump(converted_im, f, protocol) with open(filename, "rb") as f: loaded_im = pickle.load(f) # Assert - assert im == loaded_im + assert converted_im == loaded_im def helper_pickle_string(protocol: int, test_file: str, mode: str | None) -> None: with Image.open(test_file) as im: - if mode: - im = im.convert(mode) + converted_im = im.convert(mode) if mode else im # Act - dumped_string = pickle.dumps(im, protocol) + dumped_string = pickle.dumps(converted_im, protocol) loaded_im = pickle.loads(dumped_string) # Assert - assert im == loaded_im + assert converted_im == loaded_im @pytest.mark.parametrize( diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index c28f4dcc7..cf7067daa 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -154,10 +154,11 @@ class IptcImageFile(ImageFile.ImageFile): if band is not None: bands = [Image.new("L", _im.size)] * Image.getmodebands(self.mode) bands[band] = _im - _im = Image.merge(self.mode, bands) + im = Image.merge(self.mode, bands) else: - _im.load() - self.im = _im.im + im = _im + im.load() + self.im = im.im self.tile = [] return ImageFile.ImageFile.load(self) diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index 868019e80..7c3e84d74 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -323,9 +323,9 @@ if __name__ == "__main__": outfile = sys.argv[2] # perform some image operation - im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + transposed_im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) print( f"saving a flipped version of {os.path.basename(filename)} " f"as {outfile} " ) - im.save(outfile, SpiderImageFile.format) + transposed_im.save(outfile, SpiderImageFile.format) From 080afe1bf7757208ba2c7995d1ca2754e3f78c2f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:46:02 +0200 Subject: [PATCH 7/9] Replace pre-commit with prek --- .github/workflows/lint.yml | 17 ++++++++--------- .github/zizmor.yml | 1 - tox.ini | 7 ++++--- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 77d1d1caa..5f72550c0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,18 +2,17 @@ name: Lint on: [push, pull_request, workflow_dispatch] +permissions: {} + env: FORCE_COLOR: 1 -permissions: - contents: read - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - build: + lint: runs-on: ubuntu-latest @@ -24,13 +23,13 @@ jobs: with: persist-credentials: false - - name: pre-commit cache + - name: prek cache uses: actions/cache@v4 with: - path: ~/.cache/pre-commit - key: lint-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }} + path: ~/.cache/prek + key: lint-prek-${{ hashFiles('**/.pre-commit-config.yaml') }} restore-keys: | - lint-pre-commit- + lint-prek- - name: Set up Python uses: actions/setup-python@v6 @@ -50,7 +49,7 @@ jobs: - name: Lint run: tox -e lint env: - PRE_COMMIT_COLOR: always + PREK_COLOR: always - name: Mypy run: tox -e mypy diff --git a/.github/zizmor.yml b/.github/zizmor.yml index e60c79441..f4949c30c 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,4 +1,3 @@ -# Configuration for the zizmor static analysis tool, run via pre-commit in CI # https://docs.zizmor.sh/configuration/ rules: obfuscation: diff --git a/tox.ini b/tox.ini index 7f116c6e7..de18946ef 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ requires = tox>=4.2 env_list = lint + mypy py{py3, 315, 314, 313, 312, 311, 310} [testenv] @@ -18,11 +19,11 @@ commands = skip_install = true deps = check-manifest - pre-commit + prek pass_env = - PRE_COMMIT_COLOR + PREK_COLOR commands = - pre-commit run --all-files --show-diff-on-failure + prek run --all-files --show-diff-on-failure check-manifest [testenv:mypy] From 81e80f7a50e2e884d67d09ed7c735ee87f0f0f0a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:35:41 +0200 Subject: [PATCH 8/9] Install and run tox/lint/mypy via uv --- .github/workflows/lint.yml | 51 +++++++++++--------------------------- 1 file changed, 14 insertions(+), 37 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5f72550c0..e2f8bf47a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,6 +6,8 @@ permissions: {} env: FORCE_COLOR: 1 + PREK_COLOR: always + RUFF_OUTPUT_FORMAT: github concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -13,43 +15,18 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - name: Lint - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: prek cache - uses: actions/cache@v4 - with: - path: ~/.cache/prek - key: lint-prek-${{ hashFiles('**/.pre-commit-config.yaml') }} - restore-keys: | - lint-prek- - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.x" - cache: pip - cache-dependency-path: "setup.py" - - - name: Build system information - run: python3 .github/workflows/system-info.py - - - name: Install dependencies - run: | - python3 -m pip install -U pip - python3 -m pip install -U tox - - - name: Lint - run: tox -e lint - env: - PREK_COLOR: always - - - name: Mypy - run: tox -e mypy + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Lint + run: uvx --with tox-uv tox -e lint + - name: Mypy + run: uvx --with tox-uv tox -e mypy From 0a9a47fb9b9780fed4cf859da12cfa5fc867a71e Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 31 Dec 2025 23:02:31 +1100 Subject: [PATCH 9/9] Update ImageMorph documentation (#9349) Co-authored-by: Andrew Murray Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- docs/reference/ImageMorph.rst | 46 ++++++++++++++- src/PIL/ImageMorph.py | 108 +++++++++++++++++++++++++--------- 2 files changed, 122 insertions(+), 32 deletions(-) diff --git a/docs/reference/ImageMorph.rst b/docs/reference/ImageMorph.rst index 30b89a54d..77b96058a 100644 --- a/docs/reference/ImageMorph.rst +++ b/docs/reference/ImageMorph.rst @@ -4,10 +4,50 @@ :py:mod:`~PIL.ImageMorph` module ================================ -The :py:mod:`~PIL.ImageMorph` module provides morphology operations on images. +The :py:mod:`~PIL.ImageMorph` module allows `morphology`_ operators ("MorphOp") to be +applied to L mode images:: -.. automodule:: PIL.ImageMorph + from PIL import Image, ImageMorph + img = Image.open("Tests/images/hopper.bw") + mop = ImageMorph.MorphOp(op_name="erosion4") + count, imgOut = mop.apply(img) + imgOut.show() + +.. _morphology: https://en.wikipedia.org/wiki/Mathematical_morphology + +In addition to applying operators, you can also analyse images. + +You can inspect an image in isolation to determine which pixels are non-empty:: + + print(mop.get_on_pixels(img)) # [(0, 0), (1, 0), (2, 0), ...] + +Or you can retrieve a list of pixels that match the operator. This is the number of +pixels that will be non-empty after the operator is applied:: + + coords = mop.match(img) + print(coords) # [(17, 1), (18, 1), (34, 1), ...] + print(len(coords)) # 550 + + imgOut = mop.apply(img)[1] + print(len(mop.get_on_pixels(imgOut))) # 550 + +If you would like more customized operators, you can pass patterns to the MorphOp +class:: + + mop = ImageMorph.MorphOp(patterns=["1:(... ... ...)->0", "4:(00. 01. ...)->1"]) + +Or you can pass lookup table ("LUT") data directly. This LUT data can be constructed +with the :py:class:`~PIL.ImageMorph.LutBuilder`:: + + builder = ImageMorph.LutBuilder() + mop = ImageMorph.MorphOp(lut=builder.build_lut()) + +.. autoclass:: LutBuilder + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: MorphOp :members: :undoc-members: :show-inheritance: - :noindex: diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py index 4d9517d02..90e199117 100644 --- a/src/PIL/ImageMorph.py +++ b/src/PIL/ImageMorph.py @@ -65,10 +65,12 @@ class LutBuilder: def __init__( self, patterns: list[str] | None = None, op_name: str | None = None ) -> None: - if patterns is not None: - self.patterns = patterns - else: - self.patterns = [] + """ + :param patterns: A list of input patterns, or None. + :param op_name: The name of a known pattern. One of "corner", "dilation4", + "dilation8", "erosion4", "erosion8" or "edge". + :exception Exception: If the op_name is not recognized. + """ self.lut: bytearray | None = None if op_name is not None: known_patterns = { @@ -88,21 +90,38 @@ class LutBuilder: raise Exception(msg) self.patterns = known_patterns[op_name] + elif patterns is not None: + self.patterns = patterns + else: + self.patterns = [] def add_patterns(self, patterns: list[str]) -> None: + """ + Append to list of patterns. + + :param patterns: Additional patterns. + """ self.patterns += patterns def build_default_lut(self) -> bytearray: + """ + Set the current LUT, and return it. + + This is the default LUT that patterns will be applied against when building. + """ symbols = [0, 1] m = 1 << 4 # pos of current pixel self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE)) return self.lut def get_lut(self) -> bytearray | None: + """ + Returns the current LUT + """ return self.lut def _string_permute(self, pattern: str, permutation: list[int]) -> str: - """string_permute takes a pattern and a permutation and returns the + """Takes a pattern and a permutation and returns the string permuted according to the permutation list. """ assert len(permutation) == 9 @@ -111,7 +130,7 @@ class LutBuilder: def _pattern_permute( self, basic_pattern: str, options: str, basic_result: int ) -> list[tuple[str, int]]: - """pattern_permute takes a basic pattern and its result and clones + """Takes a basic pattern and its result and clones the pattern according to the modifications described in the $options parameter. It returns a list of all cloned patterns.""" patterns = [(basic_pattern, basic_result)] @@ -141,10 +160,9 @@ class LutBuilder: return patterns def build_lut(self) -> bytearray: - """Compile all patterns into a morphology lut. + """Compile all patterns into a morphology LUT, and return it. - TBD :Build based on (file) morphlut:modify_lut - """ + This is the data to be passed into MorphOp.""" self.build_default_lut() assert self.lut is not None patterns = [] @@ -164,15 +182,14 @@ class LutBuilder: patterns += self._pattern_permute(pattern, options, result) - # compile the patterns into regular expressions for speed + # Compile the patterns into regular expressions for speed compiled_patterns = [] for pattern in patterns: p = pattern[0].replace(".", "X").replace("X", "[01]") compiled_patterns.append((re.compile(p), pattern[1])) # Step through table and find patterns that match. - # Note that all the patterns are searched. The last one - # caught overrides + # Note that all the patterns are searched. The last one found takes priority for i in range(LUT_SIZE): # Build the bit pattern bitpattern = bin(i)[2:] @@ -194,18 +211,30 @@ class MorphOp: op_name: str | None = None, patterns: list[str] | None = None, ) -> None: - """Create a binary morphological operator""" - self.lut = lut - if op_name is not None: - self.lut = LutBuilder(op_name=op_name).build_lut() - elif patterns is not None: - self.lut = LutBuilder(patterns=patterns).build_lut() + """Create a binary morphological operator. + + If the LUT is not provided, then it is built using LutBuilder from the op_name + or the patterns. + + :param lut: The LUT data. + :param patterns: A list of input patterns, or None. + :param op_name: The name of a known pattern. One of "corner", "dilation4", + "dilation8", "erosion4", "erosion8", "edge". + :exception Exception: If the op_name is not recognized. + """ + if patterns is None and op_name is None: + self.lut = lut + else: + self.lut = LutBuilder(patterns, op_name).build_lut() def apply(self, image: Image.Image) -> tuple[int, Image.Image]: - """Run a single morphological operation on an image + """Run a single morphological operation on an image. Returns a tuple of the number of changed pixels and the - morphed image""" + morphed image. + + :exception Exception: If the current operator is None. + :exception ValueError: If the image is not L mode.""" if self.lut is None: msg = "No operator loaded" raise Exception(msg) @@ -213,7 +242,7 @@ class MorphOp: if image.mode != "L": msg = "Image mode must be L" raise ValueError(msg) - outimage = Image.new(image.mode, image.size, None) + outimage = Image.new(image.mode, image.size) count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim()) return count, outimage @@ -221,8 +250,12 @@ class MorphOp: """Get a list of coordinates matching the morphological operation on an image. - Returns a list of tuples of (x,y) coordinates - of all matching pixels. See :ref:`coordinate-system`.""" + Returns a list of tuples of (x,y) coordinates of all matching pixels. See + :ref:`coordinate-system`. + + :param image: An L-mode image. + :exception Exception: If the current operator is None. + :exception ValueError: If the image is not L mode.""" if self.lut is None: msg = "No operator loaded" raise Exception(msg) @@ -233,10 +266,13 @@ class MorphOp: return _imagingmorph.match(bytes(self.lut), image.getim()) def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]: - """Get a list of all turned on pixels in a binary image + """Get a list of all turned on pixels in a grayscale image - Returns a list of tuples of (x,y) coordinates - of all matching pixels. See :ref:`coordinate-system`.""" + Returns a list of tuples of (x,y) coordinates of all non-empty pixels. See + :ref:`coordinate-system`. + + :param image: An L-mode image. + :exception ValueError: If the image is not L mode.""" if image.mode != "L": msg = "Image mode must be L" @@ -244,7 +280,12 @@ class MorphOp: return _imagingmorph.get_on_pixels(image.getim()) def load_lut(self, filename: str) -> None: - """Load an operator from an mrl file""" + """ + Load an operator from an mrl file + + :param filename: The file to read from. + :exception Exception: If the length of the file data is not 512. + """ with open(filename, "rb") as f: self.lut = bytearray(f.read()) @@ -254,7 +295,12 @@ class MorphOp: raise Exception(msg) def save_lut(self, filename: str) -> None: - """Save an operator to an mrl file""" + """ + Save an operator to an mrl file. + + :param filename: The destination file. + :exception Exception: If the current operator is None. + """ if self.lut is None: msg = "No operator loaded" raise Exception(msg) @@ -262,5 +308,9 @@ class MorphOp: f.write(self.lut) def set_lut(self, lut: bytearray | None) -> None: - """Set the lut from an external source""" + """ + Set the LUT from an external source + + :param lut: A new LUT. + """ self.lut = lut