diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 41ecbe745..0c51a2014 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -217,6 +217,27 @@ def test_optimize_if_palette_can_be_reduced_by_half(): assert len(reloaded.palette.palette) // 3 == colors +def test_full_palette_second_frame(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("P", (1, 256)) + + full_palette_im = Image.new("P", (1, 256)) + for i in range(256): + full_palette_im.putpixel((0, i), i) + full_palette_im.palette = ImagePalette.ImagePalette( + "RGB", bytearray(i // 3 for i in range(768)) + ) + full_palette_im.palette.dirty = 1 + + im.save(out, save_all=True, append_images=[full_palette_im]) + + with Image.open(out) as reloaded: + reloaded.seek(1) + + for i in range(256): + reloaded.getpixel((0, i)) == i + + def test_roundtrip(tmp_path): out = str(tmp_path / "temp.gif") im = hopper() diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 23da312a6..9cd65fd48 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -266,13 +266,14 @@ following options are available:: :py:class:`PIL.ImagePalette.ImagePalette` object. **optimize** - Whether to attempt to compress the palette by eliminating unused colors. + Whether to attempt to compress the palette by eliminating unused colors + (this is only useful if the palette can be compressed to the next smaller + power of 2 elements) and whether to mark all pixels that are not new in the + next frame as transparent. + This is attempted by default, unless a palette is specified as an option or as part of the first image's :py:attr:`~PIL.Image.Image.info` dictionary. - This is only useful if the palette can be compressed to the next smaller - power of 2 elements. - Note that if the image you are saving comes from an existing GIF, it may have the following properties in its :py:attr:`~PIL.Image.Image.info` dictionary. For these options, if you do not pass them in, they will default to diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 507331d8b..4ddd5095c 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -30,7 +30,15 @@ import os import subprocess from enum import IntEnum -from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence +from . import ( + Image, + ImageChops, + ImageFile, + ImageMath, + ImageOps, + ImagePalette, + ImageSequence, +) from ._binary import i16le as i16 from ._binary import o8 from ._binary import o16le as o16 @@ -536,7 +544,15 @@ def _normalize_palette(im, palette, info): else: used_palette_colors = _get_optimize(im, info) if used_palette_colors is not None: - return im.remap_palette(used_palette_colors, source_palette) + im = im.remap_palette(used_palette_colors, source_palette) + if "transparency" in info: + try: + info["transparency"] = used_palette_colors.index( + info["transparency"] + ) + except ValueError: + del info["transparency"] + return im im.palette.palette = source_palette return im @@ -564,13 +580,11 @@ def _write_single_frame(im, fp, palette): def _getbbox(base_im, im_frame): - if _get_palette_bytes(im_frame) == _get_palette_bytes(base_im): - delta = ImageChops.subtract_modulo(im_frame, base_im) - else: - delta = ImageChops.subtract_modulo( - im_frame.convert("RGBA"), base_im.convert("RGBA") - ) - return delta.getbbox(alpha_only=False) + if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im): + im_frame = im_frame.convert("RGBA") + base_im = base_im.convert("RGBA") + delta = ImageChops.subtract_modulo(im_frame, base_im) + return delta, delta.getbbox(alpha_only=False) def _write_multiple_frames(im, fp, palette): @@ -578,6 +592,7 @@ def _write_multiple_frames(im, fp, palette): disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) im_frames = [] + previous_im = None frame_count = 0 background_im = None for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])): @@ -591,9 +606,9 @@ def _write_multiple_frames(im, fp, palette): im.encoderinfo.setdefault(k, v) encoderinfo = im.encoderinfo.copy() - im_frame = _normalize_palette(im_frame, palette, encoderinfo) if "transparency" in im_frame.info: encoderinfo.setdefault("transparency", im_frame.info["transparency"]) + im_frame = _normalize_palette(im_frame, palette, encoderinfo) if isinstance(duration, (list, tuple)): encoderinfo["duration"] = duration[frame_count] elif duration is None and "duration" in im_frame.info: @@ -602,14 +617,16 @@ def _write_multiple_frames(im, fp, palette): encoderinfo["disposal"] = disposal[frame_count] frame_count += 1 + diff_frame = None if im_frames: # delta frame - previous = im_frames[-1] - bbox = _getbbox(previous["im"], im_frame) + delta, bbox = _getbbox(previous_im, im_frame) if not bbox: # This frame is identical to the previous frame if encoderinfo.get("duration"): - previous["encoderinfo"]["duration"] += encoderinfo["duration"] + im_frames[-1]["encoderinfo"]["duration"] += encoderinfo[ + "duration" + ] continue if encoderinfo.get("disposal") == 2: if background_im is None: @@ -619,10 +636,44 @@ def _write_multiple_frames(im, fp, palette): background = _get_background(im_frame, color) background_im = Image.new("P", im_frame.size, background) background_im.putpalette(im_frames[0]["im"].palette) - bbox = _getbbox(background_im, im_frame) + delta, bbox = _getbbox(background_im, im_frame) + if encoderinfo.get("optimize") and im_frame.mode != "1": + if "transparency" not in encoderinfo: + try: + encoderinfo[ + "transparency" + ] = im_frame.palette._new_color_index(im_frame) + except ValueError: + pass + if "transparency" in encoderinfo: + # When the delta is zero, fill the image with transparency + diff_frame = im_frame.copy() + fill = Image.new( + "P", diff_frame.size, encoderinfo["transparency"] + ) + if delta.mode == "RGBA": + r, g, b, a = delta.split() + mask = ImageMath.eval( + "convert(max(max(max(r, g), b), a) * 255, '1')", + r=r, + g=g, + b=b, + a=a, + ) + else: + if delta.mode == "P": + # Convert to L without considering palette + delta_l = Image.new("L", delta.size) + delta_l.putdata(delta.getdata()) + delta = delta_l + mask = ImageMath.eval("convert(im * 255, '1')", im=delta) + diff_frame.paste(fill, mask=ImageOps.invert(mask)) else: bbox = None - im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) + previous_im = im_frame + im_frames.append( + {"im": diff_frame or im_frame, "bbox": bbox, "encoderinfo": encoderinfo} + ) if len(im_frames) == 1: if "duration" in im.encoderinfo: @@ -680,22 +731,10 @@ def get_interlace(im): def _write_local_header(fp, im, offset, flags): - transparent_color_exists = False try: - transparency = int(im.encoderinfo["transparency"]) - except (KeyError, ValueError): - pass - else: - # optimize the block away if transparent color is not used - transparent_color_exists = True - - used_palette_colors = _get_optimize(im, im.encoderinfo) - if used_palette_colors is not None: - # adjust the transparency index after optimize - try: - transparency = used_palette_colors.index(transparency) - except ValueError: - transparent_color_exists = False + transparency = im.encoderinfo["transparency"] + except KeyError: + transparency = None if "duration" in im.encoderinfo: duration = int(im.encoderinfo["duration"] / 10) @@ -704,11 +743,9 @@ def _write_local_header(fp, im, offset, flags): disposal = int(im.encoderinfo.get("disposal", 0)) - if transparent_color_exists or duration != 0 or disposal: - packed_flag = 1 if transparent_color_exists else 0 + if transparency is not None or duration != 0 or disposal: + packed_flag = 1 if transparency is not None else 0 packed_flag |= disposal << 2 - if not transparent_color_exists: - transparency = 0 fp.write( b"!" @@ -716,7 +753,7 @@ def _write_local_header(fp, im, offset, flags): + o8(4) # length + o8(packed_flag) # packed fields + o16(duration) # duration - + o8(transparency) # transparency index + + o8(transparency or 0) # transparency index + o8(0) ) @@ -804,7 +841,7 @@ def _get_optimize(im, info): :param info: encoderinfo :returns: list of indexes of palette entries in use, or None """ - if im.mode in ("P", "L") and info and info.get("optimize", 0): + if im.mode in ("P", "L") and info and info.get("optimize"): # Potentially expensive operation. # The palette saves 3 bytes per color not used, but palette diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index f9295e299..212948793 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -102,6 +102,30 @@ class ImagePalette: # Declare tostring as an alias for tobytes tostring = tobytes + def _new_color_index(self, image=None, e=None): + if not isinstance(self.palette, bytearray): + self._palette = bytearray(self.palette) + index = len(self.palette) // 3 + special_colors = () + if image: + special_colors = ( + image.info.get("background"), + image.info.get("transparency"), + ) + while index in special_colors: + index += 1 + if index >= 256: + if image: + # Search for an unused index + for i, count in reversed(list(enumerate(image.histogram()))): + if count == 0 and i not in special_colors: + index = i + break + if index >= 256: + msg = "cannot allocate more than 256 colors" + raise ValueError(msg) from e + return index + def getcolor(self, color, image=None): """Given an rgb tuple, allocate palette entry. @@ -124,27 +148,7 @@ class ImagePalette: return self.colors[color] except KeyError as e: # allocate new color slot - if not isinstance(self.palette, bytearray): - self._palette = bytearray(self.palette) - index = len(self.palette) // 3 - special_colors = () - if image: - special_colors = ( - image.info.get("background"), - image.info.get("transparency"), - ) - while index in special_colors: - index += 1 - if index >= 256: - if image: - # Search for an unused index - for i, count in reversed(list(enumerate(image.histogram()))): - if count == 0 and i not in special_colors: - index = i - break - if index >= 256: - msg = "cannot allocate more than 256 colors" - raise ValueError(msg) from e + index = self._new_color_index(image, e) self.colors[color] = index if index * 3 < len(self.palette): self._palette = (