Skip to content

Commit

Permalink
Fill identical pixels with transparency in subsequent frames
Browse files Browse the repository at this point in the history
  • Loading branch information
radarhere committed Nov 24, 2023
1 parent 04a4d54 commit 7bfeb70
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 37 deletions.
21 changes: 21 additions & 0 deletions Tests/test_file_gif.py
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
5 changes: 3 additions & 2 deletions docs/handbook/image-file-formats.rst
Expand Up @@ -267,8 +267,9 @@ following options are available::

**optimize**
If present and true, 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.
eliminating unused colors (this is only useful if the palette can
be compressed to the next smaller power of 2 elements) and by marking
all pixels that are not new in the next frame as transparent.

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.
Expand Down
71 changes: 57 additions & 14 deletions src/PIL/GifImagePlugin.py
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 @@ -562,20 +570,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 @@ -600,14 +607,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 +626,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 @@ -802,7 +845,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
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

0 comments on commit 7bfeb70

Please sign in to comment.