From 2892ee1877c233293d2f02573b1ddbfb843193e4 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 2 Sep 2026 13:19:59 +0300 Subject: [PATCH 1/3] Benchmarks: split PATHS to LOAD_PATHS/SAVE_PATHS; benchmark uncompressed DDS --- Tests/benchmarks.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Tests/benchmarks.py b/Tests/benchmarks.py index 11ed3cf743b..f00f2a895c6 100644 --- a/Tests/benchmarks.py +++ b/Tests/benchmarks.py @@ -45,9 +45,13 @@ # For benchmarks that act on test fixture files, these are the paths loaded. IMAGES_PATH = pathlib.Path(__file__).parent / "images" -PATHS = [ +SAVE_PATHS = [ IMAGES_PATH / "flower2.jpg", ] +LOAD_PATHS = [ + *SAVE_PATHS, + IMAGES_PATH / "uncompressed_rgb.dds", +] # These are derived from the other configuration, above. RGB_MODES = [mode for mode in MODES if mode.startswith("RGB")] @@ -567,7 +571,7 @@ def test_draw_lines_blend( @pytest.mark.benchmark(group="load") -@pytest.mark.parametrize("path", PATHS, ids=_format_path) +@pytest.mark.parametrize("path", LOAD_PATHS, ids=_format_path) def test_load(bench: BenchmarkFixture, path: pathlib.Path) -> None: def run() -> None: with Image.open(path) as im: @@ -577,7 +581,7 @@ def run() -> None: @pytest.mark.benchmark(group="save") -@pytest.mark.parametrize("path", PATHS, ids=_format_path) +@pytest.mark.parametrize("path", SAVE_PATHS, ids=_format_path) def test_save_jpeg(bench: BenchmarkFixture, path: pathlib.Path) -> None: with Image.open(path) as im: im.load() @@ -853,7 +857,7 @@ def test_quantize_grayscale_to_palette( "source_type", [ "synthetic", - *(pytest.param(image, id=f"{image.stem}") for image in PATHS), + *(pytest.param(image, id=f"{image.stem}") for image in LOAD_PATHS), ], ) @pytest.mark.parametrize("palette_type", ["exact", "grayscale", "web"]) From d669888ec816317ea1b7ba1ebe2750793cd0b11d Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 2 Sep 2026 14:00:15 +0300 Subject: [PATCH 2/3] Speed up uncompressed DDS reading --- src/PIL/DdsImagePlugin.py | 43 +++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 40012bc27ad..2de82a9aaae 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -20,7 +20,6 @@ from . import Image, ImageFile, ImagePalette from ._binary import i32le as i32 -from ._binary import o8 from ._binary import o32le as o32 TYPE_CHECKING = False @@ -516,20 +515,38 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: mask_totals.append(mask >> offset) assert self.fd is not None - dest_length = self.state.xsize * self.state.ysize * len(masks) - while len(data) < dest_length: - bytes_read = self.fd.read(bytecount) - if len(bytes_read) < bytecount: + pixel_count = self.state.xsize * self.state.ysize + + src = bytearray() + needed = pixel_count * bytecount + while len(src) < needed: + chunk = self.fd.read(min(needed - len(src), ImageFile.SAFEBLOCK)) + if not chunk: break - value = int.from_bytes(bytes_read, "little") - for i, mask in enumerate(masks): - masked_value = value & mask + src += chunk + pixel_count = len(src) // bytecount + src_length = pixel_count * bytecount + del src[src_length:] + + nmasks = len(masks) + data = bytearray(pixel_count * nmasks) + for i, (mask, offset, total) in enumerate( + zip(masks, mask_offsets, mask_totals) + ): + if not total: # Nothing to do here + continue + if total == 0xFF and offset % 8 == 0: # Whole byte, fast path + data[i::nmasks] = src[offset // 8 :: bytecount] + continue + values = ( + int.from_bytes(src[p : p + bytecount], "little") + for p in range(0, src_length, bytecount) + ) + data[i::nmasks] = bytes( # Remove the zero padding, and scale it to 8 bits - data += o8( - int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255) - if mask_totals[i] - else 0 - ) + int((((value & mask) >> offset) / total) * 255) + for value in values + ) self.set_as_raw(data) return -1, 0 From aae41507bd95ff7b5b55eae857b0c24dc81bec67 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 8 Sep 2026 17:23:33 +0300 Subject: [PATCH 3/3] nmasks -> mask_count Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/DdsImagePlugin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 2de82a9aaae..77cd1dbb8d4 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -528,21 +528,21 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: src_length = pixel_count * bytecount del src[src_length:] - nmasks = len(masks) - data = bytearray(pixel_count * nmasks) + mask_count = len(masks) + data = bytearray(pixel_count * mask_count) for i, (mask, offset, total) in enumerate( zip(masks, mask_offsets, mask_totals) ): if not total: # Nothing to do here continue if total == 0xFF and offset % 8 == 0: # Whole byte, fast path - data[i::nmasks] = src[offset // 8 :: bytecount] + data[i::mask_count] = src[offset // 8 :: bytecount] continue values = ( int.from_bytes(src[p : p + bytecount], "little") for p in range(0, src_length, bytecount) ) - data[i::nmasks] = bytes( + data[i::mask_count] = bytes( # Remove the zero padding, and scale it to 8 bits int((((value & mask) >> offset) / total) * 255) for value in values