Merge pull request #5552 from radarhere/palette

This commit is contained in:
Hugo van Kemenade 2021-06-28 19:01:54 +03:00 committed by GitHub
commit 50302231ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 169 additions and 75 deletions

View File

@ -249,8 +249,8 @@ def test_apng_mode():
assert im.mode == "P" assert im.mode == "P"
im.seek(im.n_frames - 1) im.seek(im.n_frames - 1)
im = im.convert("RGBA") im = im.convert("RGBA")
assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((0, 0)) == (255, 0, 0, 0)
assert im.getpixel((64, 32)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (255, 0, 0, 0)
with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im: with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im:
assert im.mode == "P" assert im.mode == "P"

View File

@ -766,10 +766,10 @@ def test_rgb_transparency(tmp_path):
# Single frame # Single frame
im = Image.new("RGB", (1, 1)) im = Image.new("RGB", (1, 1))
im.info["transparency"] = (255, 0, 0) im.info["transparency"] = (255, 0, 0)
pytest.warns(UserWarning, im.save, out) im.save(out)
with Image.open(out) as reloaded: with Image.open(out) as reloaded:
assert "transparency" not in reloaded.info assert "transparency" in reloaded.info
# Multiple frames # Multiple frames
im = Image.new("RGB", (1, 1)) im = Image.new("RGB", (1, 1))

View File

@ -582,6 +582,10 @@ class TestImage:
assert ext_individual == ext_multiple assert ext_individual == ext_multiple
def test_remap_palette(self): def test_remap_palette(self):
# Test identity transform
with Image.open("Tests/images/hopper.gif") as im:
assert_image_equal(im, im.remap_palette(list(range(256))))
# Test illegal image mode # Test illegal image mode
with hopper() as im: with hopper() as im:
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -606,7 +610,7 @@ class TestImage:
else: else:
assert new_im.palette is None assert new_im.palette is None
_make_new(im, im_p, im_p.palette) _make_new(im, im_p, ImagePalette.ImagePalette(list(range(256)) * 3))
_make_new(im_p, im, None) _make_new(im_p, im, None)
_make_new(im, blank_p, ImagePalette.ImagePalette()) _make_new(im, blank_p, ImagePalette.ImagePalette())
_make_new(im, blank_pa, ImagePalette.ImagePalette()) _make_new(im, blank_pa, ImagePalette.ImagePalette())

View File

@ -93,7 +93,7 @@ def test_trns_p(tmp_path):
im_l.save(f) im_l.save(f)
im_rgb = im.convert("RGB") im_rgb = im.convert("RGB")
assert im_rgb.info["transparency"] == (0, 0, 0) # undone assert im_rgb.info["transparency"] == (0, 1, 2) # undone
im_rgb.save(f) im_rgb.save(f)
@ -128,8 +128,8 @@ def test_trns_l(tmp_path):
assert "transparency" in im_p.info assert "transparency" in im_p.info
im_p.save(f) im_p.save(f)
im_p = pytest.warns(UserWarning, im.convert, "P", palette=Image.ADAPTIVE) im_p = im.convert("P", palette=Image.ADAPTIVE)
assert "transparency" not in im_p.info assert "transparency" in im_p.info
im_p.save(f) im_p.save(f)
@ -155,13 +155,19 @@ def test_trns_RGB(tmp_path):
assert "transparency" not in im_p.info assert "transparency" not in im_p.info
im_p.save(f) im_p.save(f)
im = Image.new("RGB", (1, 1))
im.info["transparency"] = im.getpixel((0, 0))
im_p = im.convert("P", palette=Image.ADAPTIVE)
assert im_p.info["transparency"] == im_p.getpixel((0, 0))
im_p.save(f)
def test_gif_with_rgba_palette_to_p(): def test_gif_with_rgba_palette_to_p():
# See https://github.com/python-pillow/Pillow/issues/2433 # See https://github.com/python-pillow/Pillow/issues/2433
with Image.open("Tests/images/hopper.gif") as im: with Image.open("Tests/images/hopper.gif") as im:
im.info["transparency"] = 255 im.info["transparency"] = 255
im.load() im.load()
assert im.palette.mode == "RGBA" assert im.palette.mode == "RGB"
im_p = im.convert("P") im_p = im.convert("P")
# Should not raise ValueError: unrecognized raw mode # Should not raise ValueError: unrecognized raw mode

View File

@ -157,9 +157,16 @@ def test_scale():
def test_expand_palette(): def test_expand_palette():
im = Image.open("Tests/images/hopper.gif") im = Image.open("Tests/images/p_16.tga")
im_expanded = ImageOps.expand(im) im_expanded = ImageOps.expand(im, 10, (255, 0, 0))
assert_image_equal(im_expanded.convert("RGB"), im.convert("RGB"))
px = im_expanded.convert("RGB").load()
assert px[0, 0] == (255, 0, 0)
im_cropped = im_expanded.crop(
(10, 10, im_expanded.width - 10, im_expanded.height - 10)
)
assert_image_equal(im_cropped, im)
def test_colorize_2color(): def test_colorize_2color():

View File

@ -2,27 +2,47 @@ import pytest
from PIL import Image, ImagePalette from PIL import Image, ImagePalette
from .helper import assert_image_equal_tofile from .helper import assert_image_equal, assert_image_equal_tofile
def test_sanity(): def test_sanity():
ImagePalette.ImagePalette("RGB", list(range(256)) * 3) palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3)
assert len(palette.colors) == 256
with pytest.raises(ValueError): with pytest.raises(ValueError):
ImagePalette.ImagePalette("RGB", list(range(256)) * 2) ImagePalette.ImagePalette("RGB", list(range(256)) * 3, 10)
def test_reload():
im = Image.open("Tests/images/hopper.gif")
original = im.copy()
im.palette.dirty = 1
assert_image_equal(im.convert("RGB"), original.convert("RGB"))
def test_getcolor(): def test_getcolor():
palette = ImagePalette.ImagePalette() palette = ImagePalette.ImagePalette()
assert len(palette.palette) == 0
assert len(palette.colors) == 0
test_map = {} test_map = {}
for i in range(256): for i in range(256):
test_map[palette.getcolor((i, i, i))] = i test_map[palette.getcolor((i, i, i))] = i
assert len(test_map) == 256 assert len(test_map) == 256
# Colors can be converted between RGB and RGBA
rgba_palette = ImagePalette.ImagePalette("RGBA")
assert rgba_palette.getcolor((0, 0, 0)) == rgba_palette.getcolor((0, 0, 0, 255))
assert palette.getcolor((0, 0, 0)) == palette.getcolor((0, 0, 0, 255))
# An error is raised when the palette is full
with pytest.raises(ValueError): with pytest.raises(ValueError):
palette.getcolor((1, 2, 3)) palette.getcolor((1, 2, 3))
# But not if the image is not using one of the palette entries
palette.getcolor((1, 2, 3), image=Image.new("P", (1, 1)))
# Test unknown color specifier # Test unknown color specifier
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -116,7 +136,7 @@ def test_getdata():
mode, data_out = palette.getdata() mode, data_out = palette.getdata()
# Assert # Assert
assert mode == "RGB;L" assert mode == "RGB"
def test_rawmode_getdata(): def test_rawmode_getdata():

View File

@ -472,10 +472,10 @@ def _write_multiple_frames(im, fp, palette):
previous = im_frames[-1] previous = im_frames[-1]
if encoderinfo.get("disposal") == 2: if encoderinfo.get("disposal") == 2:
if background_im is None: if background_im is None:
background = _get_background( color = im.encoderinfo.get(
im, "transparency", im.info.get("transparency", (0, 0, 0))
im.encoderinfo.get("background", im.info.get("background")),
) )
background = _get_background(im_frame, color)
background_im = Image.new("P", im_frame.size, background) background_im = Image.new("P", im_frame.size, background)
background_im.putpalette(im_frames[0]["im"].palette) background_im.putpalette(im_frames[0]["im"].palette)
base_im = background_im base_im = background_im
@ -771,7 +771,15 @@ def _get_background(im, infoBackground):
# WebPImagePlugin stores an RGBA value in info["background"] # WebPImagePlugin stores an RGBA value in info["background"]
# So it must be converted to the same format as GifImagePlugin's # So it must be converted to the same format as GifImagePlugin's
# info["background"] - a global color table index # info["background"] - a global color table index
background = im.palette.getcolor(background) try:
background = im.palette.getcolor(background, im)
except ValueError as e:
if str(e) == "cannot allocate more than 256 colors":
# If all 256 colors are in use,
# then there is no need for the background color
return 0
else:
raise
return background return background

View File

@ -833,7 +833,7 @@ class Image:
palette_length = self.im.putpalette(mode, arr) palette_length = self.im.putpalette(mode, arr)
self.palette.dirty = 0 self.palette.dirty = 0
self.palette.rawmode = None self.palette.rawmode = None
if "transparency" in self.info: if "transparency" in self.info and mode in ("RGBA", "LA", "PA"):
if isinstance(self.info["transparency"], int): if isinstance(self.info["transparency"], int):
self.im.putpalettealpha(self.info["transparency"], 0) self.im.putpalettealpha(self.info["transparency"], 0)
else: else:
@ -977,12 +977,19 @@ class Image:
if self.mode == "P": if self.mode == "P":
trns_im.putpalette(self.palette) trns_im.putpalette(self.palette)
if isinstance(t, tuple): if isinstance(t, tuple):
err = "Couldn't allocate a palette color for transparency"
try: try:
t = trns_im.palette.getcolor(t) t = trns_im.palette.getcolor(t, self)
except Exception as e: except ValueError as e:
raise ValueError( if str(e) == "cannot allocate more than 256 colors":
"Couldn't allocate a palette color for transparency" # If all 256 colors are in use,
) from e # then there is no need for transparency
t = None
else:
raise ValueError(err) from e
if t is None:
trns = None
else:
trns_im.putpixel((0, 0), t) trns_im.putpixel((0, 0), t)
if mode in ("L", "RGB"): if mode in ("L", "RGB"):
@ -1009,14 +1016,14 @@ class Image:
new = self._new(im) new = self._new(im)
from . import ImagePalette from . import ImagePalette
new.palette = ImagePalette.raw("RGB", new.im.getpalette("RGB")) new.palette = ImagePalette.ImagePalette("RGB", new.im.getpalette("RGB"))
if delete_trns: if delete_trns:
# This could possibly happen if we requantize to fewer colors. # This could possibly happen if we requantize to fewer colors.
# The transparency would be totally off in that case. # The transparency would be totally off in that case.
del new.info["transparency"] del new.info["transparency"]
if trns is not None: if trns is not None:
try: try:
new.info["transparency"] = new.palette.getcolor(trns) new.info["transparency"] = new.palette.getcolor(trns, new)
except Exception: except Exception:
# if we can't make a transparent color, don't leave the old # if we can't make a transparent color, don't leave the old
# transparency hanging around to mess us up. # transparency hanging around to mess us up.
@ -1039,16 +1046,25 @@ class Image:
raise ValueError("illegal conversion") from e raise ValueError("illegal conversion") from e
new_im = self._new(im) new_im = self._new(im)
if mode == "P" and palette != ADAPTIVE:
from . import ImagePalette
new_im.palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3)
if delete_trns: if delete_trns:
# crash fail if we leave a bytes transparency in an rgb/l mode. # crash fail if we leave a bytes transparency in an rgb/l mode.
del new_im.info["transparency"] del new_im.info["transparency"]
if trns is not None: if trns is not None:
if new_im.mode == "P": if new_im.mode == "P":
try: try:
new_im.info["transparency"] = new_im.palette.getcolor(trns) new_im.info["transparency"] = new_im.palette.getcolor(trns, new_im)
except Exception: except ValueError as e:
del new_im.info["transparency"] del new_im.info["transparency"]
warnings.warn("Couldn't allocate palette entry for transparency") if str(e) != "cannot allocate more than 256 colors":
# If all 256 colors are in use,
# then there is no need for transparency
warnings.warn(
"Couldn't allocate palette entry for transparency"
)
else: else:
new_im.info["transparency"] = trns new_im.info["transparency"] = trns
return new_im return new_im
@ -1732,7 +1748,7 @@ class Image:
Attaches a palette to this image. The image must be a "P", "PA", "L" Attaches a palette to this image. The image must be a "P", "PA", "L"
or "LA" image. or "LA" image.
The palette sequence must contain either 768 integer values, or 1024 The palette sequence must contain at most 768 integer values, or 1024
integer values if alpha is included. Each group of values represents integer values if alpha is included. Each group of values represents
the red, green, blue (and alpha if included) values for the the red, green, blue (and alpha if included) values for the
corresponding pixel index. Instead of an integer sequence, you can use corresponding pixel index. Instead of an integer sequence, you can use
@ -1745,7 +1761,6 @@ class Image:
if self.mode not in ("L", "LA", "P", "PA"): if self.mode not in ("L", "LA", "P", "PA"):
raise ValueError("illegal image mode") raise ValueError("illegal image mode")
self.load()
if isinstance(data, ImagePalette.ImagePalette): if isinstance(data, ImagePalette.ImagePalette):
palette = ImagePalette.raw(data.rawmode, data.palette) palette = ImagePalette.raw(data.rawmode, data.palette)
else: else:
@ -1792,7 +1807,7 @@ class Image:
and len(value) in [3, 4] and len(value) in [3, 4]
): ):
# RGB or RGBA value for a P image # RGB or RGBA value for a P image
value = self.palette.getcolor(value) value = self.palette.getcolor(value, self)
return self.im.putpixel(xy, value) return self.im.putpixel(xy, value)
def remap_palette(self, dest_map, source_palette=None): def remap_palette(self, dest_map, source_palette=None):
@ -1813,6 +1828,7 @@ class Image:
if source_palette is None: if source_palette is None:
if self.mode == "P": if self.mode == "P":
self.load()
real_source_palette = self.im.getpalette("RGB")[:768] real_source_palette = self.im.getpalette("RGB")[:768]
else: # L-mode else: # L-mode
real_source_palette = bytearray(i // 3 for i in range(768)) real_source_palette = bytearray(i // 3 for i in range(768))
@ -1850,23 +1866,19 @@ class Image:
m_im = self.copy() m_im = self.copy()
m_im.mode = "P" m_im.mode = "P"
m_im.palette = ImagePalette.ImagePalette( m_im.palette = ImagePalette.ImagePalette("RGB", palette=mapping_palette * 3)
"RGB", palette=mapping_palette * 3, size=768
)
# possibly set palette dirty, then # possibly set palette dirty, then
# m_im.putpalette(mapping_palette, 'L') # converts to 'P' # m_im.putpalette(mapping_palette, 'L') # converts to 'P'
# or just force it. # or just force it.
# UNDONE -- this is part of the general issue with palettes # UNDONE -- this is part of the general issue with palettes
m_im.im.putpalette(*m_im.palette.getdata()) m_im.im.putpalette("RGB;L", m_im.palette.tobytes())
m_im = m_im.convert("L") m_im = m_im.convert("L")
# Internally, we require 768 bytes for a palette. # Internally, we require 768 bytes for a palette.
new_palette_bytes = palette_bytes + (768 - len(palette_bytes)) * b"\x00" new_palette_bytes = palette_bytes + (768 - len(palette_bytes)) * b"\x00"
m_im.putpalette(new_palette_bytes) m_im.putpalette(new_palette_bytes)
m_im.palette = ImagePalette.ImagePalette( m_im.palette = ImagePalette.ImagePalette("RGB", palette=palette_bytes)
"RGB", palette=palette_bytes, size=len(palette_bytes)
)
return m_im return m_im

View File

@ -70,6 +70,7 @@ class ImageDraw:
self.palette = im.palette self.palette = im.palette
else: else:
self.palette = None self.palette = None
self._image = im
self.im = im.im self.im = im.im
self.draw = Image.core.draw(self.im, blend) self.draw = Image.core.draw(self.im, blend)
self.mode = mode self.mode = mode
@ -108,13 +109,13 @@ class ImageDraw:
if isinstance(ink, str): if isinstance(ink, str):
ink = ImageColor.getcolor(ink, self.mode) ink = ImageColor.getcolor(ink, self.mode)
if self.palette and not isinstance(ink, numbers.Number): if self.palette and not isinstance(ink, numbers.Number):
ink = self.palette.getcolor(ink) ink = self.palette.getcolor(ink, self._image)
ink = self.draw.draw_ink(ink) ink = self.draw.draw_ink(ink)
if fill is not None: if fill is not None:
if isinstance(fill, str): if isinstance(fill, str):
fill = ImageColor.getcolor(fill, self.mode) fill = ImageColor.getcolor(fill, self.mode)
if self.palette and not isinstance(fill, numbers.Number): if self.palette and not isinstance(fill, numbers.Number):
fill = self.palette.getcolor(fill) fill = self.palette.getcolor(fill, self._image)
fill = self.draw.draw_ink(fill) fill = self.draw.draw_ink(fill)
return ink, fill return ink, fill

View File

@ -20,7 +20,7 @@
import functools import functools
import operator import operator
from . import Image from . import Image, ImageDraw
# #
# helpers # helpers
@ -392,10 +392,17 @@ def expand(image, border=0, fill=0):
left, top, right, bottom = _border(border) left, top, right, bottom = _border(border)
width = left + image.size[0] + right width = left + image.size[0] + right
height = top + image.size[1] + bottom height = top + image.size[1] + bottom
out = Image.new(image.mode, (width, height), _color(fill, image.mode)) color = _color(fill, image.mode)
if image.mode == "P" and image.palette: if image.mode == "P" and image.palette:
out = Image.new(image.mode, (width, height))
out.putpalette(image.palette) out.putpalette(image.palette)
out.paste(image, (left, top)) out.paste(image, (left, top))
draw = ImageDraw.Draw(out)
draw.rectangle((0, 0, width, height), outline=color, width=border)
else:
out = Image.new(image.mode, (width, height), color)
out.paste(image, (left, top))
return out return out

View File

@ -39,14 +39,27 @@ class ImagePalette:
def __init__(self, mode="RGB", palette=None, size=0): def __init__(self, mode="RGB", palette=None, size=0):
self.mode = mode self.mode = mode
self.rawmode = None # if set, palette contains raw data self.rawmode = None # if set, palette contains raw data
self.palette = palette or bytearray(range(256)) * len(self.mode) self.palette = palette or bytearray()
self.colors = {}
self.dirty = None self.dirty = None
if (size == 0 and len(self.mode) * 256 != len(self.palette)) or ( if size != 0 and size != len(self.palette):
size != 0 and size != len(self.palette)
):
raise ValueError("wrong palette size") raise ValueError("wrong palette size")
@property
def palette(self):
return self._palette
@palette.setter
def palette(self, palette):
self._palette = palette
mode_len = len(self.mode)
self.colors = {}
for i in range(0, len(self.palette), mode_len):
color = tuple(self.palette[i : i + mode_len])
if color in self.colors:
continue
self.colors[color] = i // mode_len
def copy(self): def copy(self):
new = ImagePalette() new = ImagePalette()
@ -54,7 +67,6 @@ class ImagePalette:
new.rawmode = self.rawmode new.rawmode = self.rawmode
if self.palette is not None: if self.palette is not None:
new.palette = self.palette[:] new.palette = self.palette[:]
new.colors = self.colors.copy()
new.dirty = self.dirty new.dirty = self.dirty
return new return new
@ -68,7 +80,7 @@ class ImagePalette:
""" """
if self.rawmode: if self.rawmode:
return self.rawmode, self.palette return self.rawmode, self.palette
return self.mode + ";L", self.tobytes() return self.mode, self.tobytes()
def tobytes(self): def tobytes(self):
"""Convert palette to bytes. """Convert palette to bytes.
@ -80,14 +92,12 @@ class ImagePalette:
if isinstance(self.palette, bytes): if isinstance(self.palette, bytes):
return self.palette return self.palette
arr = array.array("B", self.palette) arr = array.array("B", self.palette)
if hasattr(arr, "tobytes"):
return arr.tobytes() return arr.tobytes()
return arr.tostring()
# Declare tostring as an alias for tobytes # Declare tostring as an alias for tobytes
tostring = tobytes tostring = tobytes
def getcolor(self, color): def getcolor(self, color, image=None):
"""Given an rgb tuple, allocate palette entry. """Given an rgb tuple, allocate palette entry.
.. warning:: This method is experimental. .. warning:: This method is experimental.
@ -95,19 +105,37 @@ class ImagePalette:
if self.rawmode: if self.rawmode:
raise ValueError("palette contains raw palette data") raise ValueError("palette contains raw palette data")
if isinstance(color, tuple): if isinstance(color, tuple):
if self.mode == "RGB":
if len(color) == 4 and color[3] == 255:
color = color[:3]
elif self.mode == "RGBA":
if len(color) == 3:
color += (255,)
try: try:
return self.colors[color] return self.colors[color]
except KeyError as e: except KeyError as e:
# allocate new color slot # allocate new color slot
if isinstance(self.palette, bytes): if not isinstance(self.palette, bytearray):
self.palette = bytearray(self.palette) self._palette = bytearray(self.palette)
index = len(self.colors) index = len(self.palette) // 3
if index >= 256:
if image:
# Search for an unused index
for i, count in reversed(list(enumerate(image.histogram()))):
if count == 0:
index = i
break
if index >= 256: if index >= 256:
raise ValueError("cannot allocate more than 256 colors") from e raise ValueError("cannot allocate more than 256 colors") from e
self.colors[color] = index self.colors[color] = index
self.palette[index] = color[0] if index * 3 < len(self.palette):
self.palette[index + 256] = color[1] self._palette = (
self.palette[index + 512] = color[2] self.palette[: index * 3]
+ bytes(color)
+ self.palette[index * 3 + 3 :]
)
else:
self._palette += bytes(color)
self.dirty = 1 self.dirty = 1
return index return index
else: else:

View File

@ -54,6 +54,7 @@ class PyAccess:
self.image32 = ffi.cast("int **", vals["image32"]) self.image32 = ffi.cast("int **", vals["image32"])
self.image = ffi.cast("unsigned char **", vals["image"]) self.image = ffi.cast("unsigned char **", vals["image"])
self.xsize, self.ysize = img.im.size self.xsize, self.ysize = img.im.size
self._img = img
# Keep pointer to im object to prevent dereferencing. # Keep pointer to im object to prevent dereferencing.
self._im = img.im self._im = img.im
@ -93,7 +94,7 @@ class PyAccess:
and len(color) in [3, 4] and len(color) in [3, 4]
): ):
# RGB or RGBA value for a P image # RGB or RGBA value for a P image
color = self._palette.getcolor(color) color = self._palette.getcolor(color, self._img)
return self.set_pixel(x, y, color) return self.set_pixel(x, y, color)

View File

@ -320,7 +320,7 @@ def _save(im, fp, filename):
alpha = ( alpha = (
"A" in im.mode "A" in im.mode
or "a" in im.mode or "a" in im.mode
or (im.mode == "P" and "A" in im.im.getpalettemode()) or (im.mode == "P" and "transparency" in im.info)
) )
im = im.convert("RGBA" if alpha else "RGB") im = im.convert("RGBA" if alpha else "RGB")