Merge pull request #6150 from radarhere/gif

This commit is contained in:
Hugo van Kemenade 2022-03-30 23:23:59 +03:00 committed by GitHub
commit e60ca89721
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 244 additions and 97 deletions

BIN
Tests/images/no_palette.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 B

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -62,12 +62,46 @@ def test_invalid_file():
def test_l_mode_transparency(): def test_l_mode_transparency():
with Image.open("Tests/images/no_palette_with_transparency.gif") as im: with Image.open("Tests/images/no_palette_with_transparency.gif") as im:
assert im.mode == "L" assert im.mode == "L"
assert im.load()[0, 0] == 0 assert im.load()[0, 0] == 128
assert im.info["transparency"] == 255 assert im.info["transparency"] == 255
im.seek(1) im.seek(1)
assert im.mode == "LA" assert im.mode == "L"
assert im.load()[0, 0] == (0, 255) assert im.load()[0, 0] == 128
def test_strategy():
with Image.open("Tests/images/chi.gif") as im:
expected_zero = im.convert("RGB")
im.seek(1)
expected_one = im.convert("RGB")
try:
GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_ALWAYS
with Image.open("Tests/images/chi.gif") as im:
assert im.mode == "RGB"
assert_image_equal(im, expected_zero)
GifImagePlugin.LOADING_STRATEGY = (
GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY
)
# Stay in P mode with only a global palette
with Image.open("Tests/images/chi.gif") as im:
assert im.mode == "P"
im.seek(1)
assert im.mode == "P"
assert_image_equal(im.convert("RGB"), expected_one)
# Change to RGB mode when a frame has an individual palette
with Image.open("Tests/images/iss634.gif") as im:
assert im.mode == "P"
im.seek(1)
assert im.mode == "RGB"
finally:
GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST
def test_optimize(): def test_optimize():
@ -394,18 +428,38 @@ def test_dispose_background_transparency():
assert px[35, 30][3] == 0 assert px[35, 30][3] == 0
def test_transparent_dispose(): @pytest.mark.parametrize(
expected_colors = [ "loading_strategy, expected_colors",
(2, 1, 2), (
((0, 255, 24, 255), (0, 0, 255, 255), (0, 255, 24, 255)), (
((0, 0, 0, 0), (0, 0, 255, 255), (0, 0, 0, 0)), GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST,
] (
with Image.open("Tests/images/transparent_dispose.gif") as img: (2, 1, 2),
for frame in range(3): ((0, 255, 24, 255), (0, 0, 255, 255), (0, 255, 24, 255)),
img.seek(frame) ((0, 0, 0, 0), (0, 0, 255, 255), (0, 0, 0, 0)),
for x in range(3): ),
color = img.getpixel((x, 0)) ),
assert color == expected_colors[frame][x] (
GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY,
(
(2, 1, 2),
(0, 1, 0),
(2, 1, 2),
),
),
),
)
def test_transparent_dispose(loading_strategy, expected_colors):
GifImagePlugin.LOADING_STRATEGY = loading_strategy
try:
with Image.open("Tests/images/transparent_dispose.gif") as img:
for frame in range(3):
img.seek(frame)
for x in range(3):
color = img.getpixel((x, 0))
assert color == expected_colors[frame][x]
finally:
GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST
def test_dispose_previous(): def test_dispose_previous():

View File

@ -107,8 +107,34 @@ writes run-length encoded files in GIF87a by default, unless GIF89a features
are used or GIF89a is already in use. are used or GIF89a is already in use.
GIF files are initially read as grayscale (``L``) or palette mode (``P``) GIF files are initially read as grayscale (``L``) or palette mode (``P``)
images, but seeking to later frames in an image will change the mode to either images. Seeking to later frames in a ``P`` image will change the image to
``RGB`` or ``RGBA``, depending on whether the first frame had transparency. ``RGB`` (or ``RGBA`` if the first frame had transparency).
``P`` mode images are changed to ``RGB`` because each frame of a GIF may contain
its own individual palette of up to 256 colors. When a new frame is placed onto a
previous frame, those colors may combine to exceed the ``P`` mode limit of 256
colors. Instead, the image is converted to ``RGB`` handle this.
If you would prefer the first ``P`` image frame to be ``RGB`` as well, so that
every ``P`` frame is converted to ``RGB`` or ``RGBA`` mode, there is a setting
available::
from PIL import GifImagePlugin
GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_ALWAYS
GIF frames do not always contain individual palettes however. If there is only
a global palette, then all of the colors can fit within ``P`` mode. If you would
prefer the frames to be kept as ``P`` in that case, there is also a setting
available::
from PIL import GifImagePlugin
GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY
To restore the default behavior, where ``P`` mode images are only converted to
``RGB`` or ``RGBA`` after the first frame::
from PIL import GifImagePlugin
GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST
The :py:meth:`~PIL.Image.open` method sets the following The :py:meth:`~PIL.Image.open` method sets the following
:py:attr:`~PIL.Image.Image.info` properties: :py:attr:`~PIL.Image.Image.info` properties:

View File

@ -160,6 +160,26 @@ Added PyEncoder
written in Python. See :ref:`Writing Your Own File Codec in Python<file-codecs-py>` for written in Python. See :ref:`Writing Your Own File Codec in Python<file-codecs-py>` for
more information. more information.
GifImagePlugin loading strategy
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Pillow 9.0.0 introduced the conversion of subsequent GIF frames to ``RGB`` or ``RGBA``. This
behaviour can now be changed so that the first ``P`` frame is converted to ``RGB`` as
well.
.. code-block:: python
from PIL import GifImagePlugin
GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_ALWAYS
Or subsequent frames can be kept in ``P`` mode as long as there is only a single
palette.
.. code-block:: python
from PIL import GifImagePlugin
GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY
Other Changes Other Changes
============= =============

View File

@ -28,12 +28,25 @@ import itertools
import math import math
import os import os
import subprocess import subprocess
from enum import IntEnum
from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence
from ._binary import i16le as i16 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
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 # Identify/read GIF files
@ -61,6 +74,12 @@ class GifImageFile(ImageFile.ImageFile):
return self.fp.read(s[0]) return self.fp.read(s[0])
return None return None
def _is_palette_needed(self, p):
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): def _open(self):
# Screen # Screen
@ -79,11 +98,9 @@ class GifImageFile(ImageFile.ImageFile):
self.info["background"] = s[11] self.info["background"] = s[11]
# check if palette contains colour indices # check if palette contains colour indices
p = self.fp.read(3 << bits) p = self.fp.read(3 << bits)
for i in range(0, len(p), 3): if self._is_palette_needed(p):
if not (i // 3 == p[i] == p[i + 1] == p[i + 2]): p = ImagePalette.raw("RGB", p)
p = ImagePalette.raw("RGB", p) self.global_palette = self.palette = p
self.global_palette = self.palette = p
break
self.__fp = self.fp # FIXME: hack self.__fp = self.fp # FIXME: hack
self.__rewind = self.fp.tell() self.__rewind = self.fp.tell()
@ -143,7 +160,6 @@ class GifImageFile(ImageFile.ImageFile):
# rewind # rewind
self.__offset = 0 self.__offset = 0
self.dispose = None self.dispose = None
self.dispose_extent = [0, 0, 0, 0] # x0, y0, x1, y1
self.__frame = -1 self.__frame = -1
self.__fp.seek(self.__rewind) self.__fp.seek(self.__rewind)
self.disposal_method = 0 self.disposal_method = 0
@ -171,32 +187,12 @@ class GifImageFile(ImageFile.ImageFile):
self.tile = [] self.tile = []
if update_image:
if self.__frame == 1:
self.pyaccess = None
if "transparency" in self.info:
if self.mode == "P":
self.im.putpalettealpha(self.info["transparency"], 0)
self.im = self.im.convert("RGBA", Image.Dither.FLOYDSTEINBERG)
self.mode = "RGBA"
else:
self.im = self.im.convert_transparent(
"LA", self.info["transparency"]
)
self.mode = "LA"
del self.info["transparency"]
else:
self.mode = "RGB"
self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG)
if self.dispose:
self.im.paste(self.dispose, self.dispose_extent)
palette = None palette = None
info = {} info = {}
frame_transparency = None frame_transparency = None
interlace = None interlace = None
frame_dispose_extent = None
while True: while True:
if not s: if not s:
@ -263,14 +259,16 @@ class GifImageFile(ImageFile.ImageFile):
x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6)
if (x1 > self.size[0] or y1 > self.size[1]) and update_image: 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]) self._size = max(x1, self.size[0]), max(y1, self.size[1])
self.dispose_extent = x0, y0, x1, y1 frame_dispose_extent = x0, y0, x1, y1
flags = s[8] flags = s[8]
interlace = (flags & 64) != 0 interlace = (flags & 64) != 0
if flags & 128: if flags & 128:
bits = (flags & 7) + 1 bits = (flags & 7) + 1
palette = ImagePalette.raw("RGB", self.fp.read(3 << bits)) p = self.fp.read(3 << bits)
if self._is_palette_needed(p):
palette = ImagePalette.raw("RGB", p)
# image data # image data
bits = self.fp.read(1)[0] bits = self.fp.read(1)[0]
@ -288,15 +286,48 @@ class GifImageFile(ImageFile.ImageFile):
if not update_image: if not update_image:
return return
frame_palette = palette or self.global_palette if self.dispose:
self.im.paste(self.dispose, self.dispose_extent)
self._frame_palette = palette or self.global_palette
if frame == 0:
if self._frame_palette:
self.mode = (
"RGB" if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS else "P"
)
else:
self.mode = "L"
if not palette and self.global_palette:
from copy import copy
palette = copy(self.global_palette)
self.palette = palette
else:
self._frame_transparency = frame_transparency
if self.mode == "P":
if (
LOADING_STRATEGY != LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY
or palette
):
self.pyaccess = None
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): def _rgb(color):
if frame_palette: if self._frame_palette:
color = tuple(frame_palette.palette[color * 3 : color * 3 + 3]) color = tuple(self._frame_palette.palette[color * 3 : color * 3 + 3])
else: else:
color = (color, color, color) color = (color, color, color)
return color return color
self.dispose_extent = frame_dispose_extent
try: try:
if self.disposal_method < 2: if self.disposal_method < 2:
# do not dispose or none specified # do not dispose or none specified
@ -311,13 +342,17 @@ class GifImageFile(ImageFile.ImageFile):
Image._decompression_bomb_check(dispose_size) Image._decompression_bomb_check(dispose_size)
# by convention, attempt to use transparency first # by convention, attempt to use transparency first
dispose_mode = "P"
color = self.info.get("transparency", frame_transparency) color = self.info.get("transparency", frame_transparency)
if color is not None: if color is not None:
dispose_mode = "RGBA" if self.mode in ("RGB", "RGBA"):
color = _rgb(color) + (0,) dispose_mode = "RGBA"
color = _rgb(color) + (0,)
else: else:
dispose_mode = "RGB" color = self.info.get("background", 0)
color = _rgb(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) self.dispose = Image.core.fill(dispose_mode, dispose_size, color)
else: else:
# replace with previous contents # replace with previous contents
@ -329,21 +364,28 @@ class GifImageFile(ImageFile.ImageFile):
dispose_size = (x1 - x0, y1 - y0) dispose_size = (x1 - x0, y1 - y0)
Image._decompression_bomb_check(dispose_size) Image._decompression_bomb_check(dispose_size)
self.dispose = Image.core.fill( dispose_mode = "P"
"RGBA", dispose_size, _rgb(frame_transparency) + (0,) 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: except AttributeError:
pass pass
if interlace is not None: if interlace is not None:
if frame == 0 and frame_transparency is not None: transparency = -1
self.info["transparency"] = frame_transparency if frame_transparency is not None:
if frame == 0:
self.info["transparency"] = frame_transparency
elif self.mode not in ("RGB", "RGBA"):
transparency = frame_transparency
self.tile = [ self.tile = [
( (
"gif", "gif",
(x0, y0, x1, y1), (x0, y0, x1, y1),
self.__offset, self.__offset,
(bits, interlace), (bits, interlace, transparency),
) )
] ]
@ -353,53 +395,47 @@ class GifImageFile(ImageFile.ImageFile):
elif k in self.info: elif k in self.info:
del self.info[k] del self.info[k]
if frame == 0:
self.mode = "P" if frame_palette else "L"
if self.mode == "P" and not palette:
from copy import copy
palette = copy(self.global_palette)
self.palette = palette
else:
self._frame_palette = frame_palette
self._frame_transparency = frame_transparency
def load_prepare(self): def load_prepare(self):
temp_mode = "P" if self._frame_palette else "L"
self._prev_im = None
if self.__frame == 0: if self.__frame == 0:
if "transparency" in self.info: if "transparency" in self.info:
self.im = Image.core.fill( self.im = Image.core.fill(
self.mode, self.size, self.info["transparency"] temp_mode, self.size, self.info["transparency"]
) )
else: elif self.mode in ("RGB", "RGBA"):
self._prev_im = self.im self._prev_im = self.im
if self._frame_palette: if self._frame_palette:
self.mode = "P"
self.im = Image.core.fill("P", self.size, self._frame_transparency or 0) self.im = Image.core.fill("P", self.size, self._frame_transparency or 0)
self.im.putpalette(*self._frame_palette.getdata()) self.im.putpalette(*self._frame_palette.getdata())
self._frame_palette = None
else: else:
self.mode = "L"
self.im = None self.im = None
self.mode = temp_mode
self._frame_palette = None
super().load_prepare() super().load_prepare()
def load_end(self): def load_end(self):
if self.__frame == 0: if self.__frame == 0:
if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS:
self.mode = "RGB"
self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG)
return return
if self._frame_transparency is not None: if self.mode == "P" and self._prev_im:
if self.mode == "P": if self._frame_transparency is not None:
self.im.putpalettealpha(self._frame_transparency, 0) self.im.putpalettealpha(self._frame_transparency, 0)
frame_im = self.im.convert("RGBA") frame_im = self.im.convert("RGBA")
else: else:
frame_im = self.im.convert_transparent("LA", self._frame_transparency) frame_im = self.im.convert("RGB")
else: else:
frame_im = self.im.convert("RGB") if not self._prev_im:
return
frame_im = self.im
frame_im = self._crop(frame_im, self.dispose_extent) frame_im = self._crop(frame_im, self.dispose_extent)
self.im = self._prev_im self.im = self._prev_im
self.mode = self.im.mode self.mode = self.im.mode
if frame_im.mode in ("LA", "RGBA"): if frame_im.mode == "RGBA":
self.im.paste(frame_im, self.dispose_extent, frame_im) self.im.paste(frame_im, self.dispose_extent, frame_im)
else: else:
self.im.paste(frame_im, self.dispose_extent) self.im.paste(frame_im, self.dispose_extent)

View File

@ -433,7 +433,8 @@ PyImaging_GifDecoderNew(PyObject *self, PyObject *args) {
char *mode; char *mode;
int bits = 8; int bits = 8;
int interlace = 0; int interlace = 0;
if (!PyArg_ParseTuple(args, "s|ii", &mode, &bits, &interlace)) { int transparency = -1;
if (!PyArg_ParseTuple(args, "s|iii", &mode, &bits, &interlace, &transparency)) {
return NULL; return NULL;
} }
@ -451,6 +452,7 @@ PyImaging_GifDecoderNew(PyObject *self, PyObject *args) {
((GIFDECODERSTATE *)decoder->state.context)->bits = bits; ((GIFDECODERSTATE *)decoder->state.context)->bits = bits;
((GIFDECODERSTATE *)decoder->state.context)->interlace = interlace; ((GIFDECODERSTATE *)decoder->state.context)->interlace = interlace;
((GIFDECODERSTATE *)decoder->state.context)->transparency = transparency;
return (PyObject *)decoder; return (PyObject *)decoder;
} }

View File

@ -30,6 +30,9 @@ typedef struct {
*/ */
int interlace; int interlace;
/* The transparent palette index, or -1 for no transparency */
int transparency;
/* PRIVATE CONTEXT (set by decoder) */ /* PRIVATE CONTEXT (set by decoder) */
/* Interlace parameters */ /* Interlace parameters */

View File

@ -248,27 +248,33 @@ ImagingGifDecode(Imaging im, ImagingCodecState state, UINT8 *buffer, Py_ssize_t
/* To squeeze some extra pixels out of this loop, we test for /* To squeeze some extra pixels out of this loop, we test for
some common cases and handle them separately. */ some common cases and handle them separately. */
if (i == 1) { /* This cannot be used if there is transparency */
if (state->x < state->xsize - 1) { if (context->transparency == -1) {
/* Single pixel, not at the end of the line. */ if (i == 1) {
*out++ = p[0]; if (state->x < state->xsize - 1) {
state->x++; /* Single pixel, not at the end of the line. */
*out++ = p[0];
state->x++;
continue;
}
} else if (state->x + i <= state->xsize) {
/* This string fits into current line. */
memcpy(out, p, i);
out += i;
state->x += i;
if (state->x == state->xsize) {
NEWLINE(state, context);
}
continue; continue;
} }
} else if (state->x + i <= state->xsize) {
/* This string fits into current line. */
memcpy(out, p, i);
out += i;
state->x += i;
if (state->x == state->xsize) {
NEWLINE(state, context);
}
continue;
} }
/* No shortcut, copy pixel by pixel */ /* No shortcut, copy pixel by pixel */
for (c = 0; c < i; c++) { for (c = 0; c < i; c++) {
*out++ = p[c]; if (p[c] != context->transparency) {
*out = p[c];
}
out++;
if (++state->x >= state->xsize) { if (++state->x >= state->xsize) {
NEWLINE(state, context); NEWLINE(state, context);
} }