#
##
## SPDX-FileCopyrightText: © 2007-2024 Benedict Verhegghe <bverheg@gmail.com>
## SPDX-License-Identifier: GPL-3.0-or-later
##
## This file is part of pyFormex 3.5 (Thu Feb 8 19:11:13 CET 2024)
## pyFormex is a tool for generating, manipulating and transforming 3D
## geometrical models by sequences of mathematical operations.
## Home page: https://pyformex.org
## Project page: https://savannah.nongnu.org/projects/pyformex/
## Development: https://gitlab.com/bverheg/pyformex
## Distributed under the GNU General Public License version 3 or later.
##
## This program is free software: you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation, either version 3 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program. If not, see http://www.gnu.org/licenses/.
##
"""Playing with colors.
This module holds the basic functionality for working with colors in pyFormex.
The :class:`Color` class represents a single color. It allows creation of
a color from different inputs and conversion to many outputs.
For working with large sets of colors, there is the :class:`colorArray`,
which collects many colors in a :class:`numpy.ndarray`, allowing highly
performant computation.
There are functions to handle named colors, convert between color systems,
compute lightness.
A number of legacy functions ar deprecated, as their functionlity can be
achieved by using the Color class.
The following table shows the built-in colors, with their name,
RGB values in 0..1 range and luminance.
>>> with floatformat('.1f'):
... for k,v in PF_COLORS.items():
... print(f"{k:>15s} = {v} -> {v.luminance():.3f} ({v.lightness():.3f})")
darkgrey = (0.4, 0.4, 0.4) -> 0.133 (0.432)
red = (1.0, 0.0, 0.0) -> 0.213 (0.532)
green = (0.0, 1.0, 0.0) -> 0.715 (0.877)
blue = (0.1, 0.1, 0.8) -> 0.053 (0.275)
cyan = (0.0, 1.0, 1.0) -> 0.787 (0.911)
magenta = (1.0, 0.0, 1.0) -> 0.285 (0.603)
yellow = (1.0, 1.0, 0.0) -> 0.928 (0.971)
white = (1.0, 1.0, 1.0) -> 1.000 (1.000)
black = (0.0, 0.0, 0.0) -> 0.000 (0.000)
darkred = (0.6, 0.0, 0.0) -> 0.068 (0.313)
darkgreen = (0.0, 0.5, 0.0) -> 0.153 (0.461)
darkblue = (0.1, 0.2, 0.6) -> 0.049 (0.264)
darkcyan = (0.0, 0.6, 0.6) -> 0.251 (0.572)
darkmagenta = (0.6, 0.0, 0.6) -> 0.091 (0.361)
darkyellow = (0.6, 0.6, 0.0) -> 0.296 (0.613)
lightgrey = (0.8, 0.8, 0.8) -> 0.604 (0.820)
mediumgrey = (0.6, 0.6, 0.6) -> 0.319 (0.632)
pyformex_pink = (1.0, 0.2, 0.4) -> 0.246 (0.567)
orange = (1.0, 0.5, 0.0) -> 0.366 (0.670)
purple = (0.6, 0.2, 1.0) -> 0.164 (0.474)
brown = (0.6, 0.4, 0.2) -> 0.165 (0.476)
gold = (1.0, 0.8, 0.0) -> 0.644 (0.842)
orchid = (0.8, 0.4, 0.9) -> 0.280 (0.599)
lightlightgrey = (0.9, 0.9, 0.9) -> 0.787 (0.911)
>>> Palette['default']
('darkgrey', 'red', 'green', 'blue', 'cyan', 'magenta', 'yellow', 'white',
'black', 'darkred', 'darkgreen', 'darkblue', 'darkcyan', 'darkmagenta',
'darkyellow', 'lightgrey', 'mediumgrey', 'pyformex_pink', 'orange', 'purple',
'brown', 'gold', 'orchid', 'lightlightgrey')
>>> Palette['dark']
('darkgrey', 'blue', 'black', 'darkred', 'darkgreen', 'darkblue',
'darkmagenta', 'purple', 'brown')
>>> Palette['light']
('red', 'green', 'cyan', 'magenta', 'yellow', 'white', 'darkcyan',
'darkyellow', 'lightgrey', 'mediumgrey', 'pyformex_pink', 'orange', 'gold',
'orchid', 'lightlightgrey')
"""
from functools import partial
import numpy as np
import pyformex as pf
from pyformex import utils
from pyformex import arraytools as at
from pyformex.globalformat import floatformat, intformat
__all__ = ['Color', 'colorArray']
class InvalidColor(ValueError):
pass
carray = partial(np.array, dtype=np.float32)
def uarray(a):
return np.array(a).round().astype(np.uint8)
[docs]def normalize_color_name(col):
"""Normalize a color name.
Different sources use different styles to name colors.
This functions normalizes color names by removing spaces and special
characters, converting everything to lower case, and converting
'gray' to 'grey'.
Examples
--------
>>> normalize_color_name('DarkGray')
'darkgrey'
>>> normalize_color_name(' dark gray')
'darkgrey'
>>> normalize_color_name('dark_gray-blue')
'dark_greyblue'
"""
col = ''.join(c for c in col if c.isalnum() or c == '_')
return col.lower().replace('gray', 'grey')
[docs]def clipit(value, minvalue=None, maxvalue=None):
"""Clip a float value
>>> clipit(1.7)
1.7
>>> clipit(1.7, minvalue=2.0)
2.0
"""
if minvalue is not None:
value = max(value, minvalue)
if maxvalue is not None:
value = min(value, maxvalue)
return value
# TODO: Color & Color4 classes??
[docs]class Color(tuple):
"""A class representing a color.
The Color class represents a single color, with or without transparency.
The color is internally stored as a tuple of 3 (RGB) or 4 (RGBA) components.
Each component is a float value in the range 0.0 to 1.0. Color instances
can be initialized from a variety of inputs.
Parameters
----------
color:
One, three or four arguments specifying the color. If three or four
arguments are given, they are handled as if a single argument
``tuple(*colors)`` was provided. If only one argument,
it must be one of the following:
- an int: an index into the Color.palette (modulo the palette length)
- a float: a grey color with the given intensity
- a string with the name of one of the built-in colorset('pf')
- a string specifying the X11 name of a color
- a hex string '#rgb' or '#rrggbb' with 1 or 2 hexadecimal digits
per color
- a tuple or list of 3 or 4 integer values in the range 0..255
- a tuple or list of 3 or 4 float values in the range 0.0..1.0
alpha: float, optional
A float in the range 0.0 to 1.0.
If provided, forces the creation of a 4-channel color, adding the
provided value as the alpha channel if ``*color`` only contained 3
components. This will not overwrite the alpha channel if ``*color``
already contains 4 components. Overwriting alpha can be achieved
with ``Color(*color[:3], alpha=value)``.
clip: bool
If True, the final values will be clipped to the range 0.0..1.0.
If False (default), values outside that range are allowed:
OpenGL can make clever use of such values and clip them at
render time.
Raises
------
ValueError: If the input is not one of the accepted data.
Examples
--------
>>> Color(1.0, 1.0, 0.0) == Color([1.0, 1.0, 0.0])
True
>>> c = Color(1.0, 0.5, 0.2)
>>> c
Color(1.0, 0.5, 0.2)
>>> c.alpha
>>> c.rgb
(1.0, 0.5, 0.2)
>>> c.rgba
(1.0, 0.5, 0.2, 0.5)
>>> c.RGB
(255, 128, 51)
>>> c.RGBA
(255, 128, 51, 128)
>>> c.web
'#ff8033'
>>> c.gl
array([1. , 0.5, 0.2])
>>> c.gl4
array([1. , 0.5, 0.2, 0.5])
>>> c.name
'chocolate1'
>>> c.namediff()
('chocolate1', 0.03398...)
>>> c.luminance()
0.3681...
>>> Color(2)
Color(0.0, 1.0, 0.0)
>>> Color('red')
Color(1.0, 0.0, 0.0)
>>> Color('indianred')
Color(0.8039..., 0.3607..., 0.3607...)
>>> Color('grey90')
Color(0.8980..., 0.8980..., 0.8980...)
>>> Color('#ff0000')
Color(1.0, 0.0, 0.0)
>>> Color("zorro")
Traceback (most recent call last):
...
pyformex.color.InvalidColor: No color named 'zorro'
>>> red
Color(1.0, 0.0, 0.0)
>>> Color([200,200,255])
Color(0.7843..., 0.7843..., 1.0)
>>> Color(np.array([200,200,255], dtype=np.uint8))
Color(0.7843..., 0.7843..., 1.0)
>>> Color([1.,1.,1.])
Color(1.0, 1.0, 1.0)
>>> Color(0.6)
Color(0.6, 0.6, 0.6)
>>> Color(0.6, alpha=0.8)
Color(0.6, 0.6, 0.6, 0.8)
>>> Color(200, 200, 255, 128)
Color(0.7843..., 0.7843..., 1.0, 0.5...)
>>> Color(200, 200, 255, alpha=0.5)
Color(0.7843..., 0.7843..., 1.0, 0.5)
>>> Color(200, 200, 255, 255, alpha=0.5)
Color(0.7843..., 0.7843..., 1.0, 1.0)
"""
DEFAULT_ALPHA = 0.5 # default alpha value
palette = [(0.0, 0.0, 0.0)]
def __new__(clas, *color, alpha=None, clip=False):
"""Initialize a Color from a variaty of inputs."""
if len(color) == 1:
color = color[0]
if isinstance(color, Color):
# a Color: make a shallow copy
pass
elif at.isInt(color):
# single int value: convert to current palette color
color = tuple(Color.palette[color % len(Color.palette)])
elif at.isFloat(color):
# single float value: convert to a grey value
color = Color.grey(color)
elif isinstance(color, (tuple, list)) and len(color) in (3, 4) \
and all(isinstance(c, (int, float)) for c in color):
# tuple of 3 ints or floats
color = tuple(color)
elif isinstance(color, np.ndarray) and len(color) in (3, 4) \
and color.dtype.kind in ('i', 'u', 'f'):
# tuple of 3 ints or floats
color = tuple(color)
elif isinstance(color, str):
if color[:1] != '#':
scolor = color
color = named_color(normalize_color_name(color))
if color is None:
raise InvalidColor(f"No color named {scolor!r}")
else:
color = web_RGB(color)
else:
raise InvalidColor(f"Can not make a Color from {color!r}")
# Here, color is a tuple
assert isinstance(color, tuple)
assert len(color) in (3, 4)
if at.isInt(color[0]):
color = RGB_rgb(color)
# Now, color is a tuple of three or 4 floats
if alpha is not None and len(color) == 3:
color = color + (alpha,)
if clip:
color = tuple(clipit(c, 0.0, 1.0) for c in color)
return super().__new__(clas, color)
[docs] def clip(self, min=0.0, max=1.0):
"""Clip the value to the specified range"""
super.__init__(clipit(c, min, max) for c in self)
@property
def r(self):
return self[0]
@property
def g(self):
return self[1]
@property
def b(self):
return self[2]
@property
def alpha(self):
if len(self) > 3:
return self[3]
@property
def rgb(self):
"""Return the red, green and blue component as a tuple"""
return self[:3]
@property
def rgba(self):
"""Return a tuple (r,g,b,a)"""
if len(self) == 4:
return self
else:
return self + (Color.DEFAULT_ALPHA,)
@property
def RGB(self):
"""Return red, green and blue values as int values"""
return rgb_RGB(self.rgb)
@property
def RGBA(self):
"""Return RGBA int values"""
return rgb_RGB(self.rgba)
@property
def gl(self):
"""Return the red, green and blue components as a float array"""
return carray(self.rgb)
@property
def gl4(self):
return carray(self.rgba)
# @property
# def yiq(self):
# """Convert rgb color to yiq system"""
# import colorsys
# return colorsys.rgb_to_yiq(*self)
# @property
# def hls(self):
# """Convert rgb color to yiq system"""
# import colorsys
# return colorsys.rgb_to_hls(*self)
# @property
# def hsv(self):
# """Convert rgb color to yiq system"""
# import colorsys
# return colorsys.rgb_to_hsv(*self)
@property
def web(self):
"""Convert a Color to hex RGB string (webcolor)"""
return RGB_web(*self.RGB)
@property
def name(self):
"""Return a human name for the color"""
return self.namediff()[0]
@property
def fg(self):
"""Return the ANSI escape sequence to set this color as foreground"""
return ansi.fg(*self.RGB)
@property
def bg(self):
"""Return the ANSI escape sequence to set this color as background"""
return ansi.bg(*self.RGB)
# TODO: memoize this
[docs] def luminance(self, gamma=True):
"""Compute the luminance of a color.
Returns
-------
float
A value in the range 0.0 (black) to 1.0 (white). The higher the
luminance, the brighter the color appears to the human eye. The
result is not linear for the human perception though: a luminance
twice as high does not mean twice as light. Use :meth:`lightness`
if you need a linear response.
Examples
--------
>>> print([f"{Color(c).luminance():0.2f}"
... for c in ['black','red','green','blue']])
['0.00', '0.21', '0.72', '0.05']
"""
if gamma:
r, g, b = (inv_gamma(c) for c in self)
else:
r, g, b = self
return 0.212655 * r + 0.715158 * g + 0.072187 * b
# TODO: memoize this
[docs] def lightness(self):
"""Compute the perceived lightness of a color.
Returns
-------
float
A float in the range 0.0 (black) to 1.0 (white). A higher lightness
means that the color appears brighter to the human eye.
This can for example be used to derive a good contrasting
foreground color to display text on a colored background.
Values lower than 0.5 contrast well with white, larger value
contrast better with black.
The response is linear: a color with lightness twice as high,
will appear twice as bright.
Notes
-----
Traditionally, lightness is often returned on a scale of 0 to 100.
We use 0 to 1, as it is more in line with the other color values.
"""
return lightness(self.luminance())
def __repr__(self):
return "Color" + str(self)
def __str__(self):
return '(' + ', '.join(repr(i) for i in self) + ')'
def report(self, mode='GRWN'):
with floatformat('.4f'), intformat('3'):
items = []
for c in mode.upper():
if c == 'G':
items.append(f"{self!r}")
elif c == 'R':
items.append(f"RGB={self.RGB!r}")
elif c == 'W':
items.append(f"web={self.web!r}")
elif c == 'N':
items.append(f"name={self.name!r}")
return ', '.join(items)
[docs] def rms_diff(self, color):
"""Compute a scalar difference between two colors"""
d = self.gl - color.gl
return np.sqrt((d*d).mean())
[docs] def closest_from(self, cset):
"""Find the closest color from a colorset"""
name = None
diff = np.inf
for n, c in cset.items():
d = self.rms_diff(c)
if d < diff:
name, diff = n, d
if d == 0.:
return name, diff # match: return immediately
return name, diff
[docs] def namediff(self, colorsets=('pf', 'x11')):
"""Find the closest color from all colorsets"""
name, diff = None, np.inf
for cset in colorsets:
name1, diff1 = self.closest_from(colorset(cset))
if diff1 < diff:
name, diff = name1, diff1
if diff == 0.:
break # match: return
return name, diff
[docs] @classmethod
def grey(clas, f):
"""Return a grey color"""
return clas((f, f, f))
[docs] @classmethod
def setPalette(clas, colors):
"""Set the palette with a list of colors"""
if isinstance(colors, str):
colors = Palette[colors]
clas.palette = colorArray([Color(c) for c in colors])
[docs]def colorArray(colors):
"""Create an array of colors.
When working with large collections of colors, colorArray provides
important performance benefits over the use of individual Color instances.
Parameters
----------
colors: float or str :term:`array_like`
One or more colors defined by float tuples rgb or rgba
(this includes Color instances) or by common color names.
Returns
-------
float ndarray:
A float ndarray of shape (..., 3) or (..., 4), depending on the
input.
Examples
--------
>>> colorArray('red')
array([1., 0., 0.])
>>> colorArray('grey90')
array([0.898, 0.898, 0.898])
>>> colorArray(['red', 'green', 'blue'])
array([[1. , 0. , 0. ],
[0. , 1. , 0. ],
[0.1, 0.1, 0.8]])
>>> colorArray([['red', 'green', 'blue'], ['cyan', 'magenta', 'yellow']])
array([[[1. , 0. , 0. ],
[0. , 1. , 0. ],
[0.1, 0.1, 0.8]],
[[0. , 1. , 1. ],
[1. , 0. , 1. ],
[1. , 1. , 0. ]]])
>>> colorArray((255, 128, 64))
Traceback (most recent call last):
...
pyformex.color.InvalidColor: colorArray does not accept integer input
"""
color = np.asarray(colors)
if color.dtype.kind in 'iu':
raise InvalidColor("colorArray does not accept integer input")
if color.dtype.kind != 'f':
try:
color = at.mapArray(Color, color) # Convert items to Color
except Exception as e:
raise InvalidColor(e)
if color.shape[-1] not in (3, 4):
raise InvalidColor(f"Invalid color array shape: {color.shape}")
return color.astype(np.float32)
################################
## color conversion functions ##
[docs]def gamma(c):
"""Apply gamma correction to an rgb channel
Computes the sRGB from a linearized RGB value
See Also
--------
https://en.wikipedia.org/wiki/SRGB
"""
return c*12.92 if c <= 0.04045/12.92 else 1.055 * c ** (1.0/2.4) - 0.055
[docs]def inv_gamma(c):
"""Apply inverse gamma correction to an rgb channel
This is also known as linearizing (or gamma-expanding) the channel
See Also
--------
https://en.wikipedia.org/wiki/SRGB
"""
return c/12.92 if c <= 0.04045 else ((c+0.055)/1.055) ** 2.4
[docs]def srgb_rgb(srgb):
"""Compute linear rgb from srgb"""
return tuple(inv_gamma(c) for c in srgb)
[docs]def rgb_srgb(rgb):
"""Compute srgb from linear rgb"""
return tuple(gamma(c) for c in rgb)
[docs]def rgb_xyz(rgb):
"""Convert RGB to CIEXYZ
The rgb values are the linearized rgb values
See Also
--------
https://en.wikipedia.org/wiki/SRGB
"""
# these numbers are correct on 4 decimals!
M = np.array(((0.4124, 0.3576, 0.1805),
(0.2126, 0.7152, 0.0722),
(0.0193, 0.1192, 0.9505),))
return tuple(M @ rgb)
[docs]def xyz_rgb(xyz):
"""Convert CIEXYZ to linear RGB
Notes
-----
Y is the luminance
See Also
--------
https://en.wikipedia.org/wiki/SRGB
"""
M = np.array(((+3.2406255, -1.5372080, -0.4986286),
(-0.9689307, +1.8757561, +0.0415175),
(+0.0557101, -0.2040211, +1.0569959),))
return tuple(M * np.array(xyz))
[docs]def xyz_lab(xyz):
"""Convert CIEXYZ to CIELAB
Notes
-----
Lstar is the perceived lightness
See Also
--------
https://en.wikipedia.org/wiki/CIELAB_color_space
"""
def f(t):
return t ** (1/3) if t > 216/24389 else t / (108/841) + 4/29
X, Y, Z = xyz
Lstar = 116 * f(Y) - 16
astar = 550 * (f(X) - f(Y))
bstar = 200 * (f(Y) - f(Z))
return (Lstar, astar, bstar)
[docs]def luminance(color, gamma=True):
"""Compute the luminance of a color.
Returns
-------
float | float array
A value in the range 0.0 (black) to 1.0 (white). The higher the
luminance, the brighter the color appears to the human eye. The
result is not linear for the human perception though: a luminance
twice as high does not mean twice as light. Use :func:`lightness`
to map luminance to perceived lightness.
Examples
--------
>>> luminance(red)
0.2126
>>> luminance(['black','red','green','blue'])
array([0. , 0.2126, 0.7152, 0.0529])
>>> luminance([black, mediumgrey, lightgrey, white])
array([0. , 0.3185, 0.6038, 1. ])
"""
color = colorArray(color)
if gamma:
color = np.where(color > 0.04045,
((color+0.055)/1.055) ** 2.4, color/12.92)
R, G, B = color[..., 0], color[..., 1], color[..., 2]
return 0.2126 * R + 0.7152 * G + 0.0722 * B
[docs]def lightness(Y):
"""Compute perceived lightness L* from linear luminance between 0.0 and 1.0
Returns
-------
float
A float in the range 0.0 (black) to 1.0 (white).
Examples
--------
>>> lightness(luminance(red))
array(0.5323)
>>> lightness(luminance(['black','red','green','blue']))
array([0. , 0.5323, 0.8774, 0.2754])
>>> lightness(luminance([black,red,green,blue]))
array([0. , 0.5323, 0.8774, 0.2754])
"""
return np.where(Y <= 216/24389, Y * (243.89/27), Y ** (1/3) * 1.16 - 0.16)
[docs]def rgb_RGB(rgb):
"""Convert float rgb values to int255"""
return tuple(int(round(c * 255)) for c in rgb)
[docs]def RGB_rgb(RGB):
"""Convert int255 rgb values to float"""
return tuple(c / 255 for c in RGB)
[docs]def web_RGB(color):
"""Convert a webcolor '#RRGGBB' or '#RGB' to (R,G,B) tuple
Examples
--------
>>> web_RGB('#ff6633')
(255, 102, 51)
>>> web_RGB('#af3')
(170, 255, 51)
"""
if len(color) == 7:
return tuple(int(color[i:i+2], 16) for i in range(1, 6, 2))
elif len(color) == 4:
return tuple(int(color[i:i+1] * 2, 16) for i in range(1, 4))
else:
raise InvalidColor(
"Webcolor should have format #RGB or #RRGGBB")
[docs]def RGB_web(R, G, B):
"""Convert a tuple(R,G,B) to hex RGB string (webcolor)
Examples
--------
>>> RGB_web(255, 128, 64)
'#ff8040'
"""
return "#%02x%02x%02x" % (R, G, B)
###############################
## Sets of named colors ##
[docs]def read_webcolors(filename):
"""Read a webcolors file
A webcolors file is a text file where each line contains two
strings: a webcolor string in the format #RRGGBB, and a common
name for the color. Lines not starting with a '#' are ignored.
Returns
-------
dict
A dict with the color names as keys and the webcolors
as values.
"""
colordict = {}
with open(filename) as fil:
for line in fil:
if line[0] == '#':
webcolor, name = line.split()
colordict[name] = Color(webcolor)
return colordict
def _color_file(cset):
"""Return the filename for a colorname colorset"""
return pf.cfg['datadir'] / f"webcolors_{cset}.txt"
[docs]def colorsets():
"""Return names of available colorsets
>>> colorsets()
('pf', 'x11', 'xkcd')
"""
return tuple(_COLORSETS_.keys())
[docs]def colorset(cset):
"""Return a colorset
This provides lazy loading of the colorsets.
They are only loaded when used.
>>> d = colorset('x11')
>>> type(_COLORSETS_['x11'])
<class 'dict'>
"""
if not isinstance(cset, str):
raise ValueError(f"No such colorset: {cset}")
cset = _COLORSETS_[cset]
if isinstance(cset, str):
# lazy load the colorset
colors = read_webcolors(_color_file(cset))
_COLORSETS_[cset] = cset = colors
return cset
[docs]def colornames(cset=None):
"""Return a list of color names in specified colorset(s).
If cset is str: return pure names
If cset is list of str: return prefixed names
If cset is None: cset = [all colorsets]
>>> colornames('x11')[0]
'aliceblue'
>>> colornames('pf')[0]
'darkgrey'
>>> colornames(['pf', 'x11'])[0]
'pf:darkgrey'
"""
if isinstance(cset, str):
names = _COLORSETS_.get(cset, {}).keys()
else:
if cset is None:
cset = _COLORSETS_.keys()
names = (f"{c}:{name}" for c in cset for name in _COLORSETS_.get(c, {}))
return list(names)
[docs]def named_color(name):
"""Find the webcolor value for the named color
Parameters
----------
name: str
The common name for the color, e.g. 'darkgreen'.
The name may be prefixed by the colorset where the name
is to be found. Known colorsets are 'pf', 'x11', 'xkcd'.
A colorset string part must be followed by a ':'. Thus,
'x11:darkgreen' is the dark green from the x11 colorset.
Multiple colorsets can be specified, like 'xkcd:x11:darkgreen',
and will be looked up in that order until the first match.
No prefix is equivalent to 'pf:x11:xkcd:'.
Returns
-------
str | None
A string with the webcolor designation of the named color or
None if no color with that name is found.
Examples
--------
>>> named_color('darkgreen')
Color(0.0, 0.5, 0.0)
>>> named_color('pf:darkgreen')
Color(0.0, 0.5, 0.0)
>>> named_color('x11:darkgreen')
Color(0.0, 0.3921..., 0.0)
>>> named_color('xkcd:darkgreen')
Color(0.01960..., 0.2862..., 0.02745...)
>>> named_color('banana')
Color(1.0, 1.0, 0.4941...)
>>> named_color('x11:banana') # no such color
"""
*colorsets, name = name.split(':')
if not colorsets:
colorsets = _COLORSETS_.keys()
for cset in colorsets:
color = colorset(cset).get(name, None)
if color is not None:
return color
########################################
## utilities for creating color files ##
[docs]def download_file(url, filename=None):
"""Download a file from a given url"""
from urllib.request import urlretrieve
filename, headers = urlretrieve(url)
return filename
def _read_xkcd_colors(filename):
"""Read the XKCD colors rgb.txt file
Returns a dict where key is a normalized color name and value is
a webcolor string #rrggbb.
"""
colordict = {}
with open(filename) as f:
for line in f:
if line[0] in '!#':
continue
try:
*name, value = line.strip().split()
name = normalize_color_name(''.join(name))
# if name in colordict:
# continue
colordict[name] = value
except Exception as e:
print(line)
print(e)
return colordict
def _read_x11_colors(filename):
"""Read the X11 colors rgb.txt file"""
colordict = {}
with open(filename) as f:
for line in f:
if line[0] in '!#':
continue
try:
r, g, b, *name = line.strip().split()
name = normalize_color_name(''.join(name))
# if name in colordict:
# continue
colordict[name] = RGB_web(int(r), int(g), int(b))
except Exception as e:
print(line)
print(e)
pass
return colordict
def _dump_webcolors(colordict, filename, origin):
"""Dump a colordict on file in compact format"""
with open(filename, 'w') as fout:
fout.write(f"! created by pyFormex from {origin}\n")
for c in sorted(colordict.items(), key=lambda c: c[0]):
fout.write(f"{c[1]} {c[0]}\n")
print(f"Saved {len(colordict)} colors to {filename}")
def _create_color_files():
"""Create the x11 and xbbd color files"""
# x11
filename = '/etc/X11/rgb.txt'
colors = _read_x11_colors(filename)
_dump_webcolors(colors, _color_file('x11'), filename)
# xkcd
url = 'https://xkcd.com/color/rgb.txt'
filename = download_file(url)
colors = _read_xkcd_colors(filename)
_dump_webcolors(colors, _color_file('xkcd'), url)
# predefined pyFormex colors
PF_COLORS = {
'darkgrey' : Color.grey(0.4), # noqa: E203
'red' : Color(1.0, 0.0, 0.0), # noqa: E203
'green' : Color(0.0, 1.0, 0.0), # noqa: E203
'blue' : Color(0.1, 0.1, 0.8), # noqa: E203
'cyan' : Color(0.0, 1.0, 1.0), # noqa: E203
'magenta' : Color(1.0, 0.0, 1.0), # noqa: E203
'yellow' : Color(1.0, 1.0, 0.0), # noqa: E203
'white' : Color.grey(1.0), # noqa: E203
'black' : Color.grey(0.0), # noqa: E203
'darkred' : Color(0.6, 0.0, 0.0), # noqa: E203
'darkgreen' : Color(0.0, 0.5, 0.0), # noqa: E203
'darkblue' : Color(0.1, 0.2, 0.6), # noqa: E203
'darkcyan' : Color(0.0, 0.6, 0.6), # noqa: E203
'darkmagenta' : Color(0.6, 0.0, 0.6), # noqa: E203
'darkyellow' : Color(0.6, 0.6, 0.0), # noqa: E203
'lightgrey' : Color.grey(0.8), # noqa: E203
'mediumgrey' : Color.grey(0.6), # noqa: E203
'pyformex_pink' : Color(1.0, 0.2, 0.4), # noqa: E203
'orange' : Color(1.0, 0.5, 0.0), # noqa: E203
'purple' : Color(0.6, 0.2, 1.0), # noqa: E203
'brown' : Color(0.6, 0.4, 0.2), # noqa: E203
'gold' : Color(1.0, 0.8, 0.0), # noqa: E203
'orchid' : Color(0.8, 0.4, 0.9), # noqa: E203
'lightlightgrey' : Color.grey(0.9), # noqa: E203
}
_COLORSETS_ = {
'pf': PF_COLORS,
'x11': 'x11',
'xkcd': 'xkcd',
}
# make the PF-colors available as names
globals().update(PF_COLORS) # in the module
pf_palette = tuple(PF_COLORS.keys())
Palette = {
'default': pf_palette,
'dark': tuple(c for c in pf_palette if Color(c).lightness() < 0.5),
'light': tuple(c for c in pf_palette if Color(c).lightness() >= 0.5)
}
# Set the default Color.palette
# Color.palette = colorArray(list(PF_COLORS.values()))
Color.setPalette(PF_COLORS.values())
# TODO: can we remove some of these?
palette = Color.palette
grey = Color.grey
#############################
## ANSI escape color codes ##
[docs]class ansi:
"""Ansi escape sequences for colors
Contains attributes: reset, bold, dim, underline, reverse, invisible,
crossout and erase_eol. Furthermore, there are two static functions
to set foreground and background color: fg and bg, both taking
a triple R,G,B values (0..255) as parameters.
"""
reset = '\033[0m'
bold = '\033[01m'
dim = '\033[02m'
underline = '\033[04m'
reverse = '\033[07m'
invisible = '\033[08m'
crossout = '\033[09m'
# default_fg = '\033[39m'
# default_bg = '\033[49m'
erase_eol = '\033[0K'
reset_all = reset + erase_eol
[docs] @staticmethod
def fg(r, g, b):
"""Return the ANSI escape sequence to set a foreground color r,g,b"""
return f"\033[38;2;{r};{g};{b}m"
[docs] @staticmethod
def bg(r, g, b):
"""Return the ANSI escape sequence to set a background color r,g,b"""
return f"\033[48;2;{r};{g};{b}m"
[docs]def printc(*args, sep=' ', end='\n', file=None, flush=False,
color=None, bgcolor=None):
"""Print in color on an ANSI terminal
This is functionally equivalent with Python's builtin print function,
but has two extra parameters that allow printing in color on an ANSI
compatible terminal. When the file parameter is used, color is disabled.
When color is used, flush is forced to True.
Parameters
----------
color: :term:`color_like`
The color to be used for the displayed text
bgcolor: :term:`color_like`
The backgropund color to be used for the displayed text.
"""
if file:
color = bgcolor = None
if color or bgcolor:
output = utils.sprint(*args, sep=sep, end='', flush=True)
bg = Color(bgcolor).bg if bgcolor else ''
fg = Color(color).fg if color else ''
print(bg, fg, output, ansi.reset_all, sep='', end='\n', flush=True)
else:
print(*args, sep=sep, end=end, file=file, flush=flush)
############################
## deprecated functions ##
import functools # noqa: E402
deprecated = functools.partial(utils.deprecated_by, replace='Color')
[docs]@deprecated()
def GLcolor(color):
"""Convert color input to a tuple (r,g,b)
This is like Color(color), but invalid colors return (0., 0., 0.)
"""
try:
return Color(color).rgb
except InvalidColor:
return (0., 0., 0.)
[docs]@deprecated()
def GLcolor4(color, alpha=0.5):
"""Like GLcolor with alpha.
"""
try:
color = Color(color)
return tuple(color.rgba)
except InvalidColor as e:
# probably a 4 component color?
color = np.asarray(color)
if color.dtype.kind in 'ui':
color = color / 255
if len(color) == 4:
return tuple(color)
else:
raise e
[docs]@deprecated()
def RGBcolor(color):
"""Return an RGB (0-255) array for a color"""
return uarray(Color(color).RGB)
[docs]@deprecated()
def RGBAcolor(color):
"""Return an RGBA (0-255) array for a color."""
return uarray(Color(color).RGBA)
[docs]@deprecated()
def WEBcolor(color):
"""Return the web color name for a color"""
return Color(color).web
colorName = WEBcolor
[docs]@deprecated()
def GREY(val, alpha=1.0):
"""Returns a grey OpenGL color of given intensity (0..1)"""
return Color.grey(val).astuple() + (alpha,)
# End