Skip to content

Commit b47ad8f

Browse files
committed
Fill identical pixels with transparency in subsequent frames
1 parent 04a4d54 commit b47ad8f

File tree

4 files changed

+122
-59
lines changed

4 files changed

+122
-59
lines changed

Tests/test_file_gif.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,27 @@ def test_optimize_if_palette_can_be_reduced_by_half():
217217
assert len(reloaded.palette.palette) // 3 == colors
218218

219219

220+
def test_full_palette_second_frame(tmp_path):
221+
out = str(tmp_path / "temp.gif")
222+
im = Image.new("P", (1, 256))
223+
224+
full_palette_im = Image.new("P", (1, 256))
225+
for i in range(256):
226+
full_palette_im.putpixel((0, i), i)
227+
full_palette_im.palette = ImagePalette.ImagePalette(
228+
"RGB", bytearray(i // 3 for i in range(768))
229+
)
230+
full_palette_im.palette.dirty = 1
231+
232+
im.save(out, save_all=True, append_images=[full_palette_im])
233+
234+
with Image.open(out) as reloaded:
235+
reloaded.seek(1)
236+
237+
for i in range(256):
238+
reloaded.getpixel((0, i)) == i
239+
240+
220241
def test_roundtrip(tmp_path):
221242
out = str(tmp_path / "temp.gif")
222243
im = hopper()

docs/handbook/image-file-formats.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,8 +267,9 @@ following options are available::
267267

268268
**optimize**
269269
If present and true, attempt to compress the palette by
270-
eliminating unused colors. This is only useful if the palette can
271-
be compressed to the next smaller power of 2 elements.
270+
eliminating unused colors (this is only useful if the palette can
271+
be compressed to the next smaller power of 2 elements) and by marking
272+
all pixels that are not new in the next frame as transparent.
272273

273274
Note that if the image you are saving comes from an existing GIF, it may have
274275
the following properties in its :py:attr:`~PIL.Image.Image.info` dictionary.

src/PIL/GifImagePlugin.py

Lines changed: 73 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,15 @@
3030
import subprocess
3131
from enum import IntEnum
3232

33-
from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence
33+
from . import (
34+
Image,
35+
ImageChops,
36+
ImageFile,
37+
ImageMath,
38+
ImageOps,
39+
ImagePalette,
40+
ImageSequence,
41+
)
3442
from ._binary import i16le as i16
3543
from ._binary import o8
3644
from ._binary import o16le as o16
@@ -534,7 +542,15 @@ def _normalize_palette(im, palette, info):
534542
else:
535543
used_palette_colors = _get_optimize(im, info)
536544
if used_palette_colors is not None:
537-
return im.remap_palette(used_palette_colors, source_palette)
545+
im = im.remap_palette(used_palette_colors, source_palette)
546+
if "transparency" in info:
547+
try:
548+
info["transparency"] = used_palette_colors.index(
549+
info["transparency"]
550+
)
551+
except ValueError:
552+
del info["transparency"]
553+
return im
538554

539555
im.palette.palette = source_palette
540556
return im
@@ -562,20 +578,19 @@ def _write_single_frame(im, fp, palette):
562578

563579

564580
def _getbbox(base_im, im_frame):
565-
if _get_palette_bytes(im_frame) == _get_palette_bytes(base_im):
566-
delta = ImageChops.subtract_modulo(im_frame, base_im)
567-
else:
568-
delta = ImageChops.subtract_modulo(
569-
im_frame.convert("RGBA"), base_im.convert("RGBA")
570-
)
571-
return delta.getbbox(alpha_only=False)
581+
if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im):
582+
im_frame = im_frame.convert("RGBA")
583+
base_im = base_im.convert("RGBA")
584+
delta = ImageChops.subtract_modulo(im_frame, base_im)
585+
return delta, delta.getbbox(alpha_only=False)
572586

573587

574588
def _write_multiple_frames(im, fp, palette):
575589
duration = im.encoderinfo.get("duration")
576590
disposal = im.encoderinfo.get("disposal", im.info.get("disposal"))
577591

578592
im_frames = []
593+
previous_im = None
579594
frame_count = 0
580595
background_im = None
581596
for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])):
@@ -589,9 +604,9 @@ def _write_multiple_frames(im, fp, palette):
589604
im.encoderinfo.setdefault(k, v)
590605

591606
encoderinfo = im.encoderinfo.copy()
592-
im_frame = _normalize_palette(im_frame, palette, encoderinfo)
593607
if "transparency" in im_frame.info:
594608
encoderinfo.setdefault("transparency", im_frame.info["transparency"])
609+
im_frame = _normalize_palette(im_frame, palette, encoderinfo)
595610
if isinstance(duration, (list, tuple)):
596611
encoderinfo["duration"] = duration[frame_count]
597612
elif duration is None and "duration" in im_frame.info:
@@ -600,14 +615,16 @@ def _write_multiple_frames(im, fp, palette):
600615
encoderinfo["disposal"] = disposal[frame_count]
601616
frame_count += 1
602617

618+
diff_frame = None
603619
if im_frames:
604620
# delta frame
605-
previous = im_frames[-1]
606-
bbox = _getbbox(previous["im"], im_frame)
621+
delta, bbox = _getbbox(previous_im, im_frame)
607622
if not bbox:
608623
# This frame is identical to the previous frame
609624
if encoderinfo.get("duration"):
610-
previous["encoderinfo"]["duration"] += encoderinfo["duration"]
625+
im_frames[-1]["encoderinfo"]["duration"] += encoderinfo[
626+
"duration"
627+
]
611628
continue
612629
if encoderinfo.get("disposal") == 2:
613630
if background_im is None:
@@ -617,10 +634,44 @@ def _write_multiple_frames(im, fp, palette):
617634
background = _get_background(im_frame, color)
618635
background_im = Image.new("P", im_frame.size, background)
619636
background_im.putpalette(im_frames[0]["im"].palette)
620-
bbox = _getbbox(background_im, im_frame)
637+
delta, bbox = _getbbox(background_im, im_frame)
638+
if encoderinfo.get("optimize") and im_frame.mode != "1":
639+
if "transparency" not in encoderinfo:
640+
try:
641+
encoderinfo[
642+
"transparency"
643+
] = im_frame.palette._new_color_index(im_frame)
644+
except ValueError:
645+
pass
646+
if "transparency" in encoderinfo:
647+
# When the delta is zero, fill the image with transparency
648+
diff_frame = im_frame.copy()
649+
fill = Image.new(
650+
"P", diff_frame.size, encoderinfo["transparency"]
651+
)
652+
if delta.mode == "RGBA":
653+
r, g, b, a = delta.split()
654+
mask = ImageMath.eval(
655+
"convert(max(max(max(r, g), b), a) * 255, '1')",
656+
r=r,
657+
g=g,
658+
b=b,
659+
a=a,
660+
)
661+
else:
662+
if delta.mode == "P":
663+
# Convert to L without considering palette
664+
delta_l = Image.new("L", delta.size)
665+
delta_l.putdata(delta.getdata())
666+
delta = delta_l
667+
mask = ImageMath.eval("convert(im * 255, '1')", im=delta)
668+
diff_frame.paste(fill, mask=ImageOps.invert(mask))
621669
else:
622670
bbox = None
623-
im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo})
671+
previous_im = im_frame
672+
im_frames.append(
673+
{"im": diff_frame or im_frame, "bbox": bbox, "encoderinfo": encoderinfo}
674+
)
624675

625676
if len(im_frames) > 1:
626677
for frame_data in im_frames:
@@ -678,22 +729,10 @@ def get_interlace(im):
678729

679730

680731
def _write_local_header(fp, im, offset, flags):
681-
transparent_color_exists = False
682732
try:
683-
transparency = int(im.encoderinfo["transparency"])
684-
except (KeyError, ValueError):
685-
pass
686-
else:
687-
# optimize the block away if transparent color is not used
688-
transparent_color_exists = True
689-
690-
used_palette_colors = _get_optimize(im, im.encoderinfo)
691-
if used_palette_colors is not None:
692-
# adjust the transparency index after optimize
693-
try:
694-
transparency = used_palette_colors.index(transparency)
695-
except ValueError:
696-
transparent_color_exists = False
733+
transparency = im.encoderinfo["transparency"]
734+
except KeyError:
735+
transparency = None
697736

698737
if "duration" in im.encoderinfo:
699738
duration = int(im.encoderinfo["duration"] / 10)
@@ -702,19 +741,17 @@ def _write_local_header(fp, im, offset, flags):
702741

703742
disposal = int(im.encoderinfo.get("disposal", 0))
704743

705-
if transparent_color_exists or duration != 0 or disposal:
706-
packed_flag = 1 if transparent_color_exists else 0
744+
if transparency is not None or duration != 0 or disposal:
745+
packed_flag = 1 if transparency is not None else 0
707746
packed_flag |= disposal << 2
708-
if not transparent_color_exists:
709-
transparency = 0
710747

711748
fp.write(
712749
b"!"
713750
+ o8(249) # extension intro
714751
+ o8(4) # length
715752
+ o8(packed_flag) # packed fields
716753
+ o16(duration) # duration
717-
+ o8(transparency) # transparency index
754+
+ o8(transparency or 0) # transparency index
718755
+ o8(0)
719756
)
720757

@@ -802,7 +839,7 @@ def _get_optimize(im, info):
802839
:param info: encoderinfo
803840
:returns: list of indexes of palette entries in use, or None
804841
"""
805-
if im.mode in ("P", "L") and info and info.get("optimize", 0):
842+
if im.mode in ("P", "L") and info and info.get("optimize"):
806843
# Potentially expensive operation.
807844

808845
# The palette saves 3 bytes per color not used, but palette

src/PIL/ImagePalette.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,30 @@ def tobytes(self):
102102
# Declare tostring as an alias for tobytes
103103
tostring = tobytes
104104

105+
def _new_color_index(self, image=None, e=None):
106+
if not isinstance(self.palette, bytearray):
107+
self._palette = bytearray(self.palette)
108+
index = len(self.palette) // 3
109+
special_colors = ()
110+
if image:
111+
special_colors = (
112+
image.info.get("background"),
113+
image.info.get("transparency"),
114+
)
115+
while index in special_colors:
116+
index += 1
117+
if index >= 256:
118+
if image:
119+
# Search for an unused index
120+
for i, count in reversed(list(enumerate(image.histogram()))):
121+
if count == 0 and i not in special_colors:
122+
index = i
123+
break
124+
if index >= 256:
125+
msg = "cannot allocate more than 256 colors"
126+
raise ValueError(msg) from e
127+
return index
128+
105129
def getcolor(self, color, image=None):
106130
"""Given an rgb tuple, allocate palette entry.
107131
@@ -124,27 +148,7 @@ def getcolor(self, color, image=None):
124148
return self.colors[color]
125149
except KeyError as e:
126150
# allocate new color slot
127-
if not isinstance(self.palette, bytearray):
128-
self._palette = bytearray(self.palette)
129-
index = len(self.palette) // 3
130-
special_colors = ()
131-
if image:
132-
special_colors = (
133-
image.info.get("background"),
134-
image.info.get("transparency"),
135-
)
136-
while index in special_colors:
137-
index += 1
138-
if index >= 256:
139-
if image:
140-
# Search for an unused index
141-
for i, count in reversed(list(enumerate(image.histogram()))):
142-
if count == 0 and i not in special_colors:
143-
index = i
144-
break
145-
if index >= 256:
146-
msg = "cannot allocate more than 256 colors"
147-
raise ValueError(msg) from e
151+
index = self._new_color_index(image, e)
148152
self.colors[color] = index
149153
if index * 3 < len(self.palette):
150154
self._palette = (

0 commit comments

Comments
 (0)