From 7928e944cb73589014bfebaea15042a92bc3c68f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 20 Mar 2022 15:09:01 +1100 Subject: [PATCH 1/9] Keep subsequent L frames without transparency as L --- Tests/images/no_palette.gif | Bin 0 -> 48 bytes Tests/test_file_gif.py | 10 ++++++++- docs/handbook/image-file-formats.rst | 5 +++-- src/PIL/GifImagePlugin.py | 32 +++++++++++++++------------ 4 files changed, 30 insertions(+), 17 deletions(-) create mode 100644 Tests/images/no_palette.gif diff --git a/Tests/images/no_palette.gif b/Tests/images/no_palette.gif new file mode 100644 index 0000000000000000000000000000000000000000..0432ebcb61c30a77eb385b323a0bd6c8d8577ef3 GIT binary patch literal 48 gcmZ?wbh9u|WMp7u00JEl0cLZsFfg*PU Date: Sun, 20 Mar 2022 16:28:31 +1100 Subject: [PATCH 2/9] Added setting to convert first GIF frame to RGB --- Tests/test_file_gif.py | 12 ++++++++++++ docs/handbook/image-file-formats.rst | 7 +++++++ src/PIL/GifImagePlugin.py | 26 ++++++++++++++++++++------ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index ba277a776fa..8d804628076 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -78,6 +78,18 @@ def test_l_mode_subsequent_frames(): assert im.load()[0, 0] == (0, 255) +def test_strategy(): + with Image.open(TEST_GIF) as im: + expected = im.convert("RGB") + + GifImagePlugin.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.ALWAYS + with Image.open(TEST_GIF) as im: + assert im.mode == "RGB" + assert_image_equal(im, expected) + + GifImagePlugin.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.AFTER_FIRST + + def test_optimize(): def test_grayscale(optimize): im = Image.new("L", (1, 1), 0) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 5db1b8c7d0a..85004d8e3f3 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -110,6 +110,13 @@ images. Seeking to later frames in a ``P`` image will change the image to ``RGB`` (or ``RGBA`` if the first frame had transparency). ``L`` images will stay in ``L`` mode (or change to ``LA`` if the first frame had transparency). +If you would prefer the first ``P`` image frame to be ``RGB``, so that ``P`` +frames are always converted to ``RGB`` or ``RGBA`` mode, there is a setting +available:: + + from PIL import GifImagePlugin + GifImagePlugin.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.ALWAYS + The :py:meth:`~PIL.Image.open` method sets the following :py:attr:`~PIL.Image.Image.info` properties: diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 764eda6ee00..7f9c1540033 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -28,12 +28,21 @@ 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 ModeStrategy(IntEnum): + AFTER_FIRST = 0 + ALWAYS = 1 + + +PALETTE_TO_RGB = ModeStrategy.AFTER_FIRST + # -------------------------------------------------------------------- # Identify/read GIF files @@ -355,18 +364,22 @@ def _rgb(color): del self.info[k] if frame == 0: - self.mode = "P" if frame_palette else "L" + if frame_palette: + self.mode = "RGB" if PALETTE_TO_RGB == ModeStrategy.ALWAYS else "P" + else: + self.mode = "L" - if self.mode == "P" and not palette: + if not palette and self.global_palette: from copy import copy palette = copy(self.global_palette) self.palette = palette else: - self._frame_palette = frame_palette self._frame_transparency = frame_transparency + self._frame_palette = frame_palette def load_prepare(self): + self.mode = "P" if self._frame_palette else "L" if self.__frame == 0: if "transparency" in self.info: self.im = Image.core.fill( @@ -375,18 +388,19 @@ def load_prepare(self): else: 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._frame_palette = None super().load_prepare() def load_end(self): if self.__frame == 0: + if self.mode == "P" and PALETTE_TO_RGB == ModeStrategy.ALWAYS: + self.mode = "RGB" + self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) return if self.mode == "P": if self._frame_transparency is not None: From ce8c682748339928d8de986147fe37149dfcbb8a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 22 Mar 2022 20:28:49 +1100 Subject: [PATCH 3/9] Added setting to convert to RGB only at a different palette --- Tests/test_file_gif.py | 65 ++++++++++--- docs/handbook/image-file-formats.rst | 16 +++- src/PIL/GifImagePlugin.py | 131 +++++++++++++++------------ src/decode.c | 4 +- src/libImaging/Gif.h | 3 + src/libImaging/GifDecode.c | 36 +++++--- 6 files changed, 164 insertions(+), 91 deletions(-) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 8d804628076..df6142ec7f4 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -79,15 +79,37 @@ def test_l_mode_subsequent_frames(): def test_strategy(): - with Image.open(TEST_GIF) as im: - expected = im.convert("RGB") + with Image.open("Tests/images/chi.gif") as im: + expected_zero = im.convert("RGB") - GifImagePlugin.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.ALWAYS - with Image.open(TEST_GIF) as im: - assert im.mode == "RGB" - assert_image_equal(im, expected) + im.seek(1) + expected_one = im.convert("RGB") + + try: + GifImagePlugin.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.ALWAYS + with Image.open("Tests/images/chi.gif") as im: + assert im.mode == "RGB" + assert_image_equal(im, expected_zero) + + GifImagePlugin.PALETTE_TO_RGB = ( + GifImagePlugin.ModeStrategy.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" - GifImagePlugin.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.AFTER_FIRST + 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.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.AFTER_FIRST def test_optimize(): @@ -414,12 +436,29 @@ 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)), - ] +@pytest.mark.parametrize( + "mode_strategy, expected_colors", + ( + ( + GifImagePlugin.ModeStrategy.DIFFERENT_PALETTE_ONLY, + ( + (2, 1, 2), + (0, 1, 0), + (2, 1, 2), + ), + ), + ( + GifImagePlugin.ModeStrategy.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)), + ), + ), + ), +) +def test_transparent_dispose(mode_strategy, expected_colors): + GifImagePlugin.PALETTE_TO_RGB = mode_strategy with Image.open("Tests/images/transparent_dispose.gif") as img: for frame in range(3): img.seek(frame) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 85004d8e3f3..6dffe834e92 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -110,13 +110,25 @@ images. Seeking to later frames in a ``P`` image will change the image to ``RGB`` (or ``RGBA`` if the first frame had transparency). ``L`` images will stay in ``L`` mode (or change to ``LA`` if the first frame had transparency). -If you would prefer the first ``P`` image frame to be ``RGB``, so that ``P`` -frames are always converted to ``RGB`` or ``RGBA`` mode, there is a setting +``P`` mode images are changed to ``RGB`` because each frame of a GIF may +introduce up to 256 colors. Because ``P`` can only have up to 256 colors, the +image is converted to handle all of the colors. + +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.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.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.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.DIFFERENT_PALETTE_ONLY + The :py:meth:`~PIL.Image.open` method sets the following :py:attr:`~PIL.Image.Image.info` properties: diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 7f9c1540033..d22d2896640 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -39,6 +39,7 @@ class ModeStrategy(IntEnum): AFTER_FIRST = 0 ALWAYS = 1 + DIFFERENT_PALETTE_ONLY = 2 PALETTE_TO_RGB = ModeStrategy.AFTER_FIRST @@ -152,7 +153,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 @@ -180,33 +180,12 @@ def _seek(self, frame, update_image=True): self.tile = [] - if update_image: - if self.__frame == 1: - self.pyaccess = None - if self.mode == "P": - 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) - elif "transparency" in self.info: - self.im = self.im.convert_transparent( - "LA", self.info["transparency"] - ) - self.mode = "LA" - del self.info["transparency"] - - 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: @@ -273,7 +252,7 @@ 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 @@ -298,15 +277,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 PALETTE_TO_RGB == ModeStrategy.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 PALETTE_TO_RGB != ModeStrategy.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) + elif self.mode == "L" and "transparency" in self.info: + self.pyaccess = None + self.im = self.im.convert_transparent("LA", self.info["transparency"]) + self.mode = "LA" + del self.info["transparency"] 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 @@ -321,13 +333,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 @@ -339,21 +355,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", "LA"): + transparency = frame_transparency self.tile = [ ( "gif", (x0, y0, x1, y1), self.__offset, - (bits, interlace), + (bits, interlace, transparency), ) ] @@ -363,35 +386,22 @@ def _rgb(color): elif k in self.info: del self.info[k] - if frame == 0: - if frame_palette: - self.mode = "RGB" if PALETTE_TO_RGB == ModeStrategy.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 - self._frame_palette = frame_palette - def load_prepare(self): - self.mode = "P" if self._frame_palette else "L" + 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", "LA"): 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(*self._frame_palette.getdata()) else: self.im = None + self.mode = temp_mode self._frame_palette = None super().load_prepare() @@ -402,17 +412,18 @@ def load_end(self): self.mode = "RGB" self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) return - 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("RGB") + elif self.mode == "L" and self._frame_transparency is not None: + frame_im = self.im.convert_transparent("LA", self._frame_transparency) else: - if self._frame_transparency is not None: - frame_im = self.im.convert_transparent("LA", self._frame_transparency) - else: - frame_im = self.im + if not self._prev_im: + return + frame_im = self.im frame_im = self._crop(frame_im, self.dispose_extent) self.im = self._prev_im 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); } From c5efe60c370c3e89db17f1a40747f1d73a40e27b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 22 Mar 2022 22:07:37 +1100 Subject: [PATCH 4/9] Reverted converting L with transparency to LA after first frame --- Tests/images/no_palette_with_transparency.gif | Bin 64 -> 1604 bytes Tests/test_file_gif.py | 43 ++++++++---------- docs/handbook/image-file-formats.rst | 3 +- src/PIL/GifImagePlugin.py | 31 ++++++------- 4 files changed, 35 insertions(+), 42 deletions(-) diff --git a/Tests/images/no_palette_with_transparency.gif b/Tests/images/no_palette_with_transparency.gif index 3cd1c0c48eb5bcd8c572b1ad034955190d801e6a..031bdcfce10dc119f4479085810427cccd925671 100644 GIT binary patch literal 1604 zcmeIx!(tr>06@_fyRmKCP8!>`ZQBjnxQ%Vwwr$(CF_ZOt!t`$M`iM!0a&Q^|1Aqac zfUkgnfPjI4K|nx2K|#U5z`()5As`?iAt9llprE0lVPIfjVPWCm;J$tP1`iL9fPjFA zh=_!Qgp7=gf`WpIii(DYhK`Pofq{XEiHU`Ug^i7kgM)*Mi;IVchmVg>KtMo9NJvCP zL`+OfLPA1HN=imXMovynK|w)DNl8UTMNLgjLqkJLOG`&bM^8`Bz`(%B$jHRR#LUdh z!otGJ%F4#Z#?H>p!NI}F$;rjV#m&vl!^6YN%ge{d$Is6%ARr(pDER&RcOfAmVPRnr z5fM>QQ86(wadB}82?~1%F4>>>gw9s+WPwX#>U3x=H}Mc z*7o-H&d$#6?(W{+-v0jn!NI}d;o;HI(ed%|$;rv->FL?o+4=eT#l^+t<>l4Y)%ErD z&CSj2?d{#&-TnRj!^6Yl+9Rw+xz?b$H&L#=O^JaC=dYf3849s m@MQu32@U{)0!2Xp{AYb}`HST*mj5l5e`$O|y`dujxc>pnng=)l literal 64 ncmZ?wbhEHbWMp7u00PCIEI|4{gARxT7UN)HU}RyzEny7+38e<8 diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index df6142ec7f4..54cc4524990 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -59,23 +59,15 @@ def test_invalid_file(): GifImagePlugin.GifImageFile(invalid_file) -def test_l_mode_subsequent_frames(): - with Image.open("Tests/images/no_palette.gif") as im: - assert im.mode == "L" - assert im.load()[0, 0] == 0 - - im.seek(1) - assert im.mode == "L" - assert im.load()[0, 0] == 0 - +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(): @@ -440,31 +432,34 @@ def test_dispose_background_transparency(): "mode_strategy, expected_colors", ( ( - GifImagePlugin.ModeStrategy.DIFFERENT_PALETTE_ONLY, + GifImagePlugin.ModeStrategy.AFTER_FIRST, ( (2, 1, 2), - (0, 1, 0), - (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.ModeStrategy.AFTER_FIRST, + GifImagePlugin.ModeStrategy.DIFFERENT_PALETTE_ONLY, ( (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)), + (0, 1, 0), + (2, 1, 2), ), ), ), ) def test_transparent_dispose(mode_strategy, expected_colors): GifImagePlugin.PALETTE_TO_RGB = mode_strategy - 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] + 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.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.AFTER_FIRST def test_dispose_previous(): diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 6dffe834e92..6309562501e 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -107,8 +107,7 @@ are used or GIF89a is already in use. GIF files are initially read as grayscale (``L``) or palette mode (``P``) images. Seeking to later frames in a ``P`` image will change the image to -``RGB`` (or ``RGBA`` if the first frame had transparency). ``L`` images will -stay in ``L`` mode (or change to ``LA`` if 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 introduce up to 256 colors. Because ``P`` can only have up to 256 colors, the diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index d22d2896640..1d59edf091a 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -71,6 +71,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 @@ -89,11 +95,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() @@ -259,7 +263,9 @@ def _seek(self, frame, update_image=True): 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] @@ -305,11 +311,6 @@ def _seek(self, frame, update_image=True): else: self.mode = "RGB" self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) - elif self.mode == "L" and "transparency" in self.info: - self.pyaccess = None - self.im = self.im.convert_transparent("LA", self.info["transparency"]) - self.mode = "LA" - del self.info["transparency"] def _rgb(color): if self._frame_palette: @@ -369,7 +370,7 @@ def _rgb(color): if frame_transparency is not None: if frame == 0: self.info["transparency"] = frame_transparency - elif self.mode not in ("RGB", "RGBA", "LA"): + elif self.mode not in ("RGB", "RGBA"): transparency = frame_transparency self.tile = [ ( @@ -394,7 +395,7 @@ def load_prepare(self): self.im = Image.core.fill( temp_mode, self.size, self.info["transparency"] ) - elif self.mode in ("RGB", "RGBA", "LA"): + 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) @@ -418,8 +419,6 @@ def load_end(self): frame_im = self.im.convert("RGBA") else: frame_im = self.im.convert("RGB") - elif self.mode == "L" and self._frame_transparency is not None: - frame_im = self.im.convert_transparent("LA", self._frame_transparency) else: if not self._prev_im: return @@ -428,7 +427,7 @@ def load_end(self): 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) From 33022eef1677bcc24dc1a03628ca10e9af9302a0 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 28 Mar 2022 22:02:26 +1100 Subject: [PATCH 5/9] Added versionadded --- src/PIL/GifImagePlugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 1d59edf091a..1be47c7b745 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -37,11 +37,14 @@ class ModeStrategy(IntEnum): + """.. versionadded:: 9.1.0""" + AFTER_FIRST = 0 ALWAYS = 1 DIFFERENT_PALETTE_ONLY = 2 +#: .. versionadded:: 9.1.0 PALETTE_TO_RGB = ModeStrategy.AFTER_FIRST # -------------------------------------------------------------------- From e22a4395d36e4048fda3cf914c223e530039f5df Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 29 Mar 2022 21:26:29 +1100 Subject: [PATCH 6/9] Renamed setting --- Tests/test_file_gif.py | 20 ++++++++++---------- docs/handbook/image-file-formats.rst | 4 ++-- src/PIL/GifImagePlugin.py | 21 +++++++++++++-------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 54cc4524990..a7e574ba5b2 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -78,13 +78,13 @@ def test_strategy(): expected_one = im.convert("RGB") try: - GifImagePlugin.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.ALWAYS + 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.PALETTE_TO_RGB = ( - GifImagePlugin.ModeStrategy.DIFFERENT_PALETTE_ONLY + 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: @@ -101,7 +101,7 @@ def test_strategy(): im.seek(1) assert im.mode == "RGB" finally: - GifImagePlugin.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.AFTER_FIRST + GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST def test_optimize(): @@ -429,10 +429,10 @@ def test_dispose_background_transparency(): @pytest.mark.parametrize( - "mode_strategy, expected_colors", + "loading_strategy, expected_colors", ( ( - GifImagePlugin.ModeStrategy.AFTER_FIRST, + GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST, ( (2, 1, 2), ((0, 255, 24, 255), (0, 0, 255, 255), (0, 255, 24, 255)), @@ -440,7 +440,7 @@ def test_dispose_background_transparency(): ), ), ( - GifImagePlugin.ModeStrategy.DIFFERENT_PALETTE_ONLY, + GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY, ( (2, 1, 2), (0, 1, 0), @@ -449,8 +449,8 @@ def test_dispose_background_transparency(): ), ), ) -def test_transparent_dispose(mode_strategy, expected_colors): - GifImagePlugin.PALETTE_TO_RGB = mode_strategy +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): @@ -459,7 +459,7 @@ def test_transparent_dispose(mode_strategy, expected_colors): color = img.getpixel((x, 0)) assert color == expected_colors[frame][x] finally: - GifImagePlugin.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.AFTER_FIRST + 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 6309562501e..295d6280965 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -118,7 +118,7 @@ every ``P`` frame is converted to ``RGB`` or ``RGBA`` mode, there is a setting available:: from PIL import GifImagePlugin - GifImagePlugin.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.ALWAYS + 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 @@ -126,7 +126,7 @@ prefer the frames to be kept as ``P`` in that case, there is also a setting available:: from PIL import GifImagePlugin - GifImagePlugin.PALETTE_TO_RGB = GifImagePlugin.ModeStrategy.DIFFERENT_PALETTE_ONLY + GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY The :py:meth:`~PIL.Image.open` method sets the following :py:attr:`~PIL.Image.Image.info` properties: diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 1be47c7b745..cee4be0689f 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -36,16 +36,16 @@ from ._binary import o16le as o16 -class ModeStrategy(IntEnum): +class LoadingStrategy(IntEnum): """.. versionadded:: 9.1.0""" - AFTER_FIRST = 0 - ALWAYS = 1 - DIFFERENT_PALETTE_ONLY = 2 + RGB_AFTER_FIRST = 0 + RGB_AFTER_DIFFERENT_PALETTE_ONLY = 1 + RGB_ALWAYS = 2 #: .. versionadded:: 9.1.0 -PALETTE_TO_RGB = ModeStrategy.AFTER_FIRST +LOADING_STRATEGY = LoadingStrategy.RGB_AFTER_FIRST # -------------------------------------------------------------------- # Identify/read GIF files @@ -292,7 +292,9 @@ def _seek(self, frame, update_image=True): self._frame_palette = palette or self.global_palette if frame == 0: if self._frame_palette: - self.mode = "RGB" if PALETTE_TO_RGB == ModeStrategy.ALWAYS else "P" + self.mode = ( + "RGB" if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS else "P" + ) else: self.mode = "L" @@ -304,7 +306,10 @@ def _seek(self, frame, update_image=True): else: self._frame_transparency = frame_transparency if self.mode == "P": - if PALETTE_TO_RGB != ModeStrategy.DIFFERENT_PALETTE_ONLY or palette: + 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) @@ -412,7 +417,7 @@ def load_prepare(self): def load_end(self): if self.__frame == 0: - if self.mode == "P" and PALETTE_TO_RGB == ModeStrategy.ALWAYS: + if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: self.mode = "RGB" self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) return From 5fb1ff6369d22ab869839a45d6f37dea743a8fff Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 29 Mar 2022 22:05:21 +1100 Subject: [PATCH 7/9] Further explain GIF palettes combining --- docs/handbook/image-file-formats.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 295d6280965..62f4d101d2f 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -109,9 +109,10 @@ GIF files are initially read as grayscale (``L``) or palette mode (``P``) 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 -introduce up to 256 colors. Because ``P`` can only have up to 256 colors, the -image is converted to handle all of the colors. +``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 From a7d36ef1f18a1ac4c0e8c8e778e0ad58363529e4 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 29 Mar 2022 22:11:42 +1100 Subject: [PATCH 8/9] Added release notes --- docs/releasenotes/9.1.0.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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 ============= From 24054d8fccbdbf50f87fb50655585b64cd7d5ab6 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 30 Mar 2022 07:29:23 +1100 Subject: [PATCH 9/9] Document how to restore original setting --- docs/handbook/image-file-formats.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 62f4d101d2f..c8568719cc4 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -129,6 +129,12 @@ 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: