mirror of
https://github.com/python-pillow/Pillow.git
synced 2025-01-14 19:36:38 +03:00
Merge pull request #2374 from radarhere/gif
Refactored GifImagePlugin code
This commit is contained in:
commit
c63e183fac
|
@ -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
|
||||||
|
|
||||||
|
|
77
PIL/Image.py
77
PIL/Image.py
|
@ -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.
|
||||||
|
|
BIN
Tests/images/gif_header_data.pkl
Normal file
BIN
Tests/images/gif_header_data.pkl
Normal file
Binary file not shown.
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
Loading…
Reference in New Issue
Block a user