diff --git a/CHANGES.rst b/CHANGES.rst index eeb733283f0..5a1095b6187 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,18 @@ Changelog (Pillow) 9.3.0 (unreleased) ------------------ +- Added conversion between RGB/RGBA/RGBX and LAB #6647 + [radarhere] + +- Do not attempt normalization if mode is already normal #6644 + [radarhere] + +- Fixed seeking to an L frame in a GIF #6576 + [radarhere] + +- Consider all frames when selecting mode for PNG save_all #6610 + [radarhere] + - Don't reassign crc on ChunkStream close #6627 [wiredfool, radarhere] diff --git a/Tests/images/no_palette_after_rgb.gif b/Tests/images/no_palette_after_rgb.gif new file mode 100644 index 00000000000..8704c464cc4 Binary files /dev/null and b/Tests/images/no_palette_after_rgb.gif differ diff --git a/Tests/images/palette_not_needed_for_second_frame.gif b/Tests/images/palette_not_needed_for_second_frame.gif new file mode 100644 index 00000000000..0617291d152 Binary files /dev/null and b/Tests/images/palette_not_needed_for_second_frame.gif differ diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index cdaad5940af..1f5567163c7 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -647,6 +647,16 @@ def test_seek_after_close(): im.seek(0) +@pytest.mark.parametrize("mode", ("RGBA", "RGB", "P")) +def test_different_modes_in_later_frames(mode, tmp_path): + test_file = str(tmp_path / "temp.png") + + im = Image.new("L", (1, 1)) + im.save(test_file, save_all=True, append_images=[Image.new(mode, (1, 1))]) + with Image.open(test_file) as reloaded: + assert reloaded.mode == mode + + def test_constants_deprecation(): for enum, prefix in { PngImagePlugin.Disposal: "APNG_DISPOSE_", diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index c612c573a42..926f5c1eea8 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -83,6 +83,21 @@ def test_l_mode_transparency(): assert im.load()[0, 0] == 128 +def test_l_mode_after_rgb(): + with Image.open("Tests/images/no_palette_after_rgb.gif") as im: + im.seek(1) + assert im.mode == "RGB" + + im.seek(2) + assert im.mode == "RGB" + + +def test_palette_not_needed_for_second_frame(): + with Image.open("Tests/images/palette_not_needed_for_second_frame.gif") as im: + im.seek(1) + assert_image_similar(im, hopper("L").convert("RGB"), 8) + + def test_strategy(): with Image.open("Tests/images/iss634.gif") as im: expected_rgb_always = im.convert("RGB") diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py index 1a78f8b4c4c..902d8bf8fa9 100644 --- a/Tests/test_image_convert.py +++ b/Tests/test_image_convert.py @@ -38,6 +38,12 @@ def convert(im, mode): convert(im, output_mode) +def test_unsupported_conversion(): + im = hopper() + with pytest.raises(ValueError): + im.convert("INVALID") + + def test_default(): im = hopper("P") @@ -242,6 +248,17 @@ def test_p2pa_palette(): assert im_pa.getpalette() == im.getpalette() +@pytest.mark.parametrize("mode", ("RGB", "RGBA", "RGBX")) +def test_rgb_lab(mode): + im = Image.new(mode, (1, 1)) + converted_im = im.convert("LAB") + assert converted_im.getpixel((0, 0)) == (0, 128, 128) + + im = Image.new("LAB", (1, 1), (255, 0, 0)) + converted_im = im.convert(mode) + assert converted_im.getpixel((0, 0))[:3] == (0, 255, 255) + + def test_matrix_illegal_conversion(): # Arrange im = hopper("CMYK") diff --git a/docs/releasenotes/9.1.0.rst b/docs/releasenotes/9.1.0.rst index 57646e5588a..48ce6fef70c 100644 --- a/docs/releasenotes/9.1.0.rst +++ b/docs/releasenotes/9.1.0.rst @@ -202,7 +202,7 @@ Pillow now builds binary wheels for musllinux, suitable for Linux distributions (rather than the glibc library used by manylinux wheels). See :pep:`656`. ImageShow temporary files on Unix -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When calling :py:meth:`~PIL.Image.Image.show` or using :py:mod:`~PIL.ImageShow`, a temporary file is created from the image. On Unix, Pillow will no longer delete these diff --git a/docs/releasenotes/9.3.0.rst b/docs/releasenotes/9.3.0.rst index 7109a09f2b4..0b8696040ff 100644 --- a/docs/releasenotes/9.3.0.rst +++ b/docs/releasenotes/9.3.0.rst @@ -63,7 +63,13 @@ TODO Other Changes ============= -Added DDS ATI1 and ATI2 reading -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Added DDS ATI1, ATI2 and BC6H reading +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Support has been added to read the ATI1 and ATI2 formats of DDS images. +Support has been added to read the ATI1, ATI2 and BC6H formats of DDS images. + +Show all frames with ImageShow +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When calling :py:meth:`~PIL.Image.Image.show` or using +:py:mod:`~PIL.ImageShow`, all frames will now be shown. diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 9df31e5eeda..dd1b21f2e63 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -274,6 +274,8 @@ def _seek(self, frame, update_image=True): p = self.fp.read(3 << bits) if self._is_palette_needed(p): palette = ImagePalette.raw("RGB", p) + else: + palette = False # image data bits = self.fp.read(1)[0] @@ -298,7 +300,7 @@ def _seek(self, frame, update_image=True): if self.dispose: self.im.paste(self.dispose, self.dispose_extent) - self._frame_palette = palette or self.global_palette + self._frame_palette = palette if palette is not None else self.global_palette self._frame_transparency = frame_transparency if frame == 0: if self._frame_palette: @@ -438,16 +440,13 @@ def load_end(self): self.mode = "RGB" self.im = self.im.convert(self.mode, Image.Dither.FLOYDSTEINBERG) return - if self.mode == "P" and self._prev_im: - if self._frame_transparency is not None: - self.im.putpalettealpha(self._frame_transparency, 0) - frame_im = self.im.convert("RGBA") - else: - frame_im = self.im.convert("RGB") + if not self._prev_im: + return + if self._frame_transparency is not None: + self.im.putpalettealpha(self._frame_transparency, 0) + frame_im = self.im.convert("RGBA") else: - if not self._prev_im: - return - frame_im = self.im + frame_im = self.im.convert("RGB") frame_im = self._crop(frame_im, self.dispose_extent) self.im = self._prev_im diff --git a/src/PIL/Image.py b/src/PIL/Image.py index d5080a05c2f..7faf0c2481b 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -880,7 +880,7 @@ def convert( and the palette can be represented without a palette. The current version supports all possible conversions between - "L", "RGB" and "CMYK." The ``matrix`` argument only supports "L" + "L", "RGB" and "CMYK". The ``matrix`` argument only supports "L" and "RGB". When translating a color image to greyscale (mode "L"), @@ -899,6 +899,9 @@ def convert( this passes the operation to :py:meth:`~PIL.Image.Image.quantize`, and ``dither`` and ``palette`` are ignored. + When converting from "PA", if an "RGBA" palette is present, the alpha + channel from the image will be used instead of the values from the palette. + :param mode: The requested mode. See: :ref:`concept-modes`. :param matrix: An optional conversion matrix. If given, this should be 4- or 12-tuple containing floating point values. @@ -1039,6 +1042,19 @@ def convert_transparency(m, v): warnings.warn("Couldn't allocate palette entry for transparency") return new + if "LAB" in (self.mode, mode): + other_mode = mode if self.mode == "LAB" else self.mode + if other_mode in ("RGB", "RGBA", "RGBX"): + from . import ImageCms + + srgb = ImageCms.createProfile("sRGB") + lab = ImageCms.createProfile("LAB") + profiles = [lab, srgb] if self.mode == "LAB" else [srgb, lab] + transform = ImageCms.buildTransform( + profiles[0], profiles[1], self.mode, mode + ) + return transform.apply(self) + # colorspace conversion if dither is None: dither = Dither.FLOYDSTEINBERG @@ -1048,7 +1064,10 @@ def convert_transparency(m, v): except ValueError: try: # normalize source image and try again - im = self.im.convert(getmodebase(self.mode)) + modebase = getmodebase(self.mode) + if modebase == self.mode: + raise + im = self.im.convert(modebase) im = im.convert(mode, dither) except KeyError as e: raise ValueError("illegal conversion") from e diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index abcffe92641..7fb468877b7 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1089,28 +1089,28 @@ def write(self, data): self.seq_num += 1 -def _write_multiple_frames(im, fp, chunk, rawmode): - default_image = im.encoderinfo.get("default_image", im.info.get("default_image")) +def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images): duration = im.encoderinfo.get("duration", im.info.get("duration", 0)) loop = im.encoderinfo.get("loop", im.info.get("loop", 0)) disposal = im.encoderinfo.get("disposal", im.info.get("disposal", Disposal.OP_NONE)) blend = im.encoderinfo.get("blend", im.info.get("blend", Blend.OP_SOURCE)) if default_image: - chain = itertools.chain(im.encoderinfo.get("append_images", [])) + chain = itertools.chain(append_images) else: - chain = itertools.chain([im], im.encoderinfo.get("append_images", [])) + chain = itertools.chain([im], append_images) im_frames = [] frame_count = 0 for im_seq in chain: for im_frame in ImageSequence.Iterator(im_seq): - im_frame = im_frame.copy() - if im_frame.mode != im.mode: - if im.mode == "P": - im_frame = im_frame.convert(im.mode, palette=im.palette) + if im_frame.mode == rawmode: + im_frame = im_frame.copy() + else: + if rawmode == "P": + im_frame = im_frame.convert(rawmode, palette=im.palette) else: - im_frame = im_frame.convert(im.mode) + im_frame = im_frame.convert(rawmode) encoderinfo = im.encoderinfo.copy() if isinstance(duration, (list, tuple)): encoderinfo["duration"] = duration[frame_count] @@ -1221,7 +1221,26 @@ def _save_all(im, fp, filename): def _save(im, fp, filename, chunk=putchunk, save_all=False): # save an image to disk (called by the save method) - mode = im.mode + if save_all: + default_image = im.encoderinfo.get( + "default_image", im.info.get("default_image") + ) + modes = set() + append_images = im.encoderinfo.get("append_images", []) + if default_image: + chain = itertools.chain(append_images) + else: + chain = itertools.chain([im], append_images) + for im_seq in chain: + for im_frame in ImageSequence.Iterator(im_seq): + modes.add(im_frame.mode) + for mode in ("RGBA", "RGB", "P"): + if mode in modes: + break + else: + mode = modes.pop() + else: + mode = im.mode if mode == "P": @@ -1373,7 +1392,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunk(fp, b"eXIf", exif) if save_all: - _write_multiple_frames(im, fp, chunk, rawmode) + _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) else: ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index b6326ef69e4..d8cabd69ddd 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -355,9 +355,9 @@ def cmd_msbuild( "libs": [r"imagequant.lib"], }, "harfbuzz": { - "url": "https://github.com/harfbuzz/harfbuzz/archive/5.3.0.zip", - "filename": "harfbuzz-5.3.0.zip", - "dir": "harfbuzz-5.3.0", + "url": "https://github.com/harfbuzz/harfbuzz/archive/5.3.1.zip", + "filename": "harfbuzz-5.3.1.zip", + "dir": "harfbuzz-5.3.1", "license": "COPYING", "build": [ cmd_cmake("-DHB_HAVE_FREETYPE:BOOL=TRUE"),