285 lines
8.8 KiB
Python
285 lines
8.8 KiB
Python
|
#
|
||
|
# The Python Imaging Library.
|
||
|
# $Id$
|
||
|
#
|
||
|
# image palette object
|
||
|
#
|
||
|
# History:
|
||
|
# 1996-03-11 fl Rewritten.
|
||
|
# 1997-01-03 fl Up and running.
|
||
|
# 1997-08-23 fl Added load hack
|
||
|
# 2001-04-16 fl Fixed randint shadow bug in random()
|
||
|
#
|
||
|
# Copyright (c) 1997-2001 by Secret Labs AB
|
||
|
# Copyright (c) 1996-1997 by Fredrik Lundh
|
||
|
#
|
||
|
# See the README file for information on usage and redistribution.
|
||
|
#
|
||
|
from __future__ import annotations
|
||
|
|
||
|
import array
|
||
|
from typing import IO, TYPE_CHECKING, Sequence
|
||
|
|
||
|
from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile
|
||
|
|
||
|
if TYPE_CHECKING:
|
||
|
from . import Image
|
||
|
|
||
|
|
||
|
class ImagePalette:
|
||
|
"""
|
||
|
Color palette for palette mapped images
|
||
|
|
||
|
:param mode: The mode to use for the palette. See:
|
||
|
:ref:`concept-modes`. Defaults to "RGB"
|
||
|
:param palette: An optional palette. If given, it must be a bytearray,
|
||
|
an array or a list of ints between 0-255. The list must consist of
|
||
|
all channels for one color followed by the next color (e.g. RGBRGBRGB).
|
||
|
Defaults to an empty palette.
|
||
|
"""
|
||
|
|
||
|
def __init__(
|
||
|
self,
|
||
|
mode: str = "RGB",
|
||
|
palette: Sequence[int] | bytes | bytearray | None = None,
|
||
|
) -> None:
|
||
|
self.mode = mode
|
||
|
self.rawmode: str | None = None # if set, palette contains raw data
|
||
|
self.palette = palette or bytearray()
|
||
|
self.dirty: int | None = None
|
||
|
|
||
|
@property
|
||
|
def palette(self) -> Sequence[int] | bytes | bytearray:
|
||
|
return self._palette
|
||
|
|
||
|
@palette.setter
|
||
|
def palette(self, palette: Sequence[int] | bytes | bytearray) -> None:
|
||
|
self._colors: dict[tuple[int, ...], int] | None = None
|
||
|
self._palette = palette
|
||
|
|
||
|
@property
|
||
|
def colors(self) -> dict[tuple[int, ...], int]:
|
||
|
if self._colors is None:
|
||
|
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
|
||
|
return self._colors
|
||
|
|
||
|
@colors.setter
|
||
|
def colors(self, colors: dict[tuple[int, ...], int]) -> None:
|
||
|
self._colors = colors
|
||
|
|
||
|
def copy(self) -> ImagePalette:
|
||
|
new = ImagePalette()
|
||
|
|
||
|
new.mode = self.mode
|
||
|
new.rawmode = self.rawmode
|
||
|
if self.palette is not None:
|
||
|
new.palette = self.palette[:]
|
||
|
new.dirty = self.dirty
|
||
|
|
||
|
return new
|
||
|
|
||
|
def getdata(self) -> tuple[str, Sequence[int] | bytes | bytearray]:
|
||
|
"""
|
||
|
Get palette contents in format suitable for the low-level
|
||
|
``im.putpalette`` primitive.
|
||
|
|
||
|
.. warning:: This method is experimental.
|
||
|
"""
|
||
|
if self.rawmode:
|
||
|
return self.rawmode, self.palette
|
||
|
return self.mode, self.tobytes()
|
||
|
|
||
|
def tobytes(self) -> bytes:
|
||
|
"""Convert palette to bytes.
|
||
|
|
||
|
.. warning:: This method is experimental.
|
||
|
"""
|
||
|
if self.rawmode:
|
||
|
msg = "palette contains raw palette data"
|
||
|
raise ValueError(msg)
|
||
|
if isinstance(self.palette, bytes):
|
||
|
return self.palette
|
||
|
arr = array.array("B", self.palette)
|
||
|
return arr.tobytes()
|
||
|
|
||
|
# Declare tostring as an alias for tobytes
|
||
|
tostring = tobytes
|
||
|
|
||
|
def _new_color_index(
|
||
|
self, image: Image.Image | None = None, e: Exception | None = None
|
||
|
) -> int:
|
||
|
if not isinstance(self.palette, bytearray):
|
||
|
self._palette = bytearray(self.palette)
|
||
|
index = len(self.palette) // 3
|
||
|
special_colors: tuple[int | tuple[int, ...] | None, ...] = ()
|
||
|
if image:
|
||
|
special_colors = (
|
||
|
image.info.get("background"),
|
||
|
image.info.get("transparency"),
|
||
|
)
|
||
|
while index in special_colors:
|
||
|
index += 1
|
||
|
if index >= 256:
|
||
|
if image:
|
||
|
# Search for an unused index
|
||
|
for i, count in reversed(list(enumerate(image.histogram()))):
|
||
|
if count == 0 and i not in special_colors:
|
||
|
index = i
|
||
|
break
|
||
|
if index >= 256:
|
||
|
msg = "cannot allocate more than 256 colors"
|
||
|
raise ValueError(msg) from e
|
||
|
return index
|
||
|
|
||
|
def getcolor(
|
||
|
self,
|
||
|
color: tuple[int, ...],
|
||
|
image: Image.Image | None = None,
|
||
|
) -> int:
|
||
|
"""Given an rgb tuple, allocate palette entry.
|
||
|
|
||
|
.. warning:: This method is experimental.
|
||
|
"""
|
||
|
if self.rawmode:
|
||
|
msg = "palette contains raw palette data"
|
||
|
raise ValueError(msg)
|
||
|
if isinstance(color, tuple):
|
||
|
if self.mode == "RGB":
|
||
|
if len(color) == 4:
|
||
|
if color[3] != 255:
|
||
|
msg = "cannot add non-opaque RGBA color to RGB palette"
|
||
|
raise ValueError(msg)
|
||
|
color = color[:3]
|
||
|
elif self.mode == "RGBA":
|
||
|
if len(color) == 3:
|
||
|
color += (255,)
|
||
|
try:
|
||
|
return self.colors[color]
|
||
|
except KeyError as e:
|
||
|
# allocate new color slot
|
||
|
index = self._new_color_index(image, e)
|
||
|
assert isinstance(self._palette, bytearray)
|
||
|
self.colors[color] = index
|
||
|
if index * 3 < len(self.palette):
|
||
|
self._palette = (
|
||
|
self._palette[: index * 3]
|
||
|
+ bytes(color)
|
||
|
+ self._palette[index * 3 + 3 :]
|
||
|
)
|
||
|
else:
|
||
|
self._palette += bytes(color)
|
||
|
self.dirty = 1
|
||
|
return index
|
||
|
else:
|
||
|
msg = f"unknown color specifier: {repr(color)}" # type: ignore[unreachable]
|
||
|
raise ValueError(msg)
|
||
|
|
||
|
def save(self, fp: str | IO[str]) -> None:
|
||
|
"""Save palette to text file.
|
||
|
|
||
|
.. warning:: This method is experimental.
|
||
|
"""
|
||
|
if self.rawmode:
|
||
|
msg = "palette contains raw palette data"
|
||
|
raise ValueError(msg)
|
||
|
if isinstance(fp, str):
|
||
|
fp = open(fp, "w")
|
||
|
fp.write("# Palette\n")
|
||
|
fp.write(f"# Mode: {self.mode}\n")
|
||
|
for i in range(256):
|
||
|
fp.write(f"{i}")
|
||
|
for j in range(i * len(self.mode), (i + 1) * len(self.mode)):
|
||
|
try:
|
||
|
fp.write(f" {self.palette[j]}")
|
||
|
except IndexError:
|
||
|
fp.write(" 0")
|
||
|
fp.write("\n")
|
||
|
fp.close()
|
||
|
|
||
|
|
||
|
# --------------------------------------------------------------------
|
||
|
# Internal
|
||
|
|
||
|
|
||
|
def raw(rawmode, data: Sequence[int] | bytes | bytearray) -> ImagePalette:
|
||
|
palette = ImagePalette()
|
||
|
palette.rawmode = rawmode
|
||
|
palette.palette = data
|
||
|
palette.dirty = 1
|
||
|
return palette
|
||
|
|
||
|
|
||
|
# --------------------------------------------------------------------
|
||
|
# Factories
|
||
|
|
||
|
|
||
|
def make_linear_lut(black: int, white: float) -> list[int]:
|
||
|
if black == 0:
|
||
|
return [int(white * i // 255) for i in range(256)]
|
||
|
|
||
|
msg = "unavailable when black is non-zero"
|
||
|
raise NotImplementedError(msg) # FIXME
|
||
|
|
||
|
|
||
|
def make_gamma_lut(exp: float) -> list[int]:
|
||
|
return [int(((i / 255.0) ** exp) * 255.0 + 0.5) for i in range(256)]
|
||
|
|
||
|
|
||
|
def negative(mode: str = "RGB") -> ImagePalette:
|
||
|
palette = list(range(256 * len(mode)))
|
||
|
palette.reverse()
|
||
|
return ImagePalette(mode, [i // len(mode) for i in palette])
|
||
|
|
||
|
|
||
|
def random(mode: str = "RGB") -> ImagePalette:
|
||
|
from random import randint
|
||
|
|
||
|
palette = [randint(0, 255) for _ in range(256 * len(mode))]
|
||
|
return ImagePalette(mode, palette)
|
||
|
|
||
|
|
||
|
def sepia(white: str = "#fff0c0") -> ImagePalette:
|
||
|
bands = [make_linear_lut(0, band) for band in ImageColor.getrgb(white)]
|
||
|
return ImagePalette("RGB", [bands[i % 3][i // 3] for i in range(256 * 3)])
|
||
|
|
||
|
|
||
|
def wedge(mode: str = "RGB") -> ImagePalette:
|
||
|
palette = list(range(256 * len(mode)))
|
||
|
return ImagePalette(mode, [i // len(mode) for i in palette])
|
||
|
|
||
|
|
||
|
def load(filename: str) -> tuple[bytes, str]:
|
||
|
# FIXME: supports GIMP gradients only
|
||
|
|
||
|
with open(filename, "rb") as fp:
|
||
|
paletteHandlers: list[
|
||
|
type[
|
||
|
GimpPaletteFile.GimpPaletteFile
|
||
|
| GimpGradientFile.GimpGradientFile
|
||
|
| PaletteFile.PaletteFile
|
||
|
]
|
||
|
] = [
|
||
|
GimpPaletteFile.GimpPaletteFile,
|
||
|
GimpGradientFile.GimpGradientFile,
|
||
|
PaletteFile.PaletteFile,
|
||
|
]
|
||
|
for paletteHandler in paletteHandlers:
|
||
|
try:
|
||
|
fp.seek(0)
|
||
|
lut = paletteHandler(fp).getpalette()
|
||
|
if lut:
|
||
|
break
|
||
|
except (SyntaxError, ValueError):
|
||
|
pass
|
||
|
else:
|
||
|
msg = "cannot load palette"
|
||
|
raise OSError(msg)
|
||
|
|
||
|
return lut # data, rawmode
|