spectra/lib/python3.12/site-packages/PIL/GifImagePlugin.py
2024-11-05 13:55:44 -05:00

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)