| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | from __future__ import annotations |
| |
|
| | import itertools |
| | import math |
| | import os |
| | import subprocess |
| | from enum import IntEnum |
| | from functools import cached_property |
| | from typing import Any, NamedTuple, cast |
| |
|
| | 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 |
| | from ._util import DeferredError |
| |
|
| | TYPE_CHECKING = False |
| | if TYPE_CHECKING: |
| | from typing import IO, Literal |
| |
|
| | 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 |
| |
|
| |
|
| | |
| | LOADING_STRATEGY = LoadingStrategy.RGB_AFTER_FIRST |
| |
|
| | |
| | |
| |
|
| |
|
| | def _accept(prefix: bytes) -> bool: |
| | return prefix.startswith((b"GIF87a", b"GIF89a")) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| |
|
| | class GifImageFile(ImageFile.ImageFile): |
| | format = "GIF" |
| | format_description = "Compuserve GIF" |
| | _close_exclusive_fp_after_loading = False |
| |
|
| | global_palette = None |
| |
|
| | def data(self) -> bytes | None: |
| | assert self.fp is not 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: |
| | |
| | assert self.fp is not None |
| | 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: |
| | |
| | self.info["background"] = s[11] |
| | |
| | p = self.fp.read(3 << bits) |
| | if self._is_palette_needed(p): |
| | palette = ImagePalette.raw("RGB", p) |
| | self.global_palette = self.palette = palette |
| |
|
| | self._fp = self.fp |
| | self.__rewind = self.fp.tell() |
| | self._n_frames: int | None = None |
| | self._seek(0) |
| |
|
| | @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 isinstance(self._fp, DeferredError): |
| | raise self._fp.ex |
| | if frame == 0: |
| | |
| | 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: |
| | |
| | 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: |
| | |
| | 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"!": |
| | |
| | |
| | |
| | s = self.fp.read(1) |
| | block = self.data() |
| | if s[0] == 249 and block is not None: |
| | |
| | |
| | |
| | flags = block[0] |
| | if flags & 1: |
| | frame_transparency = block[3] |
| | info["duration"] = i16(block, 1) * 10 |
| |
|
| | |
| | dispose_bits = 0b00011100 & flags |
| | dispose_bits = dispose_bits >> 2 |
| | if dispose_bits: |
| | |
| | |
| | |
| | |
| | self.disposal_method = dispose_bits |
| | elif s[0] == 254: |
| | |
| | |
| | |
| | comment = b"" |
| |
|
| | |
| | while block: |
| | comment += block |
| | block = self.data() |
| |
|
| | if "comment" in info: |
| | |
| | info["comment"] += b"\n" + comment |
| | else: |
| | info["comment"] = comment |
| | s = b"" |
| | continue |
| | elif s[0] == 255 and frame == 0 and block is not None: |
| | |
| | |
| | |
| | info["extension"] = block, self.fp.tell() |
| | if block.startswith(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",": |
| | |
| | |
| | |
| | s = self.fp.read(9) |
| |
|
| | |
| | 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 |
| |
|
| | |
| | bits = self.fp.read(1)[0] |
| | self.__offset = self.fp.tell() |
| | break |
| | s = b"" |
| |
|
| | 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 cast( |
| | tuple[int, int, int], |
| | tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]), |
| | ) |
| | else: |
| | return (color, color, color) |
| |
|
| | self.dispose = None |
| | self.dispose_extent: tuple[int, int, int, int] | None = frame_dispose_extent |
| | if self.dispose_extent and self.disposal_method >= 2: |
| | try: |
| | if self.disposal_method == 2: |
| | |
| |
|
| | |
| | x0, y0, x1, y1 = self.dispose_extent |
| | dispose_size = (x1 - x0, y1 - y0) |
| |
|
| | Image._decompression_bomb_check(dispose_size) |
| |
|
| | |
| | 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: |
| | |
| | if self._im is not None: |
| | |
| | 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: |
| | if self.mode == "L": |
| | frame_im = self.im.convert_transparent("LA", self._frame_transparency) |
| | else: |
| | 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 in ("LA", "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 |
| |
|
| |
|
| | |
| | |
| |
|
| |
|
| | 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 = 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: |
| | |
| | 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: |
| | 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) |
| |
|
| | |
| | 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") |
| |
|
| |
|
| | 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): |
| | |
| | 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, bbox = _getbbox(previous_im, im_frame) |
| | if not bbox: |
| | |
| | if encoderinfo.get("duration"): |
| | im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"] |
| | continue |
| | if im_frames[-1].encoderinfo.get("disposal") == 2: |
| | |
| | |
| | color = im.encoderinfo.get( |
| | "transparency", im.info.get("transparency") |
| | ) |
| | if color is not None: |
| | if background_im is None: |
| | 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] |
| | else: |
| | bbox = (0, 0) + im_frame.size |
| | 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: |
| | |
| | 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": |
| | |
| | delta_l = Image.new("L", delta.size) |
| | delta_l.putdata(delta.get_flattened_data()) |
| | 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: |
| | |
| | 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: |
| | |
| | for s in _get_global_header(im_frame, frame_data.encoderinfo): |
| | fp.write(s) |
| | offset = (0, 0) |
| | else: |
| | |
| | if not palette: |
| | frame_data.encoderinfo["include_color_table"] = True |
| |
|
| | if frame_data.bbox != (0, 0) + im_frame.size: |
| | 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: |
| | |
| | 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";") |
| |
|
| | if hasattr(fp, "flush"): |
| | fp.flush() |
| |
|
| |
|
| | def get_interlace(im: Image.Image) -> int: |
| | interlace = im.encoderinfo.get("interlace", 1) |
| |
|
| | |
| | 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) |
| | + o8(4) |
| | + o8(packed_flag) |
| | + o16(duration) |
| | + o8(transparency or 0) |
| | + 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 |
| | flags = flags | color_table_size |
| |
|
| | fp.write( |
| | b"," |
| | + o16(offset[0]) |
| | + o16(offset[1]) |
| | + o16(im.size[0]) |
| | + o16(im.size[1]) |
| | + o8(flags) |
| | ) |
| | if include_color_table and color_table_size: |
| | fp.write(_get_header_palette(palette_bytes)) |
| | fp.write(o8(8)) |
| |
|
| |
|
| | def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: |
| | |
| | |
| | |
| | |
| | |
| | |
| | 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: |
| | |
| | |
| | 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, |
| | ) |
| |
|
| | |
| | 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_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"): |
| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | |
| | optimise = _FORCE_OPTIMIZE or im.mode == "L" |
| | if optimise or im.width * im.height < 512 * 512: |
| | |
| | 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 ( |
| | |
| | len(used_palette_colors) <= current_palette_size // 2 |
| | |
| | and current_palette_size > 2 |
| | ): |
| | return used_palette_colors |
| | return None |
| |
|
| |
|
| | def _get_color_table_size(palette_bytes: bytes) -> int: |
| | |
| | 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) |
| |
|
| | |
| | |
| | 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): |
| | |
| | |
| | |
| | assert im.palette is not None |
| | try: |
| | background = im.palette.getcolor(info_background, im) |
| | except ValueError as e: |
| | if str(e) not in ( |
| | |
| | |
| | "cannot allocate more than 256 colors", |
| | |
| | "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""" |
| |
|
| | |
| | |
| |
|
| | 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" |
| | + version |
| | + o16(im.size[0]) |
| | + o16(im.size[1]), |
| | |
| | |
| | o8(color_table_size + 128), |
| | |
| | o8(background) + o8(0), |
| | |
| | _get_header_palette(palette_bytes), |
| | ] |
| | if info.get("loop") is not None: |
| | header.append( |
| | b"!" |
| | + o8(255) |
| | + o8(11) |
| | + b"NETSCAPE2.0" |
| | + o8(3) |
| | + o8(1) |
| | + o16(info["loop"]) |
| | + o8(0) |
| | ) |
| | if info.get("comment"): |
| | comment_block = b"!" + o8(254) |
| |
|
| | 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 |
| |
|
| | |
| | _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") |
| | finally: |
| | del im_frame.encoderinfo |
| |
|
| |
|
| | |
| | |
| |
|
| |
|
| | 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() |
| |
|
| | fp = Collector() |
| |
|
| | _write_frame_data(fp, im, offset, params) |
| |
|
| | return fp.data |
| |
|
| |
|
| | |
| | |
| |
|
| | 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") |
| |
|
| | |
| | |
| | |
| |
|
| | |
| |
|