Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Convert subsequent GIF frames to RGB or RGBA #5857

Merged
merged 4 commits into from Dec 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Binary file removed Tests/images/different_transparency_merged.gif
Binary file not shown.
Binary file added Tests/images/different_transparency_merged.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Tests/images/dispose_bgnd_rgba.gif
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed Tests/images/dispose_none_load_end_second.gif
Binary file not shown.
Binary file added Tests/images/dispose_none_load_end_second.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed Tests/images/dispose_prev_first_frame_seeked.gif
Binary file not shown.
Binary file added Tests/images/dispose_prev_first_frame_seeked.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed Tests/images/missing_background_first_frame.gif
Binary file not shown.
Binary file added Tests/images/missing_background_first_frame.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
46 changes: 38 additions & 8 deletions Tests/test_file_gif.py
Expand Up @@ -163,6 +163,32 @@ def test_roundtrip_save_all(tmp_path):
assert reread.n_frames == 5


@pytest.mark.parametrize(
"path, mode",
(
("Tests/images/dispose_bgnd.gif", "RGB"),
# Hexeditted copy of dispose_bgnd to add transparency
("Tests/images/dispose_bgnd_rgba.gif", "RGBA"),
),
)
def test_loading_multiple_palettes(path, mode):
with Image.open(path) as im:
assert im.mode == "P"
first_frame_colors = im.palette.colors.keys()
original_color = im.convert("RGB").load()[0, 0]

im.seek(1)
assert im.mode == mode
if mode == "RGBA":
im = im.convert("RGB")

# Check a color only from the old palette
assert im.load()[0, 0] == original_color

# Check a color from the new palette
assert im.load()[24, 24] not in first_frame_colors


def test_headers_saving_for_animated_gifs(tmp_path):
important_headers = ["background", "version", "duration", "loop"]
# Multiframe image
Expand Down Expand Up @@ -324,7 +350,7 @@ def test_dispose_none_load_end():
with Image.open("Tests/images/dispose_none_load_end.gif") as img:
img.seek(1)

assert_image_equal_tofile(img, "Tests/images/dispose_none_load_end_second.gif")
assert_image_equal_tofile(img, "Tests/images/dispose_none_load_end_second.png")


def test_dispose_background():
Expand All @@ -340,12 +366,16 @@ def test_dispose_background():
def test_dispose_background_transparency():
with Image.open("Tests/images/dispose_bgnd_transparency.gif") as img:
img.seek(2)
px = img.convert("RGBA").load()
px = img.load()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is now already an RGBA image.

assert px[35, 30][3] == 0


def test_transparent_dispose():
expected_colors = [(2, 1, 2), (0, 1, 0), (2, 1, 2)]
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)
Expand All @@ -368,7 +398,7 @@ def test_dispose_previous_first_frame():
with Image.open("Tests/images/dispose_prev_first_frame.gif") as im:
im.seek(1)
assert_image_equal_tofile(
im, "Tests/images/dispose_prev_first_frame_seeked.gif"
im, "Tests/images/dispose_prev_first_frame_seeked.png"
)


Expand Down Expand Up @@ -508,7 +538,7 @@ def test_dispose2_background(tmp_path):

with Image.open(out) as im:
im.seek(1)
assert im.getpixel((0, 0)) == 0
assert im.getpixel((0, 0)) == (255, 0, 0)


def test_transparency_in_second_frame():
Expand All @@ -517,9 +547,9 @@ def test_transparency_in_second_frame():

# Seek to the second frame
im.seek(im.tell() + 1)
assert im.info["transparency"] == 0
assert "transparency" not in im.info

assert_image_equal_tofile(im, "Tests/images/different_transparency_merged.gif")
assert_image_equal_tofile(im, "Tests/images/different_transparency_merged.png")


def test_no_transparency_in_second_frame():
Expand Down Expand Up @@ -926,4 +956,4 @@ def test_missing_background():
# but the disposal method is "Restore to background color"
with Image.open("Tests/images/missing_background.gif") as im:
im.seek(1)
assert_image_equal_tofile(im, "Tests/images/missing_background_first_frame.gif")
assert_image_equal_tofile(im, "Tests/images/missing_background_first_frame.png")
5 changes: 3 additions & 2 deletions docs/handbook/image-file-formats.rst
Expand Up @@ -91,8 +91,9 @@ Pillow reads GIF87a and GIF89a versions of the GIF file format. The library
writes run-length encoded files in GIF87a by default, unless GIF89a features
are used or GIF89a is already in use.

Note that GIF files are always read as grayscale (``L``)
or palette mode (``P``) images.
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.

The :py:meth:`~PIL.Image.open` method sets the following
:py:attr:`~PIL.Image.Image.info` properties:
Expand Down
99 changes: 77 additions & 22 deletions src/PIL/GifImagePlugin.py
Expand Up @@ -124,8 +124,7 @@ def seek(self, frame):
if not self._seek_check(frame):
return
if frame < self.__frame:
if frame != 0:
self.im = None
self.im = None
self._seek(0)

last_frame = self.__frame
Expand Down Expand Up @@ -165,12 +164,21 @@ def _seek(self, frame):
pass
self.__offset = 0

if self.__frame == 1:
self.pyaccess = None
if "transparency" in self.info:
self.mode = "RGBA"
self.im.putpalettealpha(self.info["transparency"], 0)
self.im = self.im.convert("RGBA", Image.FLOYDSTEINBERG)

del self.info["transparency"]
else:
self.mode = "RGB"
self.im = self.im.convert("RGB", Image.FLOYDSTEINBERG)
if self.dispose:
self.im.paste(self.dispose, self.dispose_extent)

from copy import copy

self.palette = copy(self.global_palette)
palette = None

info = {}
frame_transparency = None
Expand Down Expand Up @@ -246,7 +254,7 @@ def _seek(self, frame):

if flags & 128:
bits = (flags & 7) + 1
self.palette = ImagePalette.raw("RGB", self.fp.read(3 << bits))
palette = ImagePalette.raw("RGB", self.fp.read(3 << bits))

# image data
bits = self.fp.read(1)[0]
Expand All @@ -257,6 +265,15 @@ def _seek(self, frame):
pass
# raise OSError, "illegal GIF tag `%x`" % s[0]

frame_palette = palette or self.global_palette

def _rgb(color):
if frame_palette:
color = tuple(frame_palette.palette[color * 3 : color * 3 + 3])
else:
color = (color, color, color)
return color

try:
if self.disposal_method < 2:
# do not dispose or none specified
Expand All @@ -272,9 +289,13 @@ def _seek(self, frame):

# by convention, attempt to use transparency first
color = self.info.get("transparency", frame_transparency)
if color is None:
color = self.info.get("background", 0)
self.dispose = Image.core.fill("P", dispose_size, color)
if color is not None:
dispose_mode = "RGBA"
color = _rgb(color) + (0,)
else:
dispose_mode = "RGB"
color = _rgb(self.info.get("background", 0))
self.dispose = Image.core.fill(dispose_mode, dispose_size, color)
else:
# replace with previous contents
if self.im:
Expand All @@ -286,24 +307,20 @@ def _seek(self, frame):

Image._decompression_bomb_check(dispose_size)
self.dispose = Image.core.fill(
"P", dispose_size, frame_transparency
"RGBA", dispose_size, _rgb(frame_transparency) + (0,)
)
except AttributeError:
pass

if interlace is not None:
transparency = -1
if frame_transparency is not None:
if frame == 0:
self.info["transparency"] = frame_transparency
else:
transparency = frame_transparency
if frame == 0 and frame_transparency is not None:
self.info["transparency"] = frame_transparency
self.tile = [
(
"gif",
(x0, y0, x1, y1),
self.__offset,
(bits, interlace, transparency),
(bits, interlace),
)
]
else:
Expand All @@ -316,16 +333,54 @@ def _seek(self, frame):
elif k in self.info:
del self.info[k]

self.mode = "L"
if self.palette:
self.mode = "P"
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):
if not self.im and "transparency" in self.info:
self.im = Image.core.fill(self.mode, self.size, self.info["transparency"])
if self.__frame == 0:
if "transparency" in self.info:
self.im = Image.core.fill(
self.mode, self.size, self.info["transparency"]
)
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

super().load_prepare()

def load_end(self):
if self.__frame == 0:
return
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")
frame_im = self._crop(frame_im, self.dispose_extent)

self.im = self._prev_im
self.mode = self.im.mode
if frame_im.mode == "RGBA":
self.im.paste(frame_im, self.dispose_extent, frame_im)
else:
self.im.paste(frame_im, self.dispose_extent)

def tell(self):
return self.__frame

Expand Down
2 changes: 1 addition & 1 deletion src/PIL/PdfImagePlugin.py
Expand Up @@ -135,7 +135,7 @@ def _save(im, fp, filename, save_all=False):
procset = "ImageB" # grayscale
elif im.mode == "P":
filter = "ASCIIHexDecode"
palette = im.im.getpalette("RGB")
palette = im.getpalette()
colorspace = [
PdfParser.PdfName("Indexed"),
PdfParser.PdfName("DeviceRGB"),
Expand Down
4 changes: 1 addition & 3 deletions src/decode.c
Expand Up @@ -433,8 +433,7 @@ PyImaging_GifDecoderNew(PyObject *self, PyObject *args) {
char *mode;
int bits = 8;
int interlace = 0;
int transparency = -1;
if (!PyArg_ParseTuple(args, "s|iii", &mode, &bits, &interlace, &transparency)) {
if (!PyArg_ParseTuple(args, "s|ii", &mode, &bits, &interlace)) {
return NULL;
}

Expand All @@ -452,7 +451,6 @@ 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;
}
Expand Down
3 changes: 0 additions & 3 deletions src/libImaging/Gif.h
Expand Up @@ -30,9 +30,6 @@ typedef struct {
*/
int interlace;

/* The transparent palette index, or -1 for no transparency. */
int transparency;

/* PRIVATE CONTEXT (set by decoder) */

/* Interlace parameters */
Expand Down
36 changes: 15 additions & 21 deletions src/libImaging/GifDecode.c
Expand Up @@ -248,33 +248,27 @@ 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 we have transparency, we need to use the regular loop. */
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);
}
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;
}

/* No shortcut, copy pixel by pixel */
for (c = 0; c < i; c++) {
if (p[c] != context->transparency) {
*out = p[c];
}
out++;
*out++ = p[c];
if (++state->x >= state->xsize) {
NEWLINE(state, context);
}
Expand Down