Source code for hbutils.color.utils

"""
Color utility helpers for perceptual distance, random palettes, and gradients.

This module provides utility functions built on top of :class:`hbutils.color.model.Color`
for common color-related operations:

* :func:`visual_distance` - Perceptual distance between two colors in RGB space
* :func:`rnd_colors` - Generator for visually distinct random colors
* :func:`linear_gradient` - Linear gradient interpolation between multiple colors

The functions are designed to accept :class:`Color` objects, CSS3 color names,
hexadecimal strings, or RGB tuples when appropriate.

Example::

    >>> from hbutils.color import visual_distance, rnd_colors, linear_gradient
    >>>
    >>> visual_distance('red', '#00ff00')
    2.5495097567963922
    >>>
    >>> for c in rnd_colors(3):
    ...     print(c)
    #ff00ee
    #00ff00
    #009cff
    >>>
    >>> grad = linear_gradient(('red', 'yellow', 'lime'))
    >>> grad(0.5)
    <Color yellow>

"""
import math
import random
from typing import Iterator, Union, Sequence, Tuple, Callable, Optional

from .model import Color
from ..algorithm import linear_map

__all__ = [
    'visual_distance',
    'rnd_colors',
    'linear_gradient',
]


def _to_color(color: Union[Color, str, Tuple[float, float, float]]) -> Color:
    """
    Convert a color-like value into a :class:`Color` object.

    The input can be a :class:`Color` instance, a CSS3 name, a hexadecimal
    string, or an RGB tuple. If the input is already a :class:`Color`, it is
    returned as-is; otherwise, a new :class:`Color` is constructed.

    :param color: Color representation to convert.
    :type color: Union[Color, str, Tuple[float, float, float]]
    :return: Converted :class:`Color` object.
    :rtype: Color
    :raises TypeError: If the value is not supported by :class:`Color`.
    :raises ValueError: If a string color is invalid.

    Example::

        >>> from hbutils.color import Color
        >>> _to_color('red')
        <Color red>
        >>> _to_color(Color('#ffffff'))
        <Color white>
    """
    if isinstance(color, Color):
        return color
    else:
        return Color(color)


[docs] def visual_distance(c1: Union[Color, str], c2: Union[Color, str]) -> float: """ Calculate the visual distance between two colors. This function computes the perceptual distance between two colors using a weighted Euclidean distance in RGB space. The weights are based on the average red component to account for human perception differences. :param c1: First color, can be a :class:`Color` object or string representation. :type c1: Union[Color, str] :param c2: Second color, can be a :class:`Color` object or string representation. :type c2: Union[Color, str] :return: Distance value representing visual difference between colors. :rtype: float :raises TypeError: If either color is an unsupported type. :raises ValueError: If a string color is invalid. Examples:: >>> from hbutils.color import visual_distance >>> visual_distance('#ff0000', '#00ff00') 2.5495097567963922 >>> visual_distance('#778800', '#887700') 0.16996731711975946 """ c1, c2 = _to_color(c1), _to_color(c2) rgb1, rgb2 = c1.rgb, c2.rgb rmean = (rgb1.red + rgb2.red) / 2 dr = rgb1.red - rgb2.red dg = rgb1.green - rgb2.green db = rgb1.blue - rgb2.blue return math.sqrt( (2 + rmean) * dr * dr + 4 * dg * dg + (3 - rmean) * db * db )
def _dis_ratio(k: int) -> float: """ Calculate the distance ratio based on color index. This helper adjusts the minimum distance requirement when generating random colors to balance distinctiveness and feasibility. :param k: Index of the color relative to previously generated colors. :type k: int :return: Distance ratio value. :rtype: float """ if k < 3: return 6.0 if k < 6: return 2.0 elif k < 8: return 0.7 else: return 1.0
[docs] def rnd_colors( count: int, lightness: float = 0.5, saturation: float = 1.0, alpha: Optional[float] = None, init_dis: float = 4.0, lr: float = 0.95, ur: float = 1.5, rnd: Optional[random.Random] = None ) -> Iterator[Color]: """ Generate random colors that are visually distinct from each other. This generator creates ``count`` colors in HLS space and enforces a minimum visual distance between each new color and all previously generated colors. When many attempts fail, the minimum distance is relaxed; when generation succeeds quickly, the distance is increased. :param count: Number of colors to generate. :type count: int :param lightness: Lightness value in HLS color space (0.0 to 1.0). :type lightness: float :param saturation: Saturation value in HLS color space (0.0 to 1.0). :type saturation: float :param alpha: Alpha (transparency) value for colors; ``None`` means no alpha. :type alpha: Optional[float] :param init_dis: Initial minimum distance between colors. :type init_dis: float :param lr: Lower ratio for decreasing minimum distance after failures. :type lr: float :param ur: Upper ratio for increasing minimum distance after successes. :type ur: float :param rnd: Random number generator instance; if ``None``, ``random.Random(0)`` is used. :type rnd: Optional[random.Random] :return: Iterator yielding :class:`Color` objects. :rtype: Iterator[Color] .. note:: The generator yields colors lazily; iteration triggers the generation. Examples:: >>> from hbutils.color import rnd_colors >>> for c in rnd_colors(3): ... print(c) #ff00ee #00ff00 #009cff >>> for c in rnd_colors(3, 0.8, 0.9): ... print(c) #fa9ef4 #9efaa1 #9eb4fa """ rnd = rnd or random.Random(0) min_distance = init_dis _exist_colors = [] for i in range(count): try_cnt, total_try_cnt = 0, 0 while True: new_color = Color.from_hls(rnd.random(), lightness, saturation) if not _exist_colors or all( [visual_distance(color_, new_color) >= min_distance * _dis_ratio(i - j) for j, color_ in enumerate(_exist_colors)]): _exist_colors.append(new_color) if total_try_cnt <= count * 2: min_distance *= ur yield Color(new_color, alpha) break else: try_cnt += 1 total_try_cnt += 1 if try_cnt >= count * 2: min_distance *= lr try_cnt = 0
[docs] def linear_gradient( colors: Union[ Sequence[Union[Color, str, Tuple[float, float, float]]], Sequence[Tuple[float, Union[Color, str, Tuple[float, float, float]]]] ] ) -> Callable[[float], Color]: """ Create a linear gradient function from a sequence of colors. This function creates a gradient mapping that interpolates linearly between the provided colors. Colors can be provided either as a simple sequence (evenly distributed) or as position-color tuples for custom positioning. :param colors: Sequence of colors or position-color tuples. If a simple sequence, colors are evenly distributed from 0 to 1. If tuples, the first element is position and the second is the color. :type colors: Union[Sequence[Union[Color, str, Tuple[float, float, float]]], \ Sequence[Tuple[float, Union[Color, str, Tuple[float, float, float]]]]] :return: A function that maps a float position to the interpolated color. :rtype: Callable[[float], Color] :raises AssertionError: If control points are empty or not strictly increasing. :raises ZeroDivisionError: If only one control point is provided. :raises TypeError: If input is not iterable or contains invalid elements. :raises ValueError: If the gradient function is evaluated outside valid range or if any color value is invalid. Examples:: - Simple Linear Gradientation >>> from hbutils.color import linear_gradient >>> >>> f = linear_gradient(('red', 'yellow', 'lime')) >>> f(0) <Color red> >>> f(0.25) <Color #ff8000> >>> f(0.5) <Color yellow> >>> f(1) <Color lime> - Complex Linear Gradientation >>> f = linear_gradient(((-0.2, 'red'), (0.7, '#ffff0044'), (1.1, 'lime'))) >>> f(-0.2) <Color red, alpha: 1.000> >>> f(0.7) <Color yellow, alpha: 0.267> >>> f(1.1) <Color lime, alpha: 1.000> """ try: xys = [(x, _to_color(y)) for x, y in colors] except ValueError: pts = list(colors) n = len(pts) xys = [(i / (n - 1), _to_color(y)) for i, y in enumerate(colors)] rmap = linear_map([(x, y.rgb.red) for x, y in xys]) gmap = linear_map([(x, y.rgb.green) for x, y in xys]) bmap = linear_map([(x, y.rgb.blue) for x, y in xys]) if any([y.alpha is not None for _, y in xys]): amap = linear_map([(x, y.alpha if y.alpha is not None else 1.0) for x, y in xys]) else: # noinspection PyUnusedLocal def amap(x: float) -> Optional[float]: """ Return ``None`` for alpha channel when no alpha values are specified. :param x: Position parameter (unused). :type x: float :return: ``None``. :rtype: Optional[float] """ return None def _gradient(x: float) -> Color: """ Interpolate color at given position. :param x: Position in the gradient (typically within the defined range). :type x: float :return: Interpolated color at position ``x``. :rtype: Color """ return Color((rmap(x), gmap(x), bmap(x)), amap(x)) return _gradient