From ce8c682748339928d8de986147fe37149dfcbb8a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 22 Mar 2022 20:28:49 +1100 Subject: [PATCH] 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); }