Source code for tabulous.color

from __future__ import annotations
from typing import Any, NamedTuple
from functools import lru_cache
import colorsys
from tabulous.types import ColorType, ColorMapping

import numpy as np


[docs]class ColorTuple(NamedTuple): """8-bit color tuple.""" r: int g: int b: int a: int = 255 @property def opacity(self) -> float: """Return the opacity as a float between 0 and 1.""" return self.a / 255.0 @property def html(self) -> str: """Return a HTML color string.""" if self.a == 255: return f"#{self.r:02X}{self.g:02X}{self.b:02X}" return f"#{self.r:02X}{self.g:02X}{self.b:02X}{self.a:02X}" @property def hlsa(self) -> tuple[float, float, float, float]: """Return the color as HSLA.""" hlsa_float = colorsys.rgb_to_hls( self.r / 255.0, self.g / 255.0, self.b / 255.0 ) + (self.opacity,) return tuple(int(round(c * 255)) for c in hlsa_float) @property def hsva(self) -> tuple[float, float, float, float]: """Return the color as HSVA.""" hsva_float = colorsys.rgb_to_hsv( self.r / 255.0, self.g / 255.0, self.b / 255.0 ) + (self.opacity,) return tuple(int(round(c * 255)) for c in hsva_float)
[docs] @classmethod def from_html(cls, html: str) -> ColorTuple: """Create a ColorTuple from a HTML color string.""" if html.startswith("#"): html = html[1:] if len(html) == 6: html += "FF" return cls(*[int(html[i : i + 2], 16) for i in range(0, 8, 2)])
[docs] @classmethod def from_hlsa(cls, *hlsa) -> ColorTuple: """Create a ColorTuple from HSLA.""" if len(hlsa) == 1: hlsa = hlsa[0] if len(hlsa) == 3: hls = hlsa alpha = 255 hls = tuple(c / 255.0 for c in hls) return cls(*[int(round(c * 255)) for c in colorsys.hls_to_rgb(*hls)], alpha)
[docs] @classmethod def from_hsva(cls, *hsva) -> ColorTuple: """Create a ColorTuple from HSVA.""" if len(hsva) == 1: hsva = hsva[0] if len(hsva) == 3: hsv = hsva alpha = 255 hsv_float = tuple(c / 255.0 for c in hsv) return cls( *[int(round(c * 255)) for c in colorsys.hsv_to_rgb(*hsv_float)], alpha )
[docs] def equals(self, other): other = normalize_color(other) return self == other
[docs] def brighten(self, ratio: float) -> ColorTuple: """Set the saturation of the color.""" hsv = self.hsva[:3] val = round(hsv[2] * (1 + ratio)) val = min(255, max(0, val)) hsv = (hsv[0], hsv[1], val) return ColorTuple.from_hsva(hsv)
[docs] def mix(self, other, ratio: float = 0.5, alpha: bool = False) -> ColorTuple: """Mix the color with another color.""" other = normalize_color(other) if alpha: _alpha = _8bit(self.a * (1 - ratio) + other.a * ratio) else: _alpha = self.a return ColorTuple( _8bit(self.r * (1 - ratio) + other.r * ratio), _8bit(self.g * (1 - ratio) + other.g * ratio), _8bit(self.b * (1 - ratio) + other.b * ratio), _alpha, )
def _8bit(x: float) -> int: return max(min(int(round(x)), 255), 0)
[docs]def normalize_color(color: ColorType) -> ColorTuple: """Normalize a color-like object to a ColorTuple.""" if isinstance(color, str): return ColorTuple(*_str_color_to_tuple(color)) if hasattr(color, "__iter__"): out = [int(c) for c in color] if len(out) == 3: out += [255] elif len(out) == 4: pass else: raise ValueError(f"Invalid color: {color!r}") return ColorTuple(*out) raise ValueError(f"Invalid color: {color!r}")
[docs]def rgba_to_str(rgba: tuple[int, int, int, int]) -> str: color_name = COLORS_BY_VALUE.get(rgba, None) if color_name is None: code = "#" + "".join(hex(c)[2:].upper().zfill(2) for c in rgba) if code.endswith("FF"): code = code[:-2] return code return color_name
[docs]class ConvertedColormap: def __init__(self, func: ColorMapping): self.func = func self.__name__ = f"{type(self).__name__}<{func.__name__}>" self.__annotations__ = func.__annotations__ def __repr__(self): return f"{type(self).__name__}<{self.func!r}>"
[docs]class InvertedColormap(ConvertedColormap):
[docs] @classmethod def from_colormap(cls, cmap: ColorMapping) -> ColorMapping: """Convert a colormap into return an inverted one.""" if isinstance(cmap, cls): return cmap.func return cls(cmap)
def __call__(self, x: Any) -> ColorType: color = self.func(x) if color is None: return color color = np.array(normalize_color(color), dtype=np.uint8) color[:3] = 255 - color[:3] return color
[docs]class OpacityColormap(ConvertedColormap): def __init__(self, func: ColorMapping, opacity: float): super().__init__(func) if opacity < 0 or 1 < opacity: raise ValueError(f"Opacity must be between 0 and 1, got {opacity}") self._alpha = int(opacity * 255)
[docs] @classmethod def from_colormap(cls, cmap: ColorMapping, opacity: float) -> ColorMapping: """Convert a colormap into an new one with given alpha channel.""" if isinstance(cmap, cls): return cls(cmap.func, opacity) return cls(cmap, opacity)
def __call__(self, x: Any) -> ColorType: color = self.func(x) if color is None: return color color = np.array(normalize_color(color), dtype=np.uint8) color[3] = self._alpha return color
[docs]class BrightenedColormap(ConvertedColormap): def __init__(self, func: ColorMapping, factor: float): super().__init__(func) if factor < -1: raise ValueError(f"Brightening factor fell below -1.0: {factor}") if 1 < factor: raise ValueError(f"Brightening factor exceeded 1.0: {factor}") self._factor = factor
[docs] @classmethod def from_colormap(cls, cmap: ColorMapping, factor: float) -> ColorMapping: """Convert a colormap into an new one with given brightening factor.""" if isinstance(cmap, cls): return cls(cmap.func, cmap._factor + factor) return cls(cmap, factor)
def __call__(self, x: Any) -> ColorType: color = self.func(x) if color is None: return color color = np.array(normalize_color(color), dtype=np.float64) factor = self._factor if factor > 0: extreme = np.array([255, 255, 255, 255], dtype=np.float64) else: extreme = np.array([0, 0, 0, 255], dtype=np.float64) color = color * (1 - factor) + extreme * factor return np.round(color).astype(np.uint8)
@lru_cache(maxsize=64) def _str_color_to_tuple(color: str) -> tuple[int, int, int, int]: out = COLORS_BY_NAME.get(color, None) if out is not None: return out if color.startswith("#"): color = color[1:] if len(color) == 6: return int(color[:2], 16), int(color[2:4], 16), int(color[4:6], 16), 255 elif len(color) == 8: return ( int(color[:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16), ) raise ValueError(f"Invalid color: {color!r}") # This dict is modified from pydantic (MIT licence) # See https://github.com/samuelcolvin/pydantic COLORS_BY_NAME = { "aliceblue": (240, 248, 255, 255), "antiquewhite": (250, 235, 215, 255), "aqua": (0, 255, 255, 255), "aquamarine": (127, 255, 212, 255), "azure": (240, 255, 255, 255), "beige": (245, 245, 220, 255), "bisque": (255, 228, 196, 255), "black": (0, 0, 0, 255), "blanchedalmond": (255, 235, 205, 255), "blue": (0, 0, 255, 255), "blueviolet": (138, 43, 226, 255), "brown": (165, 42, 42, 255), "burlywood": (222, 184, 135, 255), "cadetblue": (95, 158, 160, 255), "chartreuse": (127, 255, 0, 255), "chocolate": (210, 105, 30, 255), "coral": (255, 127, 80, 255), "cornflowerblue": (100, 149, 237, 255), "cornsilk": (255, 248, 220, 255), "crimson": (220, 20, 60, 255), "cyan": (0, 255, 255, 255), "darkblue": (0, 0, 139, 255), "darkcyan": (0, 139, 139, 255), "darkgoldenrod": (184, 134, 11, 255), "darkgray": (169, 169, 169, 255), "darkgreen": (0, 100, 0, 255), "darkgrey": (169, 169, 169, 255), "darkkhaki": (189, 183, 107, 255), "darkmagenta": (139, 0, 139, 255), "darkolivegreen": (85, 107, 47, 255), "darkorange": (255, 140, 0, 255), "darkorchid": (153, 50, 204, 255), "darkred": (139, 0, 0, 255), "darksalmon": (233, 150, 122, 255), "darkseagreen": (143, 188, 143, 255), "darkslateblue": (72, 61, 139, 255), "darkslategray": (47, 79, 79, 255), "darkslategrey": (47, 79, 79, 255), "darkturquoise": (0, 206, 209, 255), "darkviolet": (148, 0, 211, 255), "deeppink": (255, 20, 147, 255), "deepskyblue": (0, 191, 255, 255), "dimgray": (105, 105, 105, 255), "dimgrey": (105, 105, 105, 255), "dodgerblue": (30, 144, 255, 255), "firebrick": (178, 34, 34, 255), "floralwhite": (255, 250, 240, 255), "forestgreen": (34, 139, 34, 255), "fuchsia": (255, 0, 255, 255), "gainsboro": (220, 220, 220, 255), "ghostwhite": (248, 248, 255, 255), "gold": (255, 215, 0, 255), "goldenrod": (218, 165, 32, 255), "gray": (128, 128, 128, 255), "green": (0, 128, 0, 255), "greenyellow": (173, 255, 47, 255), "grey": (128, 128, 128, 255), "honeydew": (240, 255, 240, 255), "hotpink": (255, 105, 180, 255), "indianred": (205, 92, 92, 255), "indigo": (75, 0, 130, 255), "ivory": (255, 255, 240, 255), "khaki": (240, 230, 140, 255), "lavender": (230, 230, 250, 255), "lavenderblush": (255, 240, 245, 255), "lawngreen": (124, 252, 0, 255), "lemonchiffon": (255, 250, 205, 255), "lightblue": (173, 216, 230, 255), "lightcoral": (240, 128, 128, 255), "lightcyan": (224, 255, 255, 255), "lightgoldenrodyellow": (250, 250, 210, 255), "lightgray": (211, 211, 211, 255), "lightgreen": (144, 238, 144, 255), "lightgrey": (211, 211, 211, 255), "lightpink": (255, 182, 193, 255), "lightsalmon": (255, 160, 122, 255), "lightseagreen": (32, 178, 170, 255), "lightskyblue": (135, 206, 250, 255), "lightslategray": (119, 136, 153, 255), "lightslategrey": (119, 136, 153, 255), "lightsteelblue": (176, 196, 222, 255), "lightyellow": (255, 255, 224, 255), "lime": (0, 255, 0, 255), "limegreen": (50, 205, 50, 255), "linen": (250, 240, 230, 255), "magenta": (255, 0, 255, 255), "maroon": (128, 0, 0, 255), "mediumaquamarine": (102, 205, 170, 255), "mediumblue": (0, 0, 205, 255), "mediumorchid": (186, 85, 211, 255), "mediumpurple": (147, 112, 219, 255), "mediumseagreen": (60, 179, 113, 255), "mediumslateblue": (123, 104, 238, 255), "mediumspringgreen": (0, 250, 154, 255), "mediumturquoise": (72, 209, 204, 255), "mediumvioletred": (199, 21, 133, 255), "midnightblue": (25, 25, 112, 255), "mintcream": (245, 255, 250, 255), "mistyrose": (255, 228, 225, 255), "moccasin": (255, 228, 181, 255), "navajowhite": (255, 222, 173, 255), "navy": (0, 0, 128, 255), "oldlace": (253, 245, 230, 255), "olive": (128, 128, 0, 255), "olivedrab": (107, 142, 35, 255), "orange": (255, 165, 0, 255), "orangered": (255, 69, 0, 255), "orchid": (218, 112, 214, 255), "palegoldenrod": (238, 232, 170, 255), "palegreen": (152, 251, 152, 255), "paleturquoise": (175, 238, 238, 255), "palevioletred": (219, 112, 147, 255), "papayawhip": (255, 239, 213, 255), "peachpuff": (255, 218, 185, 255), "peru": (205, 133, 63, 255), "pink": (255, 192, 203, 255), "plum": (221, 160, 221, 255), "powderblue": (176, 224, 230, 255), "purple": (128, 0, 128, 255), "red": (255, 0, 0, 255), "rosybrown": (188, 143, 143, 255), "royalblue": (65, 105, 225, 255), "saddlebrown": (139, 69, 19, 255), "salmon": (250, 128, 114, 255), "sandybrown": (244, 164, 96, 255), "seagreen": (46, 139, 87, 255), "seashell": (255, 245, 238, 255), "sienna": (160, 82, 45, 255), "silver": (192, 192, 192, 255), "skyblue": (135, 206, 235, 255), "slateblue": (106, 90, 205, 255), "slategray": (112, 128, 144, 255), "slategrey": (112, 128, 144, 255), "snow": (255, 250, 250, 255), "springgreen": (0, 255, 127, 255), "steelblue": (70, 130, 180, 255), "tan": (210, 180, 140, 255), "teal": (0, 128, 128, 255), "thistle": (216, 191, 216, 255), "tomato": (255, 99, 71, 255), "turquoise": (64, 224, 208, 255), "violet": (238, 130, 238, 255), "wheat": (245, 222, 179, 255), "white": (255, 255, 255, 255), "whitesmoke": (245, 245, 245, 255), "yellow": (255, 255, 0, 255), "yellowgreen": (154, 205, 50, 255), } COLORS_BY_VALUE = {v: k for k, v in COLORS_BY_NAME.items()}