Merge pull request #7568 from radarhere/gif_transparency

This commit is contained in:
Hugo van Kemenade 2023-12-21 12:58:07 +02:00 committed by GitHub
commit 18907b5f7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 124 additions and 61 deletions

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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 = (