1198 lines
40 KiB
Python
1198 lines
40 KiB
Python
#
|
|
# The Python Imaging Library.
|
|
# $Id$
|
|
#
|
|
# GIF file handling
|
|
#
|
|
# History:
|
|
# 1995-09-01 fl Created
|
|
# 1996-12-14 fl Added interlace support
|
|
# 1996-12-30 fl Added animation support
|
|
# 1997-01-05 fl Added write support, fixed local colour map bug
|
|
# 1997-02-23 fl Make sure to load raster data in getdata()
|
|
# 1997-07-05 fl Support external decoder (0.4)
|
|
# 1998-07-09 fl Handle all modes when saving (0.5)
|
|
# 1998-07-15 fl Renamed offset attribute to avoid name clash
|
|
# 2001-04-16 fl Added rewind support (seek to frame 0) (0.6)
|
|
# 2001-04-17 fl Added palette optimization (0.7)
|
|
# 2002-06-06 fl Added transparency support for save (0.8)
|
|
# 2004-02-24 fl Disable interlacing for small images
|
|
#
|
|
# Copyright (c) 1997-2004 by Secret Labs AB
|
|
# Copyright (c) 1995-2004 by Fredrik Lundh
|
|
#
|
|
# See the README file for information on usage and redistribution.
|
|
#
|
|
from __future__ import annotations
|
|
|
|
import itertools
|
|
import math
|
|
import os
|
|
import subprocess
|
|
from enum import IntEnum
|
|
from functools import cached_property
|
|
from typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, Union
|
|
|
|
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
|
|
|
|
if TYPE_CHECKING:
|
|
from . import _imaging
|
|
from ._typing import Buffer
|
|
|
|
|
|
class LoadingStrategy(IntEnum):
|
|
""".. versionadded:: 9.1.0"""
|
|
|
|
RGB_AFTER_FIRST = 0
|
|
RGB_AFTER_DIFFERENT_PALETTE_ONLY = 1
|
|
RGB_ALWAYS = 2
|
|
|
|
|
|
#: .. versionadded:: 9.1.0
|
|
LOADING_STRATEGY = LoadingStrategy.RGB_AFTER_FIRST
|
|
|
|
# --------------------------------------------------------------------
|
|
# Identify/read GIF files
|
|
|
|
|
|
def _accept(prefix: bytes) -> bool:
|
|
return prefix[:6] in [b"GIF87a", b"GIF89a"]
|
|
|
|
|
|
##
|
|
# Image plugin for GIF images. This plugin supports both GIF87 and
|
|
# GIF89 images.
|
|
|
|
|
|
class GifImageFile(ImageFile.ImageFile):
|
|
format = "GIF"
|
|
format_description = "Compuserve GIF"
|
|
_close_exclusive_fp_after_loading = False
|
|
|
|
global_palette = None
|
|
|
|
def data(self) -> bytes | None:
|
|
s = self.fp.read(1)
|
|
if s and s[0]:
|
|
return self.fp.read(s[0])
|
|
return None
|
|
|
|
def _is_palette_needed(self, p: bytes) -> bool:
|
|
for i in range(0, len(p), 3):
|
|
if not (i // 3 == p[i] == p[i + 1] == p[i + 2]):
|
|
return True
|
|
return False
|
|
|
|
def _open(self) -> None:
|
|
# Screen
|
|
s = self.fp.read(13)
|
|
if not _accept(s):
|
|
msg = "not a GIF file"
|
|
raise SyntaxError(msg)
|
|
|
|
self.info["version"] = s[:6]
|
|
self._size = i16(s, 6), i16(s, 8)
|
|
self.tile = []
|
|
flags = s[10]
|
|
bits = (flags & 7) + 1
|
|
|
|
if flags & 128:
|
|
# get global palette
|
|
self.info["background"] = s[11]
|
|
# check if palette contains colour indices
|
|
p = self.fp.read(3 << bits)
|
|
if self._is_palette_needed(p):
|
|
p = ImagePalette.raw("RGB", p)
|
|
self.global_palette = self.palette = p
|
|
|
|
self._fp = self.fp # FIXME: hack
|
|
self.__rewind = self.fp.tell()
|
|
self._n_frames: int | None = None
|
|
self._seek(0) # get ready to read first frame
|
|
|
|
@property
|
|
def n_frames(self) -> int:
|
|
if self._n_frames is None:
|
|
current = self.tell()
|
|
try:
|
|
while True:
|
|
self._seek(self.tell() + 1, False)
|
|
except EOFError:
|
|
self._n_frames = self.tell() + 1
|
|
self.seek(current)
|
|
return self._n_frames
|
|
|
|
@cached_property
|
|
def is_animated(self) -> bool:
|
|
if self._n_frames is not None:
|
|
return self._n_frames != 1
|
|
|
|
current = self.tell()
|
|
if current:
|
|
return True
|
|
|
|
try:
|
|
self._seek(1, False)
|
|
is_animated = True
|
|
except EOFError:
|
|
is_animated = False
|
|
|
|
self.seek(current)
|
|
return is_animated
|
|
|
|
def seek(self, frame: int) -> None:
|
|
if not self._seek_check(frame):
|
|
return
|
|
if frame < self.__frame:
|
|
self._im = None
|
|
self._seek(0)
|
|
|
|
last_frame = self.__frame
|
|
for f in range(self.__frame + 1, frame + 1):
|
|
try:
|
|
self._seek(f)
|
|
except EOFError as e:
|
|
self.seek(last_frame)
|
|
msg = "no more images in GIF file"
|
|
raise EOFError(msg) from e
|
|
|
|
def _seek(self, frame: int, update_image: bool = True) -> None:
|
|
if frame == 0:
|
|
# rewind
|
|
self.__offset = 0
|
|
self.dispose: _imaging.ImagingCore | None = None
|
|
self.__frame = -1
|
|
self._fp.seek(self.__rewind)
|
|
self.disposal_method = 0
|
|
if "comment" in self.info:
|
|
del self.info["comment"]
|
|
else:
|
|
# ensure that the previous frame was loaded
|
|
if self.tile and update_image:
|
|
self.load()
|
|
|
|
if frame != self.__frame + 1:
|
|
msg = f"cannot seek to frame {frame}"
|
|
raise ValueError(msg)
|
|
|
|
self.fp = self._fp
|
|
if self.__offset:
|
|
# backup to last frame
|
|
self.fp.seek(self.__offset)
|
|
while self.data():
|
|
pass
|
|
self.__offset = 0
|
|
|
|
s = self.fp.read(1)
|
|
if not s or s == b";":
|
|
msg = "no more images in GIF file"
|
|
raise EOFError(msg)
|
|
|
|
palette: ImagePalette.ImagePalette | Literal[False] | None = None
|
|
|
|
info: dict[str, Any] = {}
|
|
frame_transparency = None
|
|
interlace = None
|
|
frame_dispose_extent = None
|
|
while True:
|
|
if not s:
|
|
s = self.fp.read(1)
|
|
if not s or s == b";":
|
|
break
|
|
|
|
elif s == b"!":
|
|
#
|
|
# extensions
|
|
#
|
|
s = self.fp.read(1)
|
|
block = self.data()
|
|
if s[0] == 249 and block is not None:
|
|
#
|
|
# graphic control extension
|
|
#
|
|
flags = block[0]
|
|
if flags & 1:
|
|
frame_transparency = block[3]
|
|
info["duration"] = i16(block, 1) * 10
|
|
|
|
# disposal method - find the value of bits 4 - 6
|
|
dispose_bits = 0b00011100 & flags
|
|
dispose_bits = dispose_bits >> 2
|
|
if dispose_bits:
|
|
# only set the dispose if it is not
|
|
# unspecified. I'm not sure if this is
|
|
# correct, but it seems to prevent the last
|
|
# frame from looking odd for some animations
|
|
self.disposal_method = dispose_bits
|
|
elif s[0] == 254:
|
|
#
|
|
# comment extension
|
|
#
|
|
comment = b""
|
|
|
|
# Read this comment block
|
|
while block:
|
|
comment += block
|
|
block = self.data()
|
|
|
|
if "comment" in info:
|
|
# If multiple comment blocks in frame, separate with \n
|
|
info["comment"] += b"\n" + comment
|
|
else:
|
|
info["comment"] = comment
|
|
s = None
|
|
continue
|
|
elif s[0] == 255 and frame == 0 and block is not None:
|
|
#
|
|
# application extension
|
|
#
|
|
info["extension"] = block, self.fp.tell()
|
|
if block[:11] == b"NETSCAPE2.0":
|
|
block = self.data()
|
|
if block and len(block) >= 3 and block[0] == 1:
|
|
self.info["loop"] = i16(block, 1)
|
|
while self.data():
|
|
pass
|
|
|
|
elif s == b",":
|
|
#
|
|
# local image
|
|
#
|
|
s = self.fp.read(9)
|
|
|
|
# extent
|
|
x0, y0 = i16(s, 0), i16(s, 2)
|
|
x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
|
|
if (x1 > self.size[0] or y1 > self.size[1]) and update_image:
|
|
self._size = max(x1, self.size[0]), max(y1, self.size[1])
|
|
Image._decompression_bomb_check(self._size)
|
|
frame_dispose_extent = x0, y0, x1, y1
|
|
flags = s[8]
|
|
|
|
interlace = (flags & 64) != 0
|
|
|
|
if flags & 128:
|
|
bits = (flags & 7) + 1
|
|
p = self.fp.read(3 << bits)
|
|
if self._is_palette_needed(p):
|
|
palette = ImagePalette.raw("RGB", p)
|
|
else:
|
|
palette = False
|
|
|
|
# image data
|
|
bits = self.fp.read(1)[0]
|
|
self.__offset = self.fp.tell()
|
|
break
|
|
s = None
|
|
|
|
if interlace is None:
|
|
msg = "image not found in GIF frame"
|
|
raise EOFError(msg)
|
|
|
|
self.__frame = frame
|
|
if not update_image:
|
|
return
|
|
|
|
self.tile = []
|
|
|
|
if self.dispose:
|
|
self.im.paste(self.dispose, self.dispose_extent)
|
|
|
|
self._frame_palette = palette if palette is not None else self.global_palette
|
|
self._frame_transparency = frame_transparency
|
|
if frame == 0:
|
|
if self._frame_palette:
|
|
if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS:
|
|
self._mode = "RGBA" if frame_transparency is not None else "RGB"
|
|
else:
|
|
self._mode = "P"
|
|
else:
|
|
self._mode = "L"
|
|
|
|
if palette:
|
|
self.palette = palette
|
|
elif self.global_palette:
|
|
from copy import copy
|
|
|
|
self.palette = copy(self.global_palette)
|
|
else:
|
|
self.palette = None
|
|
else:
|
|
if self.mode == "P":
|
|
if (
|
|
LOADING_STRATEGY != LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY
|
|
or palette
|
|
):
|
|
if "transparency" in self.info:
|
|
self.im.putpalettealpha(self.info["transparency"], 0)
|
|
self.im = self.im.convert("RGBA", Image.Dither.FLOYDSTEINBERG)
|
|
self._mode = "RGBA"
|
|
del self.info["transparency"]
|
|
else:
|
|
self._mode = "RGB"
|
|
self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG)
|
|
|
|
def _rgb(color: int) -> tuple[int, int, int]:
|
|
if self._frame_palette:
|
|
if color * 3 + 3 > len(self._frame_palette.palette):
|
|
color = 0
|
|
return tuple(self._frame_palette.palette[color * 3 : color * 3 + 3])
|
|
else:
|
|
return (color, color, color)
|
|
|
|
self.dispose = None
|
|
self.dispose_extent = frame_dispose_extent
|
|
if self.dispose_extent and self.disposal_method >= 2:
|
|
try:
|
|
if self.disposal_method == 2:
|
|
# replace with background colour
|
|
|
|
# only dispose the extent in this frame
|
|
x0, y0, x1, y1 = self.dispose_extent
|
|
dispose_size = (x1 - x0, y1 - y0)
|
|
|
|
Image._decompression_bomb_check(dispose_size)
|
|
|
|
# by convention, attempt to use transparency first
|
|
dispose_mode = "P"
|
|
color = self.info.get("transparency", frame_transparency)
|
|
if color is not None:
|
|
if self.mode in ("RGB", "RGBA"):
|
|
dispose_mode = "RGBA"
|
|
color = _rgb(color) + (0,)
|
|
else:
|
|
color = self.info.get("background", 0)
|
|
if self.mode in ("RGB", "RGBA"):
|
|
dispose_mode = "RGB"
|
|
color = _rgb(color)
|
|
self.dispose = Image.core.fill(dispose_mode, dispose_size, color)
|
|
else:
|
|
# replace with previous contents
|
|
if self._im is not None:
|
|
# only dispose the extent in this frame
|
|
self.dispose = self._crop(self.im, self.dispose_extent)
|
|
elif frame_transparency is not None:
|
|
x0, y0, x1, y1 = self.dispose_extent
|
|
dispose_size = (x1 - x0, y1 - y0)
|
|
|
|
Image._decompression_bomb_check(dispose_size)
|
|
dispose_mode = "P"
|
|
color = frame_transparency
|
|
if self.mode in ("RGB", "RGBA"):
|
|
dispose_mode = "RGBA"
|
|
color = _rgb(frame_transparency) + (0,)
|
|
self.dispose = Image.core.fill(
|
|
dispose_mode, dispose_size, color
|
|
)
|
|
except AttributeError:
|
|
pass
|
|
|
|
if interlace is not None:
|
|
transparency = -1
|
|
if frame_transparency is not None:
|
|
if frame == 0:
|
|
if LOADING_STRATEGY != LoadingStrategy.RGB_ALWAYS:
|
|
self.info["transparency"] = frame_transparency
|
|
elif self.mode not in ("RGB", "RGBA"):
|
|
transparency = frame_transparency
|
|
self.tile = [
|
|
ImageFile._Tile(
|
|
"gif",
|
|
(x0, y0, x1, y1),
|
|
self.__offset,
|
|
(bits, interlace, transparency),
|
|
)
|
|
]
|
|
|
|
if info.get("comment"):
|
|
self.info["comment"] = info["comment"]
|
|
for k in ["duration", "extension"]:
|
|
if k in info:
|
|
self.info[k] = info[k]
|
|
elif k in self.info:
|
|
del self.info[k]
|
|
|
|
def load_prepare(self) -> None:
|
|
temp_mode = "P" if self._frame_palette else "L"
|
|
self._prev_im = None
|
|
if self.__frame == 0:
|
|
if self._frame_transparency is not None:
|
|
self.im = Image.core.fill(
|
|
temp_mode, self.size, self._frame_transparency
|
|
)
|
|
elif self.mode in ("RGB", "RGBA"):
|
|
self._prev_im = self.im
|
|
if self._frame_palette:
|
|
self.im = Image.core.fill("P", self.size, self._frame_transparency or 0)
|
|
self.im.putpalette("RGB", *self._frame_palette.getdata())
|
|
else:
|
|
self._im = None
|
|
if not self._prev_im and self._im is not None and self.size != self.im.size:
|
|
expanded_im = Image.core.fill(self.im.mode, self.size)
|
|
if self._frame_palette:
|
|
expanded_im.putpalette("RGB", *self._frame_palette.getdata())
|
|
expanded_im.paste(self.im, (0, 0) + self.im.size)
|
|
|
|
self.im = expanded_im
|
|
self._mode = temp_mode
|
|
self._frame_palette = None
|
|
|
|
super().load_prepare()
|
|
|
|
def load_end(self) -> None:
|
|
if self.__frame == 0:
|
|
if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS:
|
|
if self._frame_transparency is not None:
|
|
self.im.putpalettealpha(self._frame_transparency, 0)
|
|
self._mode = "RGBA"
|
|
else:
|
|
self._mode = "RGB"
|
|
self.im = self.im.convert(self.mode, Image.Dither.FLOYDSTEINBERG)
|
|
return
|
|
if not self._prev_im:
|
|
return
|
|
if self.size != self._prev_im.size:
|
|
if self._frame_transparency is not None:
|
|
expanded_im = Image.core.fill("RGBA", self.size)
|
|
else:
|
|
expanded_im = Image.core.fill("P", self.size)
|
|
expanded_im.putpalette("RGB", "RGB", self.im.getpalette())
|
|
expanded_im = expanded_im.convert("RGB")
|
|
expanded_im.paste(self._prev_im, (0, 0) + self._prev_im.size)
|
|
|
|
self._prev_im = expanded_im
|
|
assert self._prev_im is not None
|
|
if self._frame_transparency is not None:
|
|
self.im.putpalettealpha(self._frame_transparency, 0)
|
|
frame_im = self.im.convert("RGBA")
|
|
else:
|
|
frame_im = self.im.convert("RGB")
|
|
|
|
assert self.dispose_extent is not None
|
|
frame_im = self._crop(frame_im, self.dispose_extent)
|
|
|
|
self.im = self._prev_im
|
|
self._mode = self.im.mode
|
|
if frame_im.mode == "RGBA":
|
|
self.im.paste(frame_im, self.dispose_extent, frame_im)
|
|
else:
|
|
self.im.paste(frame_im, self.dispose_extent)
|
|
|
|
def tell(self) -> int:
|
|
return self.__frame
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# Write GIF files
|
|
|
|
|
|
RAWMODE = {"1": "L", "L": "L", "P": "P"}
|
|
|
|
|
|
def _normalize_mode(im: Image.Image) -> Image.Image:
|
|
"""
|
|
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.
|
|
|
|
:param im: Image object
|
|
:returns: Image object
|
|
"""
|
|
if im.mode in RAWMODE:
|
|
im.load()
|
|
return im
|
|
if Image.getmodebase(im.mode) == "RGB":
|
|
im = im.convert("P", palette=Image.Palette.ADAPTIVE)
|
|
assert im.palette is not None
|
|
if im.palette.mode == "RGBA":
|
|
for rgba in im.palette.colors:
|
|
if rgba[3] == 0:
|
|
im.info["transparency"] = im.palette.colors[rgba]
|
|
break
|
|
return im
|
|
return im.convert("L")
|
|
|
|
|
|
_Palette = Union[bytes, bytearray, list[int], ImagePalette.ImagePalette]
|
|
|
|
|
|
def _normalize_palette(
|
|
im: Image.Image, palette: _Palette | None, info: dict[str, Any]
|
|
) -> Image.Image:
|
|
"""
|
|
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(palette.palette)
|
|
|
|
if im.mode == "P":
|
|
if not source_palette:
|
|
im_palette = im.getpalette(None)
|
|
assert im_palette is not None
|
|
source_palette = bytearray(im_palette)
|
|
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)
|
|
assert source_palette is not None
|
|
|
|
if palette:
|
|
used_palette_colors: list[int | None] = []
|
|
assert im.palette is not None
|
|
for i in range(0, len(source_palette), 3):
|
|
source_color = tuple(source_palette[i : i + 3])
|
|
index = im.palette.colors.get(source_color)
|
|
if index in used_palette_colors:
|
|
index = None
|
|
used_palette_colors.append(index)
|
|
for i, index in enumerate(used_palette_colors):
|
|
if index is None:
|
|
for j in range(len(used_palette_colors)):
|
|
if j not in used_palette_colors:
|
|
used_palette_colors[i] = j
|
|
break
|
|
dest_map: list[int] = []
|
|
for index in used_palette_colors:
|
|
assert index is not None
|
|
dest_map.append(index)
|
|
im = im.remap_palette(dest_map)
|
|
else:
|
|
optimized_palette_colors = _get_optimize(im, info)
|
|
if optimized_palette_colors is not None:
|
|
im = im.remap_palette(optimized_palette_colors, source_palette)
|
|
if "transparency" in info:
|
|
try:
|
|
info["transparency"] = optimized_palette_colors.index(
|
|
info["transparency"]
|
|
)
|
|
except ValueError:
|
|
del info["transparency"]
|
|
return im
|
|
|
|
assert im.palette is not None
|
|
im.palette.palette = source_palette
|
|
return im
|
|
|
|
|
|
def _write_single_frame(
|
|
im: Image.Image,
|
|
fp: IO[bytes],
|
|
palette: _Palette | None,
|
|
) -> None:
|
|
im_out = _normalize_mode(im)
|
|
for k, v in im_out.info.items():
|
|
if isinstance(k, str):
|
|
im.encoderinfo.setdefault(k, v)
|
|
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, [ImageFile._Tile("gif", (0, 0) + im.size, 0, RAWMODE[im_out.mode])]
|
|
)
|
|
|
|
fp.write(b"\0") # end of image data
|
|
|
|
|
|
def _getbbox(
|
|
base_im: Image.Image, im_frame: Image.Image
|
|
) -> tuple[Image.Image, tuple[int, int, int, int] | None]:
|
|
palette_bytes = [
|
|
bytes(im.palette.palette) if im.palette else b"" for im in (base_im, im_frame)
|
|
]
|
|
if palette_bytes[0] != palette_bytes[1]:
|
|
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)
|
|
|
|
|
|
class _Frame(NamedTuple):
|
|
im: Image.Image
|
|
bbox: tuple[int, int, int, int] | None
|
|
encoderinfo: dict[str, Any]
|
|
|
|
|
|
def _write_multiple_frames(
|
|
im: Image.Image, fp: IO[bytes], palette: _Palette | None
|
|
) -> bool:
|
|
duration = im.encoderinfo.get("duration")
|
|
disposal = im.encoderinfo.get("disposal", im.info.get("disposal"))
|
|
|
|
im_frames: list[_Frame] = []
|
|
previous_im: Image.Image | None = None
|
|
frame_count = 0
|
|
background_im = None
|
|
for imSequence in itertools.chain([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())
|
|
if frame_count == 0:
|
|
for k, v in im_frame.info.items():
|
|
if k == "transparency":
|
|
continue
|
|
if isinstance(k, str):
|
|
im.encoderinfo.setdefault(k, v)
|
|
|
|
encoderinfo = im.encoderinfo.copy()
|
|
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:
|
|
encoderinfo["duration"] = im_frame.info["duration"]
|
|
if isinstance(disposal, (list, tuple)):
|
|
encoderinfo["disposal"] = disposal[frame_count]
|
|
frame_count += 1
|
|
|
|
diff_frame = None
|
|
if im_frames and previous_im:
|
|
# delta frame
|
|
delta, bbox = _getbbox(previous_im, im_frame)
|
|
if not bbox:
|
|
# This frame is identical to the previous frame
|
|
if encoderinfo.get("duration"):
|
|
im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"]
|
|
continue
|
|
if im_frames[-1].encoderinfo.get("disposal") == 2:
|
|
if background_im is None:
|
|
color = im.encoderinfo.get(
|
|
"transparency", im.info.get("transparency", (0, 0, 0))
|
|
)
|
|
background = _get_background(im_frame, color)
|
|
background_im = Image.new("P", im_frame.size, background)
|
|
assert im_frames[0].im.palette is not None
|
|
background_im.putpalette(im_frames[0].im.palette)
|
|
bbox = _getbbox(background_im, im_frame)[1]
|
|
elif encoderinfo.get("optimize") and im_frame.mode != "1":
|
|
if "transparency" not in encoderinfo:
|
|
assert im_frame.palette is not None
|
|
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", delta.size, encoderinfo["transparency"])
|
|
if delta.mode == "RGBA":
|
|
r, g, b, a = delta.split()
|
|
mask = ImageMath.lambda_eval(
|
|
lambda args: args["convert"](
|
|
args["max"](
|
|
args["max"](
|
|
args["max"](args["r"], args["g"]), args["b"]
|
|
),
|
|
args["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.lambda_eval(
|
|
lambda args: args["convert"](args["im"] * 255, "1"),
|
|
im=delta,
|
|
)
|
|
diff_frame.paste(fill, mask=ImageOps.invert(mask))
|
|
else:
|
|
bbox = None
|
|
previous_im = im_frame
|
|
im_frames.append(_Frame(diff_frame or im_frame, bbox, encoderinfo))
|
|
|
|
if len(im_frames) == 1:
|
|
if "duration" in im.encoderinfo:
|
|
# Since multiple frames will not be written, use the combined duration
|
|
im.encoderinfo["duration"] = im_frames[0].encoderinfo["duration"]
|
|
return False
|
|
|
|
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
|
|
if not palette:
|
|
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: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
_save(im, fp, filename, save_all=True)
|
|
|
|
|
|
def _save(
|
|
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
|
|
) -> None:
|
|
# header
|
|
if "palette" in im.encoderinfo or "palette" in im.info:
|
|
palette = im.encoderinfo.get("palette", im.info.get("palette"))
|
|
else:
|
|
palette = None
|
|
im.encoderinfo.setdefault("optimize", True)
|
|
|
|
if not save_all or not _write_multiple_frames(im, fp, palette):
|
|
_write_single_frame(im, fp, palette)
|
|
|
|
fp.write(b";") # end of file
|
|
|
|
if hasattr(fp, "flush"):
|
|
fp.flush()
|
|
|
|
|
|
def get_interlace(im: Image.Image) -> int:
|
|
interlace = im.encoderinfo.get("interlace", 1)
|
|
|
|
# workaround for @PIL153
|
|
if min(im.size) < 16:
|
|
interlace = 0
|
|
|
|
return interlace
|
|
|
|
|
|
def _write_local_header(
|
|
fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int
|
|
) -> None:
|
|
try:
|
|
transparency = im.encoderinfo["transparency"]
|
|
except KeyError:
|
|
transparency = None
|
|
|
|
if "duration" in im.encoderinfo:
|
|
duration = int(im.encoderinfo["duration"] / 10)
|
|
else:
|
|
duration = 0
|
|
|
|
disposal = int(im.encoderinfo.get("disposal", 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
|
|
|
|
fp.write(
|
|
b"!"
|
|
+ o8(249) # extension intro
|
|
+ o8(4) # length
|
|
+ o8(packed_flag) # packed fields
|
|
+ o16(duration) # duration
|
|
+ o8(transparency or 0) # transparency index
|
|
+ o8(0)
|
|
)
|
|
|
|
include_color_table = im.encoderinfo.get("include_color_table")
|
|
if include_color_table:
|
|
palette_bytes = _get_palette_bytes(im)
|
|
color_table_size = _get_color_table_size(palette_bytes)
|
|
if color_table_size:
|
|
flags = flags | 128 # local color table flag
|
|
flags = flags | color_table_size
|
|
|
|
fp.write(
|
|
b","
|
|
+ o16(offset[0]) # offset
|
|
+ o16(offset[1])
|
|
+ o16(im.size[0]) # size
|
|
+ o16(im.size[1])
|
|
+ o8(flags) # flags
|
|
)
|
|
if include_color_table and color_table_size:
|
|
fp.write(_get_header_palette(palette_bytes))
|
|
fp.write(o8(8)) # bits
|
|
|
|
|
|
def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
# 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
|
|
# can use the external NETPBM/PBMPLUS utilities. See comments
|
|
# below for information on how to enable this.
|
|
tempfile = im._dump()
|
|
|
|
try:
|
|
with open(filename, "wb") as f:
|
|
if im.mode != "RGB":
|
|
subprocess.check_call(
|
|
["ppmtogif", tempfile], stdout=f, stderr=subprocess.DEVNULL
|
|
)
|
|
else:
|
|
# Pipe ppmquant output into ppmtogif
|
|
# "ppmquant 256 %s | ppmtogif > %s" % (tempfile, filename)
|
|
quant_cmd = ["ppmquant", "256", tempfile]
|
|
togif_cmd = ["ppmtogif"]
|
|
quant_proc = subprocess.Popen(
|
|
quant_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
|
|
)
|
|
togif_proc = subprocess.Popen(
|
|
togif_cmd,
|
|
stdin=quant_proc.stdout,
|
|
stdout=f,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
|
|
# Allow ppmquant to receive SIGPIPE if ppmtogif exits
|
|
assert quant_proc.stdout is not None
|
|
quant_proc.stdout.close()
|
|
|
|
retcode = quant_proc.wait()
|
|
if retcode:
|
|
raise subprocess.CalledProcessError(retcode, quant_cmd)
|
|
|
|
retcode = togif_proc.wait()
|
|
if retcode:
|
|
raise subprocess.CalledProcessError(retcode, togif_cmd)
|
|
finally:
|
|
try:
|
|
os.unlink(tempfile)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# Force optimization so that we can test performance against
|
|
# cases where it took lots of memory and time previously.
|
|
_FORCE_OPTIMIZE = False
|
|
|
|
|
|
def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None:
|
|
"""
|
|
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"):
|
|
# Potentially expensive operation.
|
|
|
|
# The palette saves 3 bytes per color not used, but palette
|
|
# lengths are restricted to 3*(2**N) bytes. Max saving would
|
|
# be 768 -> 6 bytes if we went all the way down to 2 colors.
|
|
# * If we're over 128 colors, we can't save any space.
|
|
# * If there aren't any holes, it's not worth collapsing.
|
|
# * If we have a 'large' image, the palette is in the noise.
|
|
|
|
# create the new palette if not every color is used
|
|
optimise = _FORCE_OPTIMIZE or im.mode == "L"
|
|
if optimise or im.width * im.height < 512 * 512:
|
|
# check which colors are used
|
|
used_palette_colors = []
|
|
for i, count in enumerate(im.histogram()):
|
|
if count:
|
|
used_palette_colors.append(i)
|
|
|
|
if optimise or max(used_palette_colors) >= len(used_palette_colors):
|
|
return used_palette_colors
|
|
|
|
assert im.palette is not None
|
|
num_palette_colors = len(im.palette.palette) // Image.getmodebands(
|
|
im.palette.mode
|
|
)
|
|
current_palette_size = 1 << (num_palette_colors - 1).bit_length()
|
|
if (
|
|
# check that the palette would become smaller when saved
|
|
len(used_palette_colors) <= current_palette_size // 2
|
|
# check that the palette is not already the smallest possible size
|
|
and current_palette_size > 2
|
|
):
|
|
return used_palette_colors
|
|
return None
|
|
|
|
|
|
def _get_color_table_size(palette_bytes: bytes) -> int:
|
|
# calculate the palette size for the header
|
|
if not palette_bytes:
|
|
return 0
|
|
elif len(palette_bytes) < 9:
|
|
return 1
|
|
else:
|
|
return math.ceil(math.log(len(palette_bytes) // 3, 2)) - 1
|
|
|
|
|
|
def _get_header_palette(palette_bytes: bytes) -> 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)
|
|
|
|
# add the missing amount of bytes
|
|
# the palette has to be 2<<n in size
|
|
actual_target_size_diff = (2 << color_table_size) - len(palette_bytes) // 3
|
|
if actual_target_size_diff > 0:
|
|
palette_bytes += o8(0) * 3 * actual_target_size_diff
|
|
return palette_bytes
|
|
|
|
|
|
def _get_palette_bytes(im: Image.Image) -> bytes:
|
|
"""
|
|
Gets the palette for inclusion in the gif header
|
|
|
|
:param im: Image object
|
|
:returns: Bytes, len<=768 suitable for inclusion in gif header
|
|
"""
|
|
if not im.palette:
|
|
return b""
|
|
|
|
palette = bytes(im.palette.palette)
|
|
if im.palette.mode == "RGBA":
|
|
palette = b"".join(palette[i * 4 : i * 4 + 3] for i in range(len(palette) // 3))
|
|
return palette
|
|
|
|
|
|
def _get_background(
|
|
im: Image.Image,
|
|
info_background: int | tuple[int, int, int] | tuple[int, int, int, int] | None,
|
|
) -> int:
|
|
background = 0
|
|
if info_background:
|
|
if isinstance(info_background, tuple):
|
|
# WebPImagePlugin stores an RGBA value in info["background"]
|
|
# So it must be converted to the same format as GifImagePlugin's
|
|
# info["background"] - a global color table index
|
|
assert im.palette is not None
|
|
try:
|
|
background = im.palette.getcolor(info_background, im)
|
|
except ValueError as e:
|
|
if str(e) not in (
|
|
# If all 256 colors are in use,
|
|
# then there is no need for the background color
|
|
"cannot allocate more than 256 colors",
|
|
# Ignore non-opaque WebP background
|
|
"cannot add non-opaque RGBA color to RGB palette",
|
|
):
|
|
raise
|
|
else:
|
|
background = info_background
|
|
return background
|
|
|
|
|
|
def _get_global_header(im: Image.Image, info: dict[str, Any]) -> list[bytes]:
|
|
"""Return a list of strings representing a GIF header"""
|
|
|
|
# Header Block
|
|
# https://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp
|
|
|
|
version = b"87a"
|
|
if im.info.get("version") == b"89a" or (
|
|
info
|
|
and (
|
|
"transparency" in info
|
|
or info.get("loop") is not None
|
|
or info.get("duration")
|
|
or info.get("comment")
|
|
)
|
|
):
|
|
version = b"89a"
|
|
|
|
background = _get_background(im, info.get("background"))
|
|
|
|
palette_bytes = _get_palette_bytes(im)
|
|
color_table_size = _get_color_table_size(palette_bytes)
|
|
|
|
header = [
|
|
b"GIF" # signature
|
|
+ version # version
|
|
+ o16(im.size[0]) # canvas width
|
|
+ o16(im.size[1]), # canvas height
|
|
# Logical Screen Descriptor
|
|
# size of global color table + global color table flag
|
|
o8(color_table_size + 128), # packed fields
|
|
# background + reserved/aspect
|
|
o8(background) + o8(0),
|
|
# Global Color Table
|
|
_get_header_palette(palette_bytes),
|
|
]
|
|
if info.get("loop") is not None:
|
|
header.append(
|
|
b"!"
|
|
+ o8(255) # extension intro
|
|
+ o8(11)
|
|
+ b"NETSCAPE2.0"
|
|
+ o8(3)
|
|
+ o8(1)
|
|
+ o16(info["loop"]) # number of loops
|
|
+ o8(0)
|
|
)
|
|
if info.get("comment"):
|
|
comment_block = b"!" + o8(254) # extension intro
|
|
|
|
comment = info["comment"]
|
|
if isinstance(comment, str):
|
|
comment = comment.encode()
|
|
for i in range(0, len(comment), 255):
|
|
subblock = comment[i : i + 255]
|
|
comment_block += o8(len(subblock)) + subblock
|
|
|
|
comment_block += o8(0)
|
|
header.append(comment_block)
|
|
return header
|
|
|
|
|
|
def _write_frame_data(
|
|
fp: IO[bytes],
|
|
im_frame: Image.Image,
|
|
offset: tuple[int, int],
|
|
params: dict[str, Any],
|
|
) -> None:
|
|
try:
|
|
im_frame.encoderinfo = params
|
|
|
|
# local image header
|
|
_write_local_header(fp, im_frame, offset, 0)
|
|
|
|
ImageFile._save(
|
|
im_frame,
|
|
fp,
|
|
[ImageFile._Tile("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: Image.Image, palette: _Palette | None = None, info: dict[str, Any] | None = None
|
|
) -> tuple[list[bytes], list[int] | 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)
|
|
|
|
"""
|
|
if info is None:
|
|
info = {}
|
|
|
|
used_palette_colors = _get_optimize(im, info)
|
|
|
|
if "background" not 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)
|
|
|
|
return header, used_palette_colors
|
|
|
|
|
|
def getdata(
|
|
im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any
|
|
) -> list[bytes]:
|
|
"""
|
|
Legacy Method
|
|
|
|
Return a list of strings representing this image.
|
|
The first string is a local image header, the rest contains
|
|
encoded image data.
|
|
|
|
To specify duration, add the time in milliseconds,
|
|
e.g. ``getdata(im_frame, duration=1000)``
|
|
|
|
: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
|
|
|
|
"""
|
|
from io import BytesIO
|
|
|
|
class Collector(BytesIO):
|
|
data = []
|
|
|
|
def write(self, data: Buffer) -> int:
|
|
self.data.append(data)
|
|
return len(data)
|
|
|
|
im.load() # make sure raster data is available
|
|
|
|
fp = Collector()
|
|
|
|
_write_frame_data(fp, im, offset, params)
|
|
|
|
return fp.data
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# Registry
|
|
|
|
Image.register_open(GifImageFile.format, GifImageFile, _accept)
|
|
Image.register_save(GifImageFile.format, _save)
|
|
Image.register_save_all(GifImageFile.format, _save_all)
|
|
Image.register_extension(GifImageFile.format, ".gif")
|
|
Image.register_mime(GifImageFile.format, "image/gif")
|
|
|
|
#
|
|
# Uncomment the following line if you wish to use NETPBM/PBMPLUS
|
|
# instead of the built-in "uncompressed" GIF encoder
|
|
|
|
# Image.register_save(GifImageFile.format, _save_netpbm)
|