Merge pull request #2374 from radarhere/gif

Refactored GifImagePlugin code
This commit is contained in:
wiredfool 2017-03-12 17:16:29 +00:00 committed by GitHub
commit c63e183fac
5 changed files with 467 additions and 257 deletions

View File

@ -24,17 +24,14 @@
# See the README file for information on usage and redistribution. # See the README file for information on usage and redistribution.
# #
from . import Image, ImageFile, ImagePalette, \ from . import Image, ImageFile, ImagePalette, ImageChops, ImageSequence
ImageChops, ImageSequence
from ._binary import i8, i16le as i16, o8, o16le as o16 from ._binary import i8, i16le as i16, o8, o16le as o16
import itertools
__version__ = "0.9" __version__ = "0.9"
# --------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------- # --------------------------------------------------------------------
# Identify/read GIF files # Identify/read GIF files
@ -290,52 +287,164 @@ class GifImageFile(ImageFile.ImageFile):
# -------------------------------------------------------------------- # --------------------------------------------------------------------
# Write GIF files # Write GIF files
try:
import _imaging_gif
except ImportError:
_imaging_gif = None
RAWMODE = { RAWMODE = {
"1": "L", "1": "L",
"L": "L", "L": "L",
"P": "P", "P": "P"
} }
def _convert_mode(im, initial_call=False): def _normalize_mode(im, initial_call=False):
# convert on the fly (EXPERIMENTAL -- I'm not sure PIL """
# should automatically convert images on save...) Takes an image (or frame), returns an image in a mode that is appropriate
for saving in a Gif.
It may return the original image, or it may return an image converted to
palette or 'L' mode.
UNDONE: What is the point of mucking with the initial call palette, for
an image that shouldn't have a palette, or it would be a mode 'P' and
get returned in the RAWMODE clause.
:param im: Image object
:param initial_call: Default false, set to true for a single frame.
:returns: Image object
"""
if im.mode in RAWMODE:
im.load()
return im
if Image.getmodebase(im.mode) == "RGB": if Image.getmodebase(im.mode) == "RGB":
if initial_call: if initial_call:
palette_size = 256 palette_size = 256
if im.palette: if im.palette:
palette_size = len(im.palette.getdata()[1]) // 3 palette_size = len(im.palette.getdata()[1]) // 3
return im.convert("P", palette=1, colors=palette_size) return im.convert("P", palette=Image.ADAPTIVE, colors=palette_size)
else: else:
return im.convert("P") return im.convert("P")
return im.convert("L") return im.convert("L")
def _normalize_palette(im, palette, info):
"""
Normalizes the palette for image.
- Sets the palette to the incoming palette, if provided.
- Ensures that there's a palette for L mode images
- Optimizes the palette if necessary/desired.
:param im: Image object
:param palette: bytes object containing the source palette, or ....
:param info: encoderinfo
:returns: Image object
"""
source_palette = None
if palette:
# a bytes palette
if isinstance(palette, (bytes, bytearray, list)):
source_palette = bytearray(palette[:768])
if isinstance(palette, ImagePalette.ImagePalette):
source_palette = bytearray(itertools.chain.from_iterable(
zip(palette.palette[:256],
palette.palette[256:512],
palette.palette[512:768])))
if im.mode == "P":
if not source_palette:
source_palette = im.im.getpalette("RGB")[:768]
else: # L-mode
if not source_palette:
source_palette = bytearray(i//3 for i in range(768))
im.palette = ImagePalette.ImagePalette("RGB",
palette=source_palette)
used_palette_colors = _get_optimize(im, info)
if used_palette_colors is not None:
return im.remap_palette(used_palette_colors, source_palette)
im.palette.palette = source_palette
return im
def _write_single_frame(im, fp, palette):
im_out = _normalize_mode(im, True)
im_out = _normalize_palette(im_out, palette, im.encoderinfo)
for s in _get_global_header(im_out, im.encoderinfo):
fp.write(s)
# local image header
flags = 0
if get_interlace(im):
flags = flags | 64
_write_local_header(fp, im, (0, 0), flags)
im_out.encoderconfig = (8, get_interlace(im))
ImageFile._save(im_out, fp, [("gif", (0, 0)+im.size, 0,
RAWMODE[im_out.mode])])
fp.write(b"\0") # end of image data
def _write_multiple_frames(im, fp, palette):
duration = im.encoderinfo.get("duration", None)
im_frames = []
frame_count = 0
for imSequence in [im]+im.encoderinfo.get("append_images", []):
for im_frame in ImageSequence.Iterator(imSequence):
# a copy is required here since seek can still mutate the image
im_frame = _normalize_mode(im_frame.copy())
im_frame = _normalize_palette(im_frame, palette, im.encoderinfo)
encoderinfo = im.encoderinfo.copy()
if isinstance(duration, (list, tuple)):
encoderinfo['duration'] = duration[frame_count]
frame_count += 1
if im_frames:
# delta frame
previous = im_frames[-1]
if _get_palette_bytes(im_frame) == _get_palette_bytes(previous['im']):
delta = ImageChops.subtract_modulo(im_frame,
previous['im'])
else:
delta = ImageChops.subtract_modulo(im_frame.convert('RGB'),
previous['im'].convert('RGB'))
bbox = delta.getbbox()
if not bbox:
# This frame is identical to the previous frame
if duration:
previous['encoderinfo']['duration'] += encoderinfo['duration']
continue
else:
bbox = None
im_frames.append({
'im':im_frame,
'bbox':bbox,
'encoderinfo':encoderinfo
})
if len(im_frames) > 1:
for frame_data in im_frames:
im_frame = frame_data['im']
if not frame_data['bbox']:
# global header
for s in _get_global_header(im_frame,
frame_data['encoderinfo']):
fp.write(s)
offset = (0, 0)
else:
# compress difference
frame_data['encoderinfo']['include_color_table'] = True
im_frame = im_frame.crop(frame_data['bbox'])
offset = frame_data['bbox'][:2]
_write_frame_data(fp, im_frame, offset, frame_data['encoderinfo'])
return True
def _save_all(im, fp, filename): def _save_all(im, fp, filename):
_save(im, fp, filename, save_all=True) _save(im, fp, filename, save_all=True)
def _save(im, fp, filename, save_all=False): def _save(im, fp, filename, save_all=False):
im.encoderinfo.update(im.info) im.encoderinfo.update(im.info)
if _imaging_gif:
# call external driver
try:
_imaging_gif.save(im, fp, filename)
return
except IOError:
pass # write uncompressed file
if im.mode in RAWMODE:
im_out = im.copy()
else:
im_out = _convert_mode(im, True)
# header # header
try: try:
palette = im.encoderinfo["palette"] palette = im.encoderinfo["palette"]
@ -343,70 +452,8 @@ def _save(im, fp, filename, save_all=False):
palette = None palette = None
im.encoderinfo["optimize"] = im.encoderinfo.get("optimize", True) im.encoderinfo["optimize"] = im.encoderinfo.get("optimize", True)
if save_all: if not save_all or not _write_multiple_frames(im, fp, palette):
previous = None _write_single_frame(im, fp, palette)
first_frame = None
append_images = im.encoderinfo.get("append_images", [])
if "duration" in im.encoderinfo:
duration = im.encoderinfo["duration"]
else:
duration = None
frame_count = 0
for imSequence in [im]+append_images:
for im_frame in ImageSequence.Iterator(imSequence):
encoderinfo = im.encoderinfo.copy()
im_frame = _convert_mode(im_frame)
if isinstance(duration, (list, tuple)):
encoderinfo["duration"] = duration[frame_count]
frame_count += 1
# To specify duration, add the time in milliseconds to getdata(),
# e.g. getdata(im_frame, duration=1000)
if not previous:
# global header
first_frame = getheader(im_frame, palette, encoderinfo)[0]
first_frame += getdata(im_frame, (0, 0), **encoderinfo)
else:
if first_frame:
for s in first_frame:
fp.write(s)
first_frame = None
# delta frame
delta = ImageChops.subtract_modulo(im_frame, previous.copy())
bbox = delta.getbbox()
if bbox:
# compress difference
encoderinfo['include_color_table'] = True
for s in getdata(im_frame.crop(bbox),
bbox[:2], **encoderinfo):
fp.write(s)
else:
# FIXME: what should we do in this case?
pass
previous = im_frame
if first_frame:
save_all = False
if not save_all:
header = getheader(im_out, palette, im.encoderinfo)[0]
for s in header:
fp.write(s)
flags = 0
if get_interlace(im):
flags = flags | 64
# local image header
_get_local_header(fp, im, (0, 0), flags)
im_out.encoderconfig = (8, get_interlace(im))
ImageFile._save(im_out, fp, [("gif", (0, 0)+im.size, 0,
RAWMODE[im_out.mode])])
fp.write(b"\0") # end of image data
fp.write(b";") # end of file fp.write(b";") # end of file
@ -424,7 +471,7 @@ def get_interlace(im):
return interlace return interlace
def _get_local_header(fp, im, offset, flags): def _write_local_header(fp, im, offset, flags):
transparent_color_exists = False transparent_color_exists = False
try: try:
transparency = im.encoderinfo["transparency"] transparency = im.encoderinfo["transparency"]
@ -438,12 +485,9 @@ def _get_local_header(fp, im, offset, flags):
used_palette_colors = _get_optimize(im, im.encoderinfo) used_palette_colors = _get_optimize(im, im.encoderinfo)
if used_palette_colors is not None: if used_palette_colors is not None:
# adjust the transparency index after optimize # adjust the transparency index after optimize
for i, palette_color in enumerate(used_palette_colors): try:
if palette_color == transparency: transparency = used_palette_colors.index(transparency)
transparency = i except ValueError:
transparent_color_exists = True
break
else:
transparent_color_exists = False transparent_color_exists = False
if "duration" in im.encoderinfo: if "duration" in im.encoderinfo:
@ -482,7 +526,7 @@ def _get_local_header(fp, im, offset, flags):
include_color_table = im.encoderinfo.get('include_color_table') include_color_table = im.encoderinfo.get('include_color_table')
if include_color_table: if include_color_table:
palette = im.encoderinfo.get("palette", None) palette = im.encoderinfo.get("palette", None)
palette_bytes = _get_palette_bytes(im, palette, im.encoderinfo)[0] palette_bytes = _get_palette_bytes(im)
color_table_size = _get_color_table_size(palette_bytes) color_table_size = _get_color_table_size(palette_bytes)
if color_table_size: if color_table_size:
flags = flags | 128 # local color table flag flags = flags | 128 # local color table flag
@ -501,6 +545,8 @@ def _get_local_header(fp, im, offset, flags):
def _save_netpbm(im, fp, filename): def _save_netpbm(im, fp, filename):
# Unused by default.
# To use, uncomment the register_save call at the end of the file.
# #
# If you need real GIF compression and/or RGB quantization, you # If you need real GIF compression and/or RGB quantization, you
# can use the external NETPBM/PBMPLUS utilities. See comments # can use the external NETPBM/PBMPLUS utilities. See comments
@ -541,14 +587,21 @@ def _save_netpbm(im, fp, filename):
pass pass
# --------------------------------------------------------------------
# GIF utilities
# Force optimization so that we can test performance against # Force optimization so that we can test performance against
# cases where it took lots of memory and time previously. # cases where it took lots of memory and time previously.
_FORCE_OPTIMIZE = False _FORCE_OPTIMIZE = False
def _get_optimize(im, info): def _get_optimize(im, info):
"""
Palette optimization is a potentially expensive operation.
This function determines if the palette should be optimized using
some heuristics, then returns the list of palette entries in use.
:param im: Image object
: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", 0):
# Potentially expensive operation. # Potentially expensive operation.
@ -560,23 +613,16 @@ def _get_optimize(im, info):
# * If we have a 'large' image, the palette is in the noise. # * If we have a 'large' image, the palette is in the noise.
# create the new palette if not every color is used # create the new palette if not every color is used
used_palette_colors = _get_used_palette_colors(im) optimise = _FORCE_OPTIMIZE or im.mode == 'L'
if _FORCE_OPTIMIZE or im.mode == 'L' or \ if optimise or im.width * im.height < 512 * 512:
(len(used_palette_colors) <= 128 and
max(used_palette_colors) > len(used_palette_colors) and
im.width * im.height < 512 * 512):
return used_palette_colors
def _get_used_palette_colors(im):
used_palette_colors = []
# check which colors are used # check which colors are used
i = 0 used_palette_colors = []
for count in im.histogram(): for i, count in enumerate(im.histogram()):
if count: if count:
used_palette_colors.append(i) used_palette_colors.append(i)
i += 1
if optimise or (len(used_palette_colors) <= 128 and
max(used_palette_colors) > len(used_palette_colors)):
return used_palette_colors return used_palette_colors
def _get_color_table_size(palette_bytes): def _get_color_table_size(palette_bytes):
@ -588,6 +634,13 @@ def _get_color_table_size(palette_bytes):
return color_table_size return color_table_size
def _get_header_palette(palette_bytes): def _get_header_palette(palette_bytes):
"""
Returns the palette, null padded to the next power of 2 (*3) bytes
suitable for direct inclusion in the GIF header
:param palette_bytes: Unpadded palette bytes, in RGBRGB form
:returns: Null padded palette
"""
color_table_size = _get_color_table_size(palette_bytes) color_table_size = _get_color_table_size(palette_bytes)
# add the missing amount of bytes # add the missing amount of bytes
@ -597,83 +650,16 @@ def _get_header_palette(palette_bytes):
palette_bytes += o8(0) * 3 * actual_target_size_diff palette_bytes += o8(0) * 3 * actual_target_size_diff
return palette_bytes return palette_bytes
def _get_palette_bytes(im, palette, info): def _get_palette_bytes(im):
if im.mode == "P": """
if palette and isinstance(palette, bytes): Gets the palette for inclusion in the gif header
source_palette = palette[:768]
else:
source_palette = im.im.getpalette("RGB")[:768]
else: # L-mode
if palette and isinstance(palette, bytes):
source_palette = palette[:768]
else:
source_palette = bytearray(i//3 for i in range(768))
palette_bytes = None :param im: Image object
:returns: Bytes, len<=768 suitable for inclusion in gif header
"""
return im.palette.palette
used_palette_colors = _get_optimize(im, info) def _get_global_header(im, info):
if used_palette_colors is not None:
palette_bytes = b""
new_positions = [0]*256
# pick only the used colors from the palette
for i, oldPosition in enumerate(used_palette_colors):
palette_bytes += source_palette[oldPosition*3:oldPosition*3+3]
new_positions[oldPosition] = i
# replace the palette color id of all pixel with the new id
# Palette images are [0..255], mapped through a 1 or 3
# byte/color map. We need to remap the whole image
# from palette 1 to palette 2. New_positions is
# an array of indexes into palette 1. Palette 2 is
# palette 1 with any holes removed.
# We're going to leverage the convert mechanism to use the
# C code to remap the image from palette 1 to palette 2,
# by forcing the source image into 'L' mode and adding a
# mapping 'L' mode palette, then converting back to 'L'
# sans palette thus converting the image bytes, then
# assigning the optimized RGB palette.
# perf reference, 9500x4000 gif, w/~135 colors
# 14 sec prepatch, 1 sec postpatch with optimization forced.
mapping_palette = bytearray(new_positions)
m_im = im.copy()
m_im.mode = 'P'
m_im.palette = ImagePalette.ImagePalette("RGB",
palette=mapping_palette*3,
size=768)
#possibly set palette dirty, then
#m_im.putpalette(mapping_palette, 'L') # converts to 'P'
# or just force it.
# UNDONE -- this is part of the general issue with palettes
m_im.im.putpalette(*m_im.palette.getdata())
m_im = m_im.convert('L')
# Internally, we require 768 bytes for a palette.
new_palette_bytes = (palette_bytes +
(768 - len(palette_bytes)) * b'\x00')
m_im.putpalette(new_palette_bytes)
m_im.palette = ImagePalette.ImagePalette("RGB",
palette=palette_bytes,
size=len(palette_bytes))
# oh gawd, this is modifying the image in place so I can pass by ref.
# REFACTOR SOONEST
im.frombytes(m_im.tobytes())
if not palette_bytes:
palette_bytes = source_palette
# returning palette, _not_ padded to 768 bytes like our internal ones.
return palette_bytes, used_palette_colors
def getheader(im, palette=None, info=None):
"""Return a list of strings representing a GIF header""" """Return a list of strings representing a GIF header"""
# Header Block # Header Block
@ -691,42 +677,86 @@ def getheader(im, palette=None, info=None):
if im.info.get("version") == b"89a": if im.info.get("version") == b"89a":
version = b"89a" version = b"89a"
header = [ palette_bytes = _get_palette_bytes(im)
color_table_size = _get_color_table_size(palette_bytes)
background = info["background"] if "background" in info else 0
return [
b"GIF"+version + # signature + version b"GIF"+version + # signature + version
o16(im.size[0]) + # canvas width o16(im.size[0]) + # canvas width
o16(im.size[1]) # canvas height o16(im.size[1]), # canvas height
]
palette_bytes, used_palette_colors = _get_palette_bytes(im, palette, info)
# Logical Screen Descriptor # Logical Screen Descriptor
color_table_size = _get_color_table_size(palette_bytes)
# size of global color table + global color table flag # size of global color table + global color table flag
header.append(o8(color_table_size + 128)) # packed fields o8(color_table_size + 128), # packed fields
# background + reserved/aspect # background + reserved/aspect
if info and "background" in info: o8(background) + o8(0),
background = info["background"]
elif "background" in im.info: # Global Color Table
# This elif is redundant within GifImagePlugin _get_header_palette(palette_bytes)
# since im.info parameters are bundled into the info dictionary ]
# However, external scripts may call getheader directly
# So this maintains earlier behaviour def _write_frame_data(fp, im_frame, offset, params):
background = im.info["background"] try:
else: im_frame.encoderinfo = params
background = 0
header.append(o8(background) + o8(0)) # local image header
# end of Logical Screen Descriptor _write_local_header(fp, im_frame, offset, 0)
ImageFile._save(im_frame, fp, [("gif", (0, 0)+im_frame.size, 0,
RAWMODE[im_frame.mode])])
fp.write(b"\0") # end of image data
finally:
del im_frame.encoderinfo
# --------------------------------------------------------------------
# Legacy GIF utilities
def getheader(im, palette=None, info=None):
"""
Legacy Method to get Gif data from image.
Warning:: May modify image data.
:param im: Image object
:param palette: bytes object containing the source palette, or ....
:param info: encoderinfo
:returns: tuple of(list of header items, optimized palette)
"""
used_palette_colors = _get_optimize(im, info)
if info is None:
info = {}
if not "background" in info and "background" in im.info:
info["background"] = im.info["background"]
im_mod = _normalize_palette(im, palette, info)
im.palette = im_mod.palette
im.im = im_mod.im
header = _get_global_header(im, info)
# Header + Logical Screen Descriptor + Global Color Table
header.append(_get_header_palette(palette_bytes))
return header, used_palette_colors return header, used_palette_colors
# To specify duration, add the time in milliseconds to getdata(),
# e.g. getdata(im_frame, duration=1000)
def getdata(im, offset=(0, 0), **params): def getdata(im, offset=(0, 0), **params):
"""Return a list of strings representing this image. """
The first string is a local image header, the rest contains Legacy Method
encoded image data."""
Return a list of strings representing this image.
The first string is a local image header, the rest contains
encoded image data.
:param im: Image object
:param offset: Tuple of (x, y) pixels. Defaults to (0,0)
:param **params: E.g. duration or other encoder info parameters
:returns: List of Bytes containing gif encoded frame data
"""
class Collector(object): class Collector(object):
data = [] data = []
@ -737,18 +767,7 @@ def getdata(im, offset=(0, 0), **params):
fp = Collector() fp = Collector()
try: _write_frame_data(fp, im, offset, params)
im.encoderinfo = params
# local image header
_get_local_header(fp, im, offset, 0)
ImageFile._save(im, fp, [("gif", (0, 0)+im.size, 0, RAWMODE[im.mode])])
fp.write(b"\0") # end of image data
finally:
del im.encoderinfo
return fp.data return fp.data

View File

@ -1514,6 +1514,83 @@ class Image(object):
return self.pyaccess.putpixel(xy, value) return self.pyaccess.putpixel(xy, value)
return self.im.putpixel(xy, value) return self.im.putpixel(xy, value)
def remap_palette(self, dest_map, source_palette=None):
"""
Rewrites the image to reorder the palette.
:param dest_map: A list of indexes into the original palette.
e.g. [1,0] would swap a two item palette, and list(range(255))
is the identity transform.
:param source_palette: Bytes or None.
:returns: An :py:class:`~PIL.Image.Image` object.
"""
from . import ImagePalette
if self.mode not in ("L", "P"):
raise ValueError("illegal image mode")
if source_palette is None:
if self.mode == "P":
source_palette = self.im.getpalette("RGB")[:768]
else: # L-mode
source_palette = bytearray(i//3 for i in range(768))
palette_bytes = b""
new_positions = [0]*256
# pick only the used colors from the palette
for i, oldPosition in enumerate(dest_map):
palette_bytes += source_palette[oldPosition*3:oldPosition*3+3]
new_positions[oldPosition] = i
# replace the palette color id of all pixel with the new id
# Palette images are [0..255], mapped through a 1 or 3
# byte/color map. We need to remap the whole image
# from palette 1 to palette 2. New_positions is
# an array of indexes into palette 1. Palette 2 is
# palette 1 with any holes removed.
# We're going to leverage the convert mechanism to use the
# C code to remap the image from palette 1 to palette 2,
# by forcing the source image into 'L' mode and adding a
# mapping 'L' mode palette, then converting back to 'L'
# sans palette thus converting the image bytes, then
# assigning the optimized RGB palette.
# perf reference, 9500x4000 gif, w/~135 colors
# 14 sec prepatch, 1 sec postpatch with optimization forced.
mapping_palette = bytearray(new_positions)
m_im = self.copy()
m_im.mode = 'P'
m_im.palette = ImagePalette.ImagePalette("RGB",
palette=mapping_palette*3,
size=768)
#possibly set palette dirty, then
#m_im.putpalette(mapping_palette, 'L') # converts to 'P'
# or just force it.
# UNDONE -- this is part of the general issue with palettes
m_im.im.putpalette(*m_im.palette.getdata())
m_im = m_im.convert('L')
# Internally, we require 768 bytes for a palette.
new_palette_bytes = (palette_bytes +
(768 - len(palette_bytes)) * b'\x00')
m_im.putpalette(new_palette_bytes)
m_im.palette = ImagePalette.ImagePalette("RGB",
palette=palette_bytes,
size=len(palette_bytes))
return m_im
def resize(self, size, resample=NEAREST): def resize(self, size, resample=NEAREST):
""" """
Returns a resized copy of this image. Returns a resized copy of this image.

Binary file not shown.

View File

@ -1,7 +1,6 @@
from helper import unittest, PillowTestCase, hopper, netpbm_available from helper import unittest, PillowTestCase, hopper, netpbm_available
from PIL import Image from PIL import Image, ImagePalette, GifImagePlugin
from PIL import GifImagePlugin
from io import BytesIO from io import BytesIO
@ -269,11 +268,8 @@ class TestFileGif(PillowTestCase):
duration = 1000 duration = 1000
out = self.tempfile('temp.gif') out = self.tempfile('temp.gif')
with open(out, "wb") as fp:
im = Image.new('L', (100, 100), '#000') im = Image.new('L', (100, 100), '#000')
for s in GifImagePlugin.getheader(im)[0] + GifImagePlugin.getdata(im, duration=duration): im.save(out, duration=duration)
fp.write(s)
fp.write(b";")
reread = Image.open(out) reread = Image.open(out)
self.assertEqual(reread.info['duration'], duration) self.assertEqual(reread.info['duration'], duration)
@ -285,7 +281,7 @@ class TestFileGif(PillowTestCase):
im_list = [ im_list = [
Image.new('L', (100, 100), '#000'), Image.new('L', (100, 100), '#000'),
Image.new('L', (100, 100), '#111'), Image.new('L', (100, 100), '#111'),
Image.new('L', (100, 100), '#222'), Image.new('L', (100, 100), '#222')
] ]
#duration as list #duration as list
@ -320,17 +316,38 @@ class TestFileGif(PillowTestCase):
except EOFError: except EOFError:
pass pass
def test_identical_frames(self):
duration_list = [1000, 1500, 2000, 4000]
out = self.tempfile('temp.gif')
im_list = [
Image.new('L', (100, 100), '#000'),
Image.new('L', (100, 100), '#000'),
Image.new('L', (100, 100), '#000'),
Image.new('L', (100, 100), '#111')
]
#duration as list
im_list[0].save(
out,
save_all=True,
append_images=im_list[1:],
duration=duration_list
)
reread = Image.open(out)
# Assert that the first three frames were combined
self.assertEqual(reread.n_frames, 2)
# Assert that the new duration is the total of the identical frames
self.assertEqual(reread.info['duration'], 4500)
def test_number_of_loops(self): def test_number_of_loops(self):
number_of_loops = 2 number_of_loops = 2
out = self.tempfile('temp.gif') out = self.tempfile('temp.gif')
with open(out, "wb") as fp:
im = Image.new('L', (100, 100), '#000') im = Image.new('L', (100, 100), '#000')
for s in GifImagePlugin.getheader(im)[0] + GifImagePlugin.getdata(im, loop=number_of_loops): im.save(out, loop=number_of_loops)
fp.write(s)
fp.write(b";")
reread = Image.open(out) reread = Image.open(out)
self.assertEqual(reread.info['loop'], number_of_loops) self.assertEqual(reread.info['loop'], number_of_loops)
@ -404,10 +421,10 @@ class TestFileGif(PillowTestCase):
def test_transparent_optimize(self): def test_transparent_optimize(self):
# from issue #2195, if the transparent color is incorrectly # from issue #2195, if the transparent color is incorrectly
# optimized out, gif loses transparency Need a palette that # optimized out, gif loses transparency
# isn't using the 0 color, and one that's > 128 items where # Need a palette that isn't using the 0 color, and one
# the transparent color is actually the top palette entry to # that's > 128 items where the transparent color is actually
# trigger the bug. # the top palette entry to trigger the bug.
from PIL import ImagePalette from PIL import ImagePalette
@ -424,6 +441,99 @@ class TestFileGif(PillowTestCase):
self.assertEqual(reloaded.info['transparency'], 253) self.assertEqual(reloaded.info['transparency'], 253)
def test_bbox(self):
out = self.tempfile('temp.gif')
im = Image.new('RGB', (100,100), '#fff')
ims = [Image.new("RGB", (100,100), '#000')]
im.save(out, save_all=True, append_images=ims)
reread = Image.open(out)
self.assertEqual(reread.n_frames, 2)
def test_palette_save_L(self):
# generate an L mode image with a separate palette
im = hopper('P')
im_l = Image.frombytes('L', im.size, im.tobytes())
palette = bytes(bytearray(im.getpalette()))
out = self.tempfile('temp.gif')
im_l.save(out, palette=palette)
reloaded = Image.open(out)
self.assert_image_equal(reloaded.convert('RGB'), im.convert('RGB'))
def test_palette_save_P(self):
# pass in a different palette, then construct what the image
# would look like.
# Forcing a non-straight grayscale palette.
im = hopper('P')
palette = bytes(bytearray([255-i//3 for i in range(768)]))
out = self.tempfile('temp.gif')
im.save(out, palette=palette)
reloaded = Image.open(out)
im.putpalette(palette)
self.assert_image_equal(reloaded, im)
def test_palette_save_ImagePalette(self):
# pass in a different palette, as an ImagePalette.ImagePalette
# effectively the same as test_palette_save_P
im = hopper('P')
palette = ImagePalette.ImagePalette('RGB', list(range(256))[::-1]*3)
out = self.tempfile('temp.gif')
im.save(out, palette=palette)
reloaded = Image.open(out)
im.putpalette(palette)
self.assert_image_equal(reloaded, im)
def test_save_I(self):
# Test saving something that would trigger the auto-convert to 'L'
im = hopper('I')
out = self.tempfile('temp.gif')
im.save(out)
reloaded = Image.open(out)
self.assert_image_equal(reloaded.convert('L'), im.convert('L'))
def test_getdata(self):
# test getheader/getdata against legacy values
# Create a 'P' image with holes in the palette
im = Image._wedge().resize((16, 16))
im.putpalette(ImagePalette.ImagePalette('RGB'))
im.info = {'background': 0}
passed_palette = bytes(bytearray([255-i//3 for i in range(768)]))
GifImagePlugin._FORCE_OPTIMIZE = True
try:
h = GifImagePlugin.getheader(im, passed_palette)
d = GifImagePlugin.getdata(im)
import pickle
# Enable to get target values on pre-refactor version
#with open('Tests/images/gif_header_data.pkl', 'wb') as f:
# pickle.dump((h, d), f, 1)
with open('Tests/images/gif_header_data.pkl', 'rb') as f:
(h_target, d_target) = pickle.load(f)
self.assertEqual(h, h_target)
self.assertEqual(d, d_target)
finally:
GifImagePlugin._FORCE_OPTIMIZE = False
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View File

@ -125,7 +125,11 @@ are available::
be compressed to the next smaller power of 2 elements. be compressed to the next smaller power of 2 elements.
**palette** **palette**
Use the specified palette for the saved image. Use the specified palette for the saved image. The palette should
be a bytes or bytearray object containing the palette entries in
RGBRGB... form. It should be no more than 768 bytes. Alternately,
the palette can be passed in as an
:py:class:`PIL.ImagePalette.ImagePalette` object.
Reading local images Reading local images
~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~