Merge pull request #8128 from radarhere/type_hint_gif

This commit is contained in:
Hugo van Kemenade 2024-06-11 06:18:46 -06:00 committed by GitHub
commit ecf3a986ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 153 additions and 95 deletions

View File

@ -53,6 +53,7 @@ def test_closed_file() -> None:
def test_seek_after_close() -> None: def test_seek_after_close() -> None:
im = Image.open("Tests/images/iss634.gif") im = Image.open("Tests/images/iss634.gif")
assert isinstance(im, GifImagePlugin.GifImageFile)
im.load() im.load()
im.close() im.close()
@ -377,7 +378,8 @@ def test_save_netpbm_bmp_mode(tmp_path: Path) -> None:
img = img.convert("RGB") img = img.convert("RGB")
tempfile = str(tmp_path / "temp.gif") tempfile = str(tmp_path / "temp.gif")
GifImagePlugin._save_netpbm(img, 0, tempfile) b = BytesIO()
GifImagePlugin._save_netpbm(img, b, tempfile)
with Image.open(tempfile) as reloaded: with Image.open(tempfile) as reloaded:
assert_image_similar(img, reloaded.convert("RGB"), 0) assert_image_similar(img, reloaded.convert("RGB"), 0)
@ -388,7 +390,8 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None:
img = img.convert("L") img = img.convert("L")
tempfile = str(tmp_path / "temp.gif") tempfile = str(tmp_path / "temp.gif")
GifImagePlugin._save_netpbm(img, 0, tempfile) b = BytesIO()
GifImagePlugin._save_netpbm(img, b, tempfile)
with Image.open(tempfile) as reloaded: with Image.open(tempfile) as reloaded:
assert_image_similar(img, reloaded.convert("L"), 0) assert_image_similar(img, reloaded.convert("L"), 0)
@ -648,7 +651,7 @@ def test_dispose2_palette(tmp_path: Path) -> None:
assert rgb_img.getpixel((50, 50)) == circle assert rgb_img.getpixel((50, 50)) == circle
# Check that frame transparency wasn't added unnecessarily # Check that frame transparency wasn't added unnecessarily
assert img._frame_transparency is None assert getattr(img, "_frame_transparency") is None
def test_dispose2_diff(tmp_path: Path) -> None: def test_dispose2_diff(tmp_path: Path) -> None:

View File

@ -29,9 +29,10 @@ import itertools
import math import math
import os import os
import subprocess import subprocess
import sys
from enum import IntEnum from enum import IntEnum
from functools import cached_property from functools import cached_property
from typing import IO from typing import IO, TYPE_CHECKING, Any, List, Literal, NamedTuple, Union
from . import ( from . import (
Image, Image,
@ -46,6 +47,9 @@ from ._binary import i16le as i16
from ._binary import o8 from ._binary import o8
from ._binary import o16le as o16 from ._binary import o16le as o16
if TYPE_CHECKING:
from . import _imaging
class LoadingStrategy(IntEnum): class LoadingStrategy(IntEnum):
""".. versionadded:: 9.1.0""" """.. versionadded:: 9.1.0"""
@ -118,7 +122,7 @@ class GifImageFile(ImageFile.ImageFile):
self._seek(0) # get ready to read first frame self._seek(0) # get ready to read first frame
@property @property
def n_frames(self): def n_frames(self) -> int:
if self._n_frames is None: if self._n_frames is None:
current = self.tell() current = self.tell()
try: try:
@ -163,11 +167,11 @@ class GifImageFile(ImageFile.ImageFile):
msg = "no more images in GIF file" msg = "no more images in GIF file"
raise EOFError(msg) from e raise EOFError(msg) from e
def _seek(self, frame, update_image=True): def _seek(self, frame: int, update_image: bool = True) -> None:
if frame == 0: if frame == 0:
# rewind # rewind
self.__offset = 0 self.__offset = 0
self.dispose = None self.dispose: _imaging.ImagingCore | None = None
self.__frame = -1 self.__frame = -1
self._fp.seek(self.__rewind) self._fp.seek(self.__rewind)
self.disposal_method = 0 self.disposal_method = 0
@ -195,9 +199,9 @@ class GifImageFile(ImageFile.ImageFile):
msg = "no more images in GIF file" msg = "no more images in GIF file"
raise EOFError(msg) raise EOFError(msg)
palette = None palette: ImagePalette.ImagePalette | Literal[False] | None = None
info = {} info: dict[str, Any] = {}
frame_transparency = None frame_transparency = None
interlace = None interlace = None
frame_dispose_extent = None frame_dispose_extent = None
@ -213,7 +217,7 @@ class GifImageFile(ImageFile.ImageFile):
# #
s = self.fp.read(1) s = self.fp.read(1)
block = self.data() block = self.data()
if s[0] == 249: if s[0] == 249 and block is not None:
# #
# graphic control extension # graphic control extension
# #
@ -249,14 +253,14 @@ class GifImageFile(ImageFile.ImageFile):
info["comment"] = comment info["comment"] = comment
s = None s = None
continue continue
elif s[0] == 255 and frame == 0: elif s[0] == 255 and frame == 0 and block is not None:
# #
# application extension # application extension
# #
info["extension"] = block, self.fp.tell() info["extension"] = block, self.fp.tell()
if block[:11] == b"NETSCAPE2.0": if block[:11] == b"NETSCAPE2.0":
block = self.data() block = self.data()
if len(block) >= 3 and block[0] == 1: if block and len(block) >= 3 and block[0] == 1:
self.info["loop"] = i16(block, 1) self.info["loop"] = i16(block, 1)
while self.data(): while self.data():
pass pass
@ -345,12 +349,11 @@ class GifImageFile(ImageFile.ImageFile):
else: else:
return (color, color, color) return (color, color, color)
self.dispose_extent = frame_dispose_extent
try:
if self.disposal_method < 2:
# do not dispose or none specified
self.dispose = None self.dispose = None
elif self.disposal_method == 2: 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 # replace with background colour
# only dispose the extent in this frame # only dispose the extent in this frame
@ -387,7 +390,9 @@ class GifImageFile(ImageFile.ImageFile):
if self.mode in ("RGB", "RGBA"): if self.mode in ("RGB", "RGBA"):
dispose_mode = "RGBA" dispose_mode = "RGBA"
color = _rgb(frame_transparency) + (0,) color = _rgb(frame_transparency) + (0,)
self.dispose = Image.core.fill(dispose_mode, dispose_size, color) self.dispose = Image.core.fill(
dispose_mode, dispose_size, color
)
except AttributeError: except AttributeError:
pass pass
@ -498,7 +503,12 @@ def _normalize_mode(im: Image.Image) -> Image.Image:
return im.convert("L") return im.convert("L")
def _normalize_palette(im, palette, info): _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. Normalizes the palette for image.
- Sets the palette to the incoming palette, if provided. - Sets the palette to the incoming palette, if provided.
@ -526,8 +536,10 @@ def _normalize_palette(im, palette, info):
source_palette = bytearray(i // 3 for i in range(768)) source_palette = bytearray(i // 3 for i in range(768))
im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette) im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette)
used_palette_colors: list[int] | None
if palette: if palette:
used_palette_colors = [] used_palette_colors = []
assert source_palette is not None
for i in range(0, len(source_palette), 3): for i in range(0, len(source_palette), 3):
source_color = tuple(source_palette[i : i + 3]) source_color = tuple(source_palette[i : i + 3])
index = im.palette.colors.get(source_color) index = im.palette.colors.get(source_color)
@ -561,7 +573,7 @@ def _normalize_palette(im, palette, info):
def _write_single_frame( def _write_single_frame(
im: Image.Image, im: Image.Image,
fp: IO[bytes], fp: IO[bytes],
palette: bytes | bytearray | list[int] | ImagePalette.ImagePalette, palette: _Palette | None,
) -> None: ) -> None:
im_out = _normalize_mode(im) im_out = _normalize_mode(im)
for k, v in im_out.info.items(): for k, v in im_out.info.items():
@ -585,7 +597,7 @@ def _write_single_frame(
def _getbbox( def _getbbox(
base_im: Image.Image, im_frame: Image.Image base_im: Image.Image, im_frame: Image.Image
) -> tuple[Image.Image, tuple[int, int, int, int]]: ) -> tuple[Image.Image, tuple[int, int, int, int] | None]:
if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im): if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im):
im_frame = im_frame.convert("RGBA") im_frame = im_frame.convert("RGBA")
base_im = base_im.convert("RGBA") base_im = base_im.convert("RGBA")
@ -593,12 +605,20 @@ def _getbbox(
return delta, delta.getbbox(alpha_only=False) return delta, delta.getbbox(alpha_only=False)
def _write_multiple_frames(im, fp, palette): 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") duration = im.encoderinfo.get("duration")
disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) disposal = im.encoderinfo.get("disposal", im.info.get("disposal"))
im_frames = [] im_frames: list[_Frame] = []
previous_im = None previous_im: Image.Image | None = None
frame_count = 0 frame_count = 0
background_im = None background_im = None
for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])): for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])):
@ -624,24 +644,22 @@ def _write_multiple_frames(im, fp, palette):
frame_count += 1 frame_count += 1
diff_frame = None diff_frame = None
if im_frames: if im_frames and previous_im:
# delta frame # delta frame
delta, bbox = _getbbox(previous_im, im_frame) delta, bbox = _getbbox(previous_im, im_frame)
if not bbox: if not bbox:
# This frame is identical to the previous frame # This frame is identical to the previous frame
if encoderinfo.get("duration"): if encoderinfo.get("duration"):
im_frames[-1]["encoderinfo"]["duration"] += encoderinfo[ im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"]
"duration"
]
continue continue
if im_frames[-1]["encoderinfo"].get("disposal") == 2: if im_frames[-1].encoderinfo.get("disposal") == 2:
if background_im is None: if background_im is None:
color = im.encoderinfo.get( color = im.encoderinfo.get(
"transparency", im.info.get("transparency", (0, 0, 0)) "transparency", im.info.get("transparency", (0, 0, 0))
) )
background = _get_background(im_frame, color) background = _get_background(im_frame, color)
background_im = Image.new("P", im_frame.size, background) background_im = Image.new("P", im_frame.size, background)
background_im.putpalette(im_frames[0]["im"].palette) background_im.putpalette(im_frames[0].im.palette)
bbox = _getbbox(background_im, im_frame)[1] bbox = _getbbox(background_im, im_frame)[1]
elif encoderinfo.get("optimize") and im_frame.mode != "1": elif encoderinfo.get("optimize") and im_frame.mode != "1":
if "transparency" not in encoderinfo: if "transparency" not in encoderinfo:
@ -687,31 +705,29 @@ def _write_multiple_frames(im, fp, palette):
else: else:
bbox = None bbox = None
previous_im = im_frame previous_im = im_frame
im_frames.append( im_frames.append(_Frame(diff_frame or im_frame, bbox, encoderinfo))
{"im": diff_frame or im_frame, "bbox": bbox, "encoderinfo": encoderinfo}
)
if len(im_frames) == 1: if len(im_frames) == 1:
if "duration" in im.encoderinfo: if "duration" in im.encoderinfo:
# Since multiple frames will not be written, use the combined duration # Since multiple frames will not be written, use the combined duration
im.encoderinfo["duration"] = im_frames[0]["encoderinfo"]["duration"] im.encoderinfo["duration"] = im_frames[0].encoderinfo["duration"]
return return False
for frame_data in im_frames: for frame_data in im_frames:
im_frame = frame_data["im"] im_frame = frame_data.im
if not frame_data["bbox"]: if not frame_data.bbox:
# global header # global header
for s in _get_global_header(im_frame, frame_data["encoderinfo"]): for s in _get_global_header(im_frame, frame_data.encoderinfo):
fp.write(s) fp.write(s)
offset = (0, 0) offset = (0, 0)
else: else:
# compress difference # compress difference
if not palette: if not palette:
frame_data["encoderinfo"]["include_color_table"] = True frame_data.encoderinfo["include_color_table"] = True
im_frame = im_frame.crop(frame_data["bbox"]) im_frame = im_frame.crop(frame_data.bbox)
offset = frame_data["bbox"][:2] offset = frame_data.bbox[:2]
_write_frame_data(fp, im_frame, offset, frame_data["encoderinfo"]) _write_frame_data(fp, im_frame, offset, frame_data.encoderinfo)
return True return True
@ -748,7 +764,9 @@ def get_interlace(im: Image.Image) -> int:
return interlace return interlace
def _write_local_header(fp, im, offset, flags): def _write_local_header(
fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int
) -> None:
try: try:
transparency = im.encoderinfo["transparency"] transparency = im.encoderinfo["transparency"]
except KeyError: except KeyError:
@ -849,7 +867,7 @@ def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
_FORCE_OPTIMIZE = False _FORCE_OPTIMIZE = False
def _get_optimize(im, info): def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None:
""" """
Palette optimization is a potentially expensive operation. Palette optimization is a potentially expensive operation.
@ -893,6 +911,7 @@ def _get_optimize(im, info):
and current_palette_size > 2 and current_palette_size > 2
): ):
return used_palette_colors return used_palette_colors
return None
def _get_color_table_size(palette_bytes: bytes) -> int: def _get_color_table_size(palette_bytes: bytes) -> int:
@ -933,7 +952,10 @@ def _get_palette_bytes(im: Image.Image) -> bytes:
return im.palette.palette if im.palette else b"" return im.palette.palette if im.palette else b""
def _get_background(im, info_background): def _get_background(
im: Image.Image,
info_background: int | tuple[int, int, int] | tuple[int, int, int, int] | None,
) -> int:
background = 0 background = 0
if info_background: if info_background:
if isinstance(info_background, tuple): if isinstance(info_background, tuple):
@ -956,7 +978,7 @@ def _get_background(im, info_background):
return background return background
def _get_global_header(im, info): def _get_global_header(im: Image.Image, info: dict[str, Any]) -> list[bytes]:
"""Return a list of strings representing a GIF header""" """Return a list of strings representing a GIF header"""
# Header Block # Header Block
@ -1018,7 +1040,12 @@ def _get_global_header(im, info):
return header return header
def _write_frame_data(fp, im_frame, offset, params): def _write_frame_data(
fp: IO[bytes],
im_frame: Image.Image,
offset: tuple[int, int],
params: dict[str, Any],
) -> None:
try: try:
im_frame.encoderinfo = params im_frame.encoderinfo = params
@ -1038,7 +1065,9 @@ def _write_frame_data(fp, im_frame, offset, params):
# Legacy GIF utilities # Legacy GIF utilities
def getheader(im, palette=None, info=None): 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. Legacy Method to get Gif data from image.
@ -1050,11 +1079,11 @@ def getheader(im, palette=None, info=None):
:returns: tuple of(list of header items, optimized palette) :returns: tuple of(list of header items, optimized palette)
""" """
used_palette_colors = _get_optimize(im, info)
if info is None: if info is None:
info = {} info = {}
used_palette_colors = _get_optimize(im, info)
if "background" not in info and "background" in im.info: if "background" not in info and "background" in im.info:
info["background"] = im.info["background"] info["background"] = im.info["background"]
@ -1066,7 +1095,9 @@ def getheader(im, palette=None, info=None):
return header, used_palette_colors return header, used_palette_colors
def getdata(im, offset=(0, 0), **params): def getdata(
im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any
) -> list[bytes]:
""" """
Legacy Method Legacy Method
@ -1083,12 +1114,23 @@ def getdata(im, offset=(0, 0), **params):
:returns: List of bytes containing GIF encoded frame data :returns: List of bytes containing GIF encoded frame data
""" """
from io import BytesIO
class Collector: class Collector(BytesIO):
data = [] data = []
def write(self, data: bytes) -> None: if sys.version_info >= (3, 12):
from collections.abc import Buffer
def write(self, data: Buffer) -> int:
self.data.append(data) self.data.append(data)
return len(data)
else:
def write(self, data: Any) -> int:
self.data.append(data)
return len(data)
im.load() # make sure raster data is available im.load() # make sure raster data is available

View File

@ -41,7 +41,7 @@ import warnings
from collections.abc import Callable, MutableMapping from collections.abc import Callable, MutableMapping
from enum import IntEnum from enum import IntEnum
from types import ModuleType from types import ModuleType
from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, cast from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, Tuple, cast
# VERSION was removed in Pillow 6.0.0. # VERSION was removed in Pillow 6.0.0.
# PILLOW_VERSION was removed in Pillow 9.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0.
@ -1367,7 +1367,7 @@ class Image:
""" """
return ImageMode.getmode(self.mode).bands return ImageMode.getmode(self.mode).bands
def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int]: def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int] | None:
""" """
Calculates the bounding box of the non-zero regions in the Calculates the bounding box of the non-zero regions in the
image. image.
@ -3029,12 +3029,18 @@ def new(
color = ImageColor.getcolor(color, mode) color = ImageColor.getcolor(color, mode)
im = Image() im = Image()
if mode == "P" and isinstance(color, (list, tuple)) and len(color) in [3, 4]: if (
mode == "P"
and isinstance(color, (list, tuple))
and all(isinstance(i, int) for i in color)
):
color_ints: tuple[int, ...] = cast(Tuple[int, ...], tuple(color))
if len(color_ints) == 3 or len(color_ints) == 4:
# RGB or RGBA value for a P image # RGB or RGBA value for a P image
from . import ImagePalette from . import ImagePalette
im.palette = ImagePalette.ImagePalette() im.palette = ImagePalette.ImagePalette()
color = im.palette.getcolor(color) color = im.palette.getcolor(color_ints)
return im._new(core.fill(mode, size, color)) return im._new(core.fill(mode, size, color))

View File

@ -497,7 +497,7 @@ def expand(
color = _color(fill, image.mode) color = _color(fill, image.mode)
if image.palette: if image.palette:
palette = ImagePalette.ImagePalette(palette=image.getpalette()) palette = ImagePalette.ImagePalette(palette=image.getpalette())
if isinstance(color, tuple): if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4):
color = palette.getcolor(color) color = palette.getcolor(color)
else: else:
palette = None palette = None

View File

@ -18,10 +18,13 @@
from __future__ import annotations from __future__ import annotations
import array import array
from typing import IO, Sequence from typing import IO, TYPE_CHECKING, Sequence
from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile
if TYPE_CHECKING:
from . import Image
class ImagePalette: class ImagePalette:
""" """
@ -128,7 +131,11 @@ class ImagePalette:
raise ValueError(msg) from e raise ValueError(msg) from e
return index return index
def getcolor(self, color, image=None) -> int: def getcolor(
self,
color: tuple[int, int, int] | tuple[int, int, int, int],
image: Image.Image | None = None,
) -> int:
"""Given an rgb tuple, allocate palette entry. """Given an rgb tuple, allocate palette entry.
.. warning:: This method is experimental. .. warning:: This method is experimental.
@ -163,7 +170,7 @@ class ImagePalette:
self.dirty = 1 self.dirty = 1
return index return index
else: else:
msg = f"unknown color specifier: {repr(color)}" msg = f"unknown color specifier: {repr(color)}" # type: ignore[unreachable]
raise ValueError(msg) raise ValueError(msg)
def save(self, fp: str | IO[str]) -> None: def save(self, fp: str | IO[str]) -> None: