Skip to content

Commit

Permalink
Added setting to convert to RGB only at a different palette
Browse files Browse the repository at this point in the history
  • Loading branch information
radarhere committed Mar 22, 2022
1 parent 66bb2bd commit ce8c682
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 91 deletions.
65 changes: 52 additions & 13 deletions Tests/test_file_gif.py
Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 14 additions & 2 deletions docs/handbook/image-file-formats.rst
Expand Up @@ -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:

Expand Down
131 changes: 71 additions & 60 deletions src/PIL/GifImagePlugin.py
Expand Up @@ -39,6 +39,7 @@
class ModeStrategy(IntEnum):
AFTER_FIRST = 0
ALWAYS = 1
DIFFERENT_PALETTE_ONLY = 2


PALETTE_TO_RGB = ModeStrategy.AFTER_FIRST
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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),
)
]

Expand All @@ -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()
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/decode.c
Expand Up @@ -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;
}

Expand All @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions src/libImaging/Gif.h
Expand Up @@ -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 */
Expand Down

0 comments on commit ce8c682

Please sign in to comment.