Skip to content

Commit

Permalink
Merge pull request #7568 from radarhere/gif_transparency
Browse files Browse the repository at this point in the history
  • Loading branch information
hugovk committed Dec 21, 2023
2 parents 0331d9d + 55c5587 commit 18907b5
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 61 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
9 changes: 5 additions & 4 deletions docs/handbook/image-file-formats.rst
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
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 @@ -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
Expand Down Expand Up @@ -564,20 +580,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 @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -704,19 +743,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 @@ -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
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 18907b5

Please sign in to comment.