Source code for psychos.utils.colors

"""psychos.utils.colors: Module with utility functions to handle color conversions."""

import re
from typing import Optional, Tuple, Iterable, Union, Callable, List

from ..types import ColorType, ColorSpace

__all__ = ["Color"]


[docs] class Color: """ A class to handle color conversions between different formats. Parameters ---------- value : Optional[ColorType] The initial color value. Can be: - None: This sets the color to None. - str: A hex string (e.g., "#FF5733") or color name (e.g., "red"). - Iterable with 3 or 4 numbers (ints [0,255] or floats). """ COLOR_CONVERSIONS = {} NAMED_COLORS = {}
[docs] def __init__( self, color: Optional[Union[ColorType, "Color"]] = None, space: Union[ColorSpace, str] = "auto", ) -> None: """Initialize the Color""" if isinstance(color, Color): space = color.space color = color.color if space is not None and isinstance(space, str): space = space.lower().strip() if space not in (self.list_spaces() + [None, "auto"]): raise ValueError(f"Invalid color space: '{space}'. Available: {self.list_spaces()}") self.color = color self.space = space if self.space == "auto": self._detect_space()
def _detect_space(self): """Detect the color space of the input color between common formats (rgb, hex, name).""" if self.color is None: self.space = None elif isinstance(self.color, str): self._detect_string_space() elif isinstance(self.color, (Iterable, tuple, list)): self._detect_iterable_space() else: raise ValueError("Cannot detect color space from input.") def _detect_string_space(self): """Detect the color space of the input color string.""" color = self.color.lower().strip().replace(" ", "") # Check if a named color if color in self.NAMED_COLORS: self.space = "name" self.color = color # Check if a hex color elif re.match(r"^#([0-9a-fA-F]{3}){1,2}$", color): self.space = "hex" # Check if a hexa color elif re.match(r"^#([0-9a-fA-F]{4}){1,2}$", color): self.space = "hexa" else: raise ValueError( "Cannot detect color space from input. Is a not valid hex color or named color?" ) def _detect_iterable_space(self): """Detect the color space of the input color iterable.""" colors = tuple(self.color) # 3 elements, are between 0 and 1: RGB if len(colors) == 3 and all(0 <= c <= 1 for c in colors): self.space = "rgb" # 4 elements, are between 0 and 1: RGBA elif len(colors) == 4 and all(0 <= c <= 1 for c in colors): self.space = "rgba" # 3 elements, are between 0 and 255: RGB255 elif len(colors) == 3 and all(0 <= c <= 255 for c in colors): self.space = "rgb255" self.color = tuple(int(c) for c in colors) # 4 elements, are between 0 and 255: RGBA255 elif len(colors) == 4 and all(0 <= c <= 255 for c in colors): self.space = "rgba255" self.color = tuple(int(c) for c in colors) else: raise ValueError("Ambiguous color format. Please specify the color space.") def __repr__(self): """String representation of the Color object.""" return f"Color({self.color}, space='{self.space}')" @classmethod def _find_conversion(cls, from_space: str, to_space: str) -> List[Callable]: """ Find a path of conversions from 'from_space' to 'to_space' using BFS. Parameters ---------- from_space : str The initial color space. to_space : str The target color space. Returns ------- List[Callable] A list of conversion functions to apply in order. Raises ------ ValueError If no conversion path exists between 'from_space' and 'to_space'. """ from collections import deque # pylint: disable=import-outside-toplevel # BFS initialization queue = deque([(from_space, [])]) # (current_space, conversion_path) visited = set() while queue: current_space, path = queue.popleft() # If we reached the target space, return the conversion path if current_space == to_space: return path # If already visited, skip if current_space in visited: continue # Mark as visited visited.add(current_space) # Explore neighbors (available conversions from current_space) for neighbor, func in cls.COLOR_CONVERSIONS.get(current_space, []): if neighbor not in visited: # Enqueue the neighbor with updated path queue.append((neighbor, path + [func])) # If no path was found, raise an error raise ValueError(f"No conversion path found from {from_space} to {to_space}")
[docs] @classmethod def list_spaces(cls) -> List[str]: """List all available color spaces.""" return list(cls.COLOR_CONVERSIONS.keys())
[docs] @classmethod def list_named_colors(cls) -> List[str]: """List all available named colors.""" return list(cls.NAMED_COLORS.keys())
[docs] def to(self, space: Union[ColorSpace, str]) -> ColorType: """Generic class to get the color in a specific space. Parameters ---------- space : ColorSpace The target color space. Can see available options calling :meth:`list_spaces`. Returns ------- ColorType The color in the target space (e.g. An RGB tuple, a hex string, etc). """ if self.color is None or space == self.space: return self.color for to_space, func in self.COLOR_CONVERSIONS.get(self.space, []): # Direct conversion if to_space == space: return func(self.color) # Find the shortest path of conversions to reach the target space conversion_path = self._find_conversion(self.space, space) color = self.color # Apply conversions chain for func in conversion_path: color = func(color) return color
[docs] def to_rgb(self) -> Tuple[float, float, float]: """ Convert the current color to the RGB color space (float values between 0 and 1). Returns ------- Tuple[float, float, float] A tuple representing the RGB components of the color. Example ------- >>> color = Color("#ff5733") >>> color.to_rgb() (1.0, 0.341, 0.2) >>> color = Color("red") >>> color.to_rgb() (1.0, 0.0, 0.0) """ return self.to(space="rgb")
[docs] def to_rgba(self) -> Tuple[float, float, float, float]: """ Convert the current color to the RGBA color space (float values between 0 and 1). Returns ------- Tuple[float, float, float, float] A tuple representing the RGBA components of the color, with the alpha channel. Example ------- >>> color = Color("#ff573380") >>> color.to_rgba() (1.0, 0.341, 0.2, 0.5) >>> color = Color("green") >>> color.to_rgba() (0.0, 1.0, 0.0, 1.0) """ return self.to(space="rgba")
[docs] def to_rgb255(self) -> Tuple[int, int, int]: """ Convert the current color to the RGB color space (integer values between 0 and 255). Returns ------- Tuple[int, int, int] A tuple representing the RGB components of the color in integer form. Example ------- >>> color = Color("#ff5733") >>> color.to_rgb255() (255, 87, 51) >>> color = Color("blue") >>> color.to_rgb255() (0, 0, 255) """ return self.to(space="rgb255")
[docs] def to_rgba255(self) -> Tuple[int, int, int, int]: """ Convert the current color to the RGBA color space (integer values between 0 and 255). Returns ------- Tuple[int, int, int, int] A tuple representing the RGBA components of the color in integer form. Example ------- >>> color = Color("#ff573380") >>> color.to_rgba255() (255, 87, 51, 128) >>> color = Color("red") >>> color.to_rgba255() (255, 0, 0, 255) """ return self.to(space="rgba255")
[docs] def to_hex(self) -> str: """ Convert the current color to a hexadecimal string. Returns ------- str A string representing the color in hex format. Example ------- >>> color = Color((255, 87, 51)) >>> color.to_hex() '#ff5733' >>> color = Color("blue") >>> color.to_hex() '#0000ff' """ return self.to(space="hex")
[docs] def to_hexa(self) -> str: """ Convert the current color to a hexadecimal string with alpha (transparency) included. Returns ------- str A string representing the color in hex format, including the alpha value. Example ------- >>> color = Color((255, 87, 51, 128)) >>> color.to_hexa() '#ff573380' >>> color = Color("red") >>> color.to_hexa() '#ff0000ff' """ return self.to(space="hexa")
[docs] def to_name(self) -> str: """ Convert the current color to a named color, if available. Returns ------- str A string representing the color name, if it matches one of the known color names. Raises ------ ValueError If no matching color name is found. Example ------- >>> color = Color("#ff0000") >>> color.to_name() 'red' >>> color = Color("green") >>> color.to_name() 'green' """ return self.to(space="name")
[docs] def to_hsv(self) -> Tuple[float, float, float]: """ Convert the current color to the HSV color space. Returns ------- Tuple[float, float, float] A tuple representing the HSV components of the color. Example ------- >>> color = Color("#ff5733") >>> color.to_hsv() (0.033, 0.8, 1.0) >>> color = Color("purple") >>> color.to_hsv() (0.833, 1.0, 0.502) """ return self.to(space="hsv")
[docs] def to_cmyk(self) -> Tuple[float, float, float, float]: """ Convert the current color to the CMYK color space. Returns ------- Tuple[float, float, float, float] A tuple representing the CMYK components of the color. Example ------- >>> color = Color("#ff5733") >>> color.to_cmyk() (0.0, 0.66, 0.8, 0.0) >>> color = Color("yellow") >>> color.to_cmyk() (0.0, 0.0, 1.0, 0.0) """ return self.to(space="cmyk")
[docs] def to_yiq(self) -> Tuple[float, float, float]: """ Convert the current color to the YIQ color space (used for TV broadcasting). Returns ------- Tuple[float, float, float] A tuple representing the YIQ components of the color. Example ------- >>> color = Color("#ff5733") >>> color.to_yiq() (0.592, 0.458, 0.079) >>> color = Color("black") >>> color.to_yiq() (0.0, 0.0, 0.0) """ return self.to(space="yiq")
[docs] def to_hsl(self) -> Tuple[float, float, float]: """ Convert the current color to the HSL color space (Hue, Saturation, Lightness). Returns ------- Tuple[float, float, float] A tuple representing the HSL components of the color. Example ------- >>> color = Color("#ff5733") >>> color.to_hsl() (0.033, 1.0, 0.6) >>> color = Color("blue") >>> color.to_hsl() (0.667, 1.0, 0.5) """ return self.to(space="hsl")
[docs] @classmethod def register_conversion(cls, from_space: str, to_space: str, func: Callable = None) -> Callable: """ Register a new color conversion function, either by using it as a decorator or by calling it directly with a function. Usage: @Color.register_conversion("rgb", "rgba") def rgb_to_rgba(...): pass or Color.register_conversion("rgb", "rgba", rgb_to_rgba) Parameters ---------- from_space : str The source color space. to_space : str The target color space. func : callable, optional The conversion function to register. If None, it's used as a decorator. Returns ------- callable The conversion function, or the decorator if `func` is None. """ if func is not None: # Direct method call: Color.register_conversion("rgb", "rgba", func) cls.COLOR_CONVERSIONS.setdefault(from_space, []).append((to_space, func)) return func # Return the decorator if no function is passed def decorator(f): cls.COLOR_CONVERSIONS.setdefault(from_space, []).append((to_space, f)) return f return decorator
[docs] @classmethod def register_named_color(cls, name: str, hex_code: str) -> None: """Register a new named color.""" cls.NAMED_COLORS[name.lower()] = hex_code
[docs] @classmethod def batch_register_named_colors(cls, colors: dict) -> None: """Register multiple named colors at once.""" cls.NAMED_COLORS.update(colors)
@Color.register_conversion("rgb", "rgba") def rgb_to_rgba(color: Tuple[float, float, float]) -> Tuple[float, float, float, float]: """Conversion between RGB to RGBA""" r, g, b = color return (r, g, b, 1.0) @Color.register_conversion("rgba", "rgb") def rgba_to_rgb(color: Tuple[float, float, float, float]) -> Tuple[float, float, float]: """Conversion between RGBA to RGB. This will loss alpha channel""" r, g, b, _ = color return (r, g, b) def _to_255(c: float) -> int: """Convert a 0-1 float color component to 255 scale.""" # To int c = int(round(c * 255)) # Clip to 0-255 range return min(max(c, 0), 255) @Color.register_conversion("rgb", "rgb255") def rgb_to_rgb255(color: Tuple[float, float, float, float]) -> Tuple[int, int, int]: """Convert RGB to RGB255.""" r, g, b = color return (_to_255(r), _to_255(g), _to_255(b)) @Color.register_conversion("rgb255", "rgb") def rgb255_to_rgb(color: Tuple[int, int, int]) -> Tuple[float, float, float]: """Convert RGB255 to RGB.""" return tuple(c / 255.0 for c in color) @Color.register_conversion("rgba255", "rgba") def rgba255_to_rgba(color: Tuple[int, int, int, int]) -> Tuple[float, float, float, float]: """Convert RGBA255 (0-255 scale) to RGBA (0.0-1.0 scale).""" r, g, b, a = color return (r / 255.0, g / 255.0, b / 255.0, a / 255.0) @Color.register_conversion("rgba", "rgba255") def rgba_to_rgba255(color: Tuple[float, float, float, float]) -> Tuple[int, int, int, int]: """Convert RGBA (0.0-1.0 scale) to RGBA255 (0-255 scale).""" r, g, b, a = color return (_to_255(r), _to_255(g), _to_255(b), _to_255(a)) @Color.register_conversion("name", "hex") def name_to_hex(color: str) -> str: """Convert color name to hex.""" if color.lower().strip().replace(" ", "") in Color.NAMED_COLORS: return Color.NAMED_COLORS[color.lower()] raise ValueError( f"No found color with name: '{color}'. Available colors: {Color.list_named_colors()}" ) @Color.register_conversion("hex", "name") def hex_to_name(color: str) -> str: """Convert hex to color name if possible.""" for name, hex_code in Color.NAMED_COLORS.items(): if hex_code.lower() == color.lower(): return name raise ValueError(f"No name found for hex color: {color}") @Color.register_conversion("hex", "rgb255") def hex_to_rgb255(color: str) -> Tuple[float, float, float]: """Convert hex to RGB.""" r, g, b = int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16) return (r, g, b) @Color.register_conversion("rgb255", "hex") def rgb255_to_hex(color: Tuple[float, float, float]) -> str: """Convert RGB to hex.""" return f"#{''.join(f'{int(c):02X}' for c in color)}" @Color.register_conversion("hex", "hexa") def hex_to_hexa(color: str) -> str: """Convert hex to hexa by appending FF for full alpha.""" return color + "FF" if len(color) == 7 else color @Color.register_conversion("hexa", "hex") def hexa_to_hex(color: str) -> str: """Convert hexa to hex by removing the alpha channel if it is FF.""" return color[:7] @Color.register_conversion("hexa", "rgba255") def hexa_to_rgba255(color: str) -> Tuple[int, int, int, int]: """Convert hexa to RGBA.""" r, g, b = int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16) a = int(color[7:9], 16) if len(color) == 9 else 255 return (r, g, b, a) @Color.register_conversion("rgba255", "hexa") def rgba255_to_hexa(color: Tuple[int, int, int, int]) -> str: """Convert RGBA to hexa string.""" r, g, b, a = color return f"#{r:02X}{g:02X}{b:02X}{a:02X}" @Color.register_conversion("rgb", "yiq") def rgb_to_yiq(color: Tuple[float, float, float]) -> Tuple[float, float, float]: """Convert RGB to YIQ.""" import colorsys # pylint: disable=import-outside-toplevel return colorsys.rgb_to_yiq(*color) @Color.register_conversion("yiq", "rgb") def yiq_to_rgb(color: Tuple[float, float, float]) -> Tuple[float, float, float]: """Convert YIQ to RGB.""" import colorsys # pylint: disable=import-outside-toplevel return colorsys.yiq_to_rgb(*color) @Color.register_conversion("rgb", "hls") def rgb_to_hls(color: Tuple[float, float, float]) -> Tuple[float, float, float]: """Convert RGB to HLS.""" import colorsys # pylint: disable=import-outside-toplevel return colorsys.rgb_to_hls(*color) @Color.register_conversion("hls", "rgb") def hls_to_rgb(color: Tuple[float, float, float]) -> Tuple[float, float, float]: """Convert HLS to RGB.""" import colorsys # pylint: disable=import-outside-toplevel return colorsys.hls_to_rgb(*color) @Color.register_conversion("rgb", "hsv") def rgb_to_hsv(color: Tuple[float, float, float]) -> Tuple[float, float, float]: """Convert RGB to HSV.""" import colorsys # pylint: disable=import-outside-toplevel return colorsys.rgb_to_hsv(*color) @Color.register_conversion("hsv", "rgb") def hsv_to_rgb(color: Tuple[float, float, float]) -> Tuple[float, float, float]: """Convert HSV to RGB.""" import colorsys # pylint: disable=import-outside-toplevel return colorsys.hsv_to_rgb(*color) @Color.register_conversion("cmyk", "rgb") def cmyk_to_rgb(color: Tuple[float, float, float, float]) -> Tuple[float, float, float]: """Convert CMYK to RGB.""" c, m, y, k = color r = (1 - c) * (1 - k) g = (1 - m) * (1 - k) b = (1 - y) * (1 - k) return r, g, b @Color.register_conversion("rgb", "cmyk") def rgb_to_cmyk(color: Tuple[float, float, float]) -> Tuple[float, float, float, float]: """Convert RGB to CMYK.""" r, g, b = color c = 1 - r m = 1 - g y = 1 - b k = min(c, m, y) if k == 1: return 0.0, 0.0, 0.0, 1.0 c = (c - k) / (1 - k) m = (m - k) / (1 - k) y = (y - k) / (1 - k) return c, m, y, k @Color.register_conversion("hls", "hsl") def hls_to_hsl(color: Tuple[float, float, float]) -> Tuple[float, float, float]: """Convert HLS to HSL.""" h, l, s = color return h, s, l @Color.register_conversion("hsl", "hls") def hsl_to_hls(color: Tuple[float, float, float]) -> Tuple[float, float, float]: """Convert HSL to HLS.""" h, s, l = color return h, l, s # The following dictionary contains the most common color names and their hex values. # Have been taken from the package 'webcolors', under the BSD 3-Clause license. # Source code: https://github.com/ubernostrum/webcolors/blob/24.8.0/src/webcolors/_definitions.py # Copyright (c) James Bennett, and contributors. All rights reserved. Color.batch_register_named_colors( { "aliceblue": "#f0f8ff", "antiquewhite": "#faebd7", "aqua": "#00ffff", "aquamarine": "#7fffd4", "azure": "#f0ffff", "beige": "#f5f5dc", "bisque": "#ffe4c4", "black": "#000000", "blanchedalmond": "#ffebcd", "blue": "#0000ff", "blueviolet": "#8a2be2", "brown": "#a52a2a", "burlywood": "#deb887", "cadetblue": "#5f9ea0", "chartreuse": "#7fff00", "chocolate": "#d2691e", "coral": "#ff7f50", "cornflowerblue": "#6495ed", "cornsilk": "#fff8dc", "crimson": "#dc143c", "cyan": "#00ffff", "darkblue": "#00008b", "darkcyan": "#008b8b", "darkgoldenrod": "#b8860b", "darkgray": "#a9a9a9", "darkgreen": "#006400", "darkgrey": "#a9a9a9", "darkkhaki": "#bdb76b", "darkmagenta": "#8b008b", "darkolivegreen": "#556b2f", "darkorange": "#ff8c00", "darkorchid": "#9932cc", "darkred": "#8b0000", "darksalmon": "#e9967a", "darkseagreen": "#8fbc8f", "darkslateblue": "#483d8b", "darkslategray": "#2f4f4f", "darkslategrey": "#2f4f4f", "darkturquoise": "#00ced1", "darkviolet": "#9400d3", "deeppink": "#ff1493", "deepskyblue": "#00bfff", "dimgray": "#696969", "dimgrey": "#696969", "dodgerblue": "#1e90ff", "eigengrau": "#16161d", "firebrick": "#b22222", "floralwhite": "#fffaf0", "forestgreen": "#228b22", "fuchsia": "#ff00ff", "gainsboro": "#dcdcdc", "ghostwhite": "#f8f8ff", "gold": "#ffd700", "goldenrod": "#daa520", "gray": "#808080", "green": "#008000", "greenyellow": "#adff2f", "grey": "#808080", "honeydew": "#f0fff0", "hotpink": "#ff69b4", "indianred": "#cd5c5c", "indigo": "#4b0082", "ivory": "#fffff0", "khaki": "#f0e68c", "lavender": "#e6e6fa", "lavenderblush": "#fff0f5", "lawngreen": "#7cfc00", "lemonchiffon": "#fffacd", "lightblue": "#add8e6", "lightcoral": "#f08080", "lightcyan": "#e0ffff", "lightgoldenrodyellow": "#fafad2", "lightgray": "#d3d3d3", "lightgreen": "#90ee90", "lightgrey": "#d3d3d3", "lightpink": "#ffb6c1", "lightsalmon": "#ffa07a", "lightseagreen": "#20b2aa", "lightskyblue": "#87cefa", "lightslategray": "#778899", "lightslategrey": "#778899", "lightsteelblue": "#b0c4de", "lightyellow": "#ffffe0", "lime": "#00ff00", "limegreen": "#32cd32", "linen": "#faf0e6", "magenta": "#ff00ff", "maroon": "#800000", "mediumaquamarine": "#66cdaa", "mediumblue": "#0000cd", "mediumorchid": "#ba55d3", "mediumpurple": "#9370db", "mediumseagreen": "#3cb371", "mediumslateblue": "#7b68ee", "mediumspringgreen": "#00fa9a", "mediumturquoise": "#48d1cc", "mediumvioletred": "#c71585", "midnightblue": "#191970", "mintcream": "#f5fffa", "mistyrose": "#ffe4e1", "moccasin": "#ffe4b5", "navajowhite": "#ffdead", "navy": "#000080", "oldlace": "#fdf5e6", "olive": "#808000", "olivedrab": "#6b8e23", "orange": "#ffa500", "orangered": "#ff4500", "orchid": "#da70d6", "palegoldenrod": "#eee8aa", "palegreen": "#98fb98", "paleturquoise": "#afeeee", "palevioletred": "#db7093", "papayawhip": "#ffefd5", "peachpuff": "#ffdab9", "peru": "#cd853f", "pink": "#ffc0cb", "plum": "#dda0dd", "powderblue": "#b0e0e6", "purple": "#800080", "red": "#ff0000", "rosybrown": "#bc8f8f", "royalblue": "#4169e1", "saddlebrown": "#8b4513", "salmon": "#fa8072", "sandybrown": "#f4a460", "seagreen": "#2e8b57", "seashell": "#fff5ee", "sienna": "#a0522d", "silver": "#c0c0c0", "skyblue": "#87ceeb", "slateblue": "#6a5acd", "slategray": "#708090", "slategrey": "#708090", "snow": "#fffafa", "springgreen": "#00ff7f", "steelblue": "#4682b4", "tan": "#d2b48c", "teal": "#008080", "thistle": "#d8bfd8", "tomato": "#ff6347", "turquoise": "#40e0d0", "violet": "#ee82ee", "wheat": "#f5deb3", "white": "#ffffff", "whitesmoke": "#f5f5f5", "yellow": "#ffff00", "yellowgreen": "#9acd32", } )