Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fill identical pixels with transparency in subsequent frames when saving GIF #7568

Merged
merged 1 commit into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Tests/test_file_gif.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 5 additions & 4 deletions docs/handbook/image-file-formats.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 73 additions & 36 deletions src/PIL/GifImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,15 @@
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
Expand Down Expand Up @@ -534,7 +542,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
Expand Down Expand Up @@ -562,20 +578,19 @@ 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):
duration = im.encoderinfo.get("duration")
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", [])):
Expand All @@ -589,9 +604,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:
Expand All @@ -600,14 +615,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:
Expand All @@ -617,10 +634,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:
for frame_data in im_frames:
Expand Down Expand Up @@ -678,22 +729,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)
Expand All @@ -702,19 +741,17 @@ 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"!"
+ o8(249) # extension intro
+ o8(4) # length
+ o8(packed_flag) # packed fields
+ o16(duration) # duration
+ o8(transparency) # transparency index
+ o8(transparency or 0) # transparency index
+ o8(0)
)

Expand Down Expand Up @@ -802,7 +839,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
Expand Down
46 changes: 25 additions & 21 deletions src/PIL/ImagePalette.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,30 @@ def tobytes(self):
# 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.

Expand All @@ -124,27 +148,7 @@ def getcolor(self, color, image=None):
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 = (
Expand Down