diff --git a/Tests/images/no_palette.gif b/Tests/images/no_palette.gif new file mode 100644 index 00000000000..0432ebcb61c Binary files /dev/null and b/Tests/images/no_palette.gif differ diff --git a/Tests/images/no_palette_with_transparency.gif b/Tests/images/no_palette_with_transparency.gif index 3cd1c0c48eb..031bdcfce10 100644 Binary files a/Tests/images/no_palette_with_transparency.gif and b/Tests/images/no_palette_with_transparency.gif differ diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 924adad9e62..a7e574ba5b2 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -62,12 +62,46 @@ def test_invalid_file(): def test_l_mode_transparency(): with Image.open("Tests/images/no_palette_with_transparency.gif") as im: assert im.mode == "L" - assert im.load()[0, 0] == 0 + assert im.load()[0, 0] == 128 assert im.info["transparency"] == 255 im.seek(1) - assert im.mode == "LA" - assert im.load()[0, 0] == (0, 255) + assert im.mode == "L" + 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(): @@ -394,18 +428,38 @@ def test_dispose_background_transparency(): assert px[35, 30][3] == 0 -def test_transparent_dispose(): - 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)), - ] - 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] +@pytest.mark.parametrize( + "loading_strategy, expected_colors", + ( + ( + GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST, + ( + (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_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(): diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 08af189da82..c8568719cc4 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -106,8 +106,34 @@ writes run-length encoded files in GIF87a by default, unless GIF89a features are used or GIF89a is already in use. 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 -``RGB`` or ``RGBA``, depending on whether the first frame had transparency. +images. Seeking to later frames in a ``P`` image will change the image to +``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 :py:attr:`~PIL.Image.Image.info` properties: diff --git a/docs/releasenotes/9.1.0.rst b/docs/releasenotes/9.1.0.rst index 1e5203ac213..5a0bdba3ef7 100644 --- a/docs/releasenotes/9.1.0.rst +++ b/docs/releasenotes/9.1.0.rst @@ -160,6 +160,26 @@ Added PyEncoder written in Python. See :ref:`Writing Your Own File Codec in Python` for 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 ============= diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 9cf27b0261b..cee4be0689f 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -28,12 +28,25 @@ import math import os import subprocess +from enum import IntEnum from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence from ._binary import i16le as i16 from ._binary import o8 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 @@ -61,6 +74,12 @@ def data(self): return self.fp.read(s[0]) 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): # Screen @@ -79,11 +98,9 @@ def _open(self): self.info["background"] = s[11] # check if palette contains colour indices p = self.fp.read(3 << bits) - for i in range(0, len(p), 3): - if not (i // 3 == p[i] == p[i + 1] == p[i + 2]): - p = ImagePalette.raw("RGB", p) - self.global_palette = self.palette = p - break + 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() @@ -143,7 +160,6 @@ def _seek(self, frame, update_image=True): # rewind self.__offset = 0 self.dispose = None - self.dispose_extent = [0, 0, 0, 0] # x0, y0, x1, y1 self.__frame = -1 self.__fp.seek(self.__rewind) self.disposal_method = 0 @@ -171,32 +187,12 @@ def _seek(self, frame, update_image=True): 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 info = {} frame_transparency = None interlace = None + frame_dispose_extent = None while True: if not s: @@ -263,14 +259,16 @@ def _seek(self, frame, update_image=True): x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) if x1 > self.size[0] or 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] interlace = (flags & 64) != 0 if flags & 128: 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 bits = self.fp.read(1)[0] @@ -288,15 +286,48 @@ def _seek(self, frame, update_image=True): if not update_image: 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): - if frame_palette: - color = tuple(frame_palette.palette[color * 3 : color * 3 + 3]) + if self._frame_palette: + color = tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]) else: color = (color, color, color) return color + self.dispose_extent = frame_dispose_extent try: if self.disposal_method < 2: # do not dispose or none specified @@ -311,13 +342,17 @@ def _rgb(color): 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: - dispose_mode = "RGBA" - color = _rgb(color) + (0,) + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(color) + (0,) else: - dispose_mode = "RGB" - color = _rgb(self.info.get("background", 0)) + 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 @@ -329,21 +364,28 @@ def _rgb(color): dispose_size = (x1 - x0, y1 - y0) Image._decompression_bomb_check(dispose_size) - self.dispose = Image.core.fill( - "RGBA", dispose_size, _rgb(frame_transparency) + (0,) - ) + 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: - if frame == 0 and frame_transparency is not None: - self.info["transparency"] = frame_transparency + transparency = -1 + 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 = [ ( "gif", (x0, y0, x1, y1), self.__offset, - (bits, interlace), + (bits, interlace, transparency), ) ] @@ -353,53 +395,47 @@ def _rgb(color): elif k in self.info: 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): + temp_mode = "P" if self._frame_palette else "L" + self._prev_im = None if self.__frame == 0: if "transparency" in self.info: 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 if self._frame_palette: - self.mode = "P" self.im = Image.core.fill("P", self.size, self._frame_transparency or 0) self.im.putpalette(*self._frame_palette.getdata()) - self._frame_palette = None else: - self.mode = "L" self.im = None + self.mode = temp_mode + self._frame_palette = None super().load_prepare() def load_end(self): 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 - if self._frame_transparency is not None: - if self.mode == "P": + if self.mode == "P" and self._prev_im: + 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_transparent("LA", self._frame_transparency) + frame_im = self.im.convert("RGB") 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) self.im = self._prev_im 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) else: self.im.paste(frame_im, self.dispose_extent) diff --git a/src/decode.c b/src/decode.c index e236264cdb4..cb018a4e706 100644 --- a/src/decode.c +++ b/src/decode.c @@ -433,7 +433,8 @@ PyImaging_GifDecoderNew(PyObject *self, PyObject *args) { char *mode; int bits = 8; 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; } @@ -451,6 +452,7 @@ PyImaging_GifDecoderNew(PyObject *self, PyObject *args) { ((GIFDECODERSTATE *)decoder->state.context)->bits = bits; ((GIFDECODERSTATE *)decoder->state.context)->interlace = interlace; + ((GIFDECODERSTATE *)decoder->state.context)->transparency = transparency; return (PyObject *)decoder; } diff --git a/src/libImaging/Gif.h b/src/libImaging/Gif.h index 4029bbfe5f1..5d7e2bdaa96 100644 --- a/src/libImaging/Gif.h +++ b/src/libImaging/Gif.h @@ -30,6 +30,9 @@ typedef struct { */ int interlace; + /* The transparent palette index, or -1 for no transparency */ + int transparency; + /* PRIVATE CONTEXT (set by decoder) */ /* Interlace parameters */ diff --git a/src/libImaging/GifDecode.c b/src/libImaging/GifDecode.c index 30478e24aac..0be4771cdeb 100644 --- a/src/libImaging/GifDecode.c +++ b/src/libImaging/GifDecode.c @@ -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 some common cases and handle them separately. */ - if (i == 1) { - if (state->x < state->xsize - 1) { - /* Single pixel, not at the end of the line. */ - *out++ = p[0]; - state->x++; + /* This cannot be used if there is transparency */ + if (context->transparency == -1) { + if (i == 1) { + if (state->x < state->xsize - 1) { + /* 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; } - } 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 */ for (c = 0; c < i; c++) { - *out++ = p[c]; + if (p[c] != context->transparency) { + *out = p[c]; + } + out++; if (++state->x >= state->xsize) { NEWLINE(state, context); }