123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197 |
- #
- # 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)
- 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)
- first_palette = im_frames[0].im.palette
- assert first_palette is not None
- background_im.putpalette(first_palette, first_palette.mode)
- 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)
|