"""
color conversion in various colorspaces and palettes
"""
__author__ = "Philippe Guglielmetti"
__copyright__ = "Copyright 2012-, Philippe Guglielmetti"
__license__ = "LGPL"
__credits__ = ['Colormath https://pypi.python.org/pypi/colormath/',
'Bruno Nuttens Pantone color table http://blog.brunonuttens.com/206-conversion-couleurs-pantone-lab-rvb-hexa-liste-sql-csv/',
]
# get https://pypi.python.org/pypi/colormath/ if you need more
import os
import sys
import logging
import numpy
import networkx
from collections import OrderedDict
from Goulib import math2, itertools2, image, graph
import Goulib.table as Gtable
# color conversion
# redefine some converters in current module to build converters dict below
import skimage.color as skcolor
import matplotlib.colors as mplcolors
[docs]def rgb2hex(c, illuminant='ignore'):
return mplcolors.rgb2hex(c)
[docs]def hex2rgb(c, illuminant='ignore'):
return mplcolors.hex2color(c)
[docs]def rgb2cmyk(rgb, **kwargs):
"""
:param rgb: 3-tuple of floats of red,green,blue in [0..1] range
:return: 4-tuple of floats (cyan, magenta, yellow, black) in [0..1] range
"""
# http://stackoverflow.com/questions/14088375/how-can-i-convert-rgb-to-cmyk-and-vice-versa-in-python
r, g, b = rgb
c = 1 - r
m = 1 - g
y = 1 - b
k = min(c, m, y)
if k == 1:
return (0, 0, 0, 1)
c = (c - k) / (1 - k)
m = (m - k) / (1 - k)
y = (y - k) / (1 - k)
return (c, m, y, k)
[docs]def cmyk2rgb(cmyk, **kwargs):
"""
:param cmyk: 4-tuple of floats (cyan, magenta, yellow, black) in [0..1] range
:result: 3-tuple of floats (red,green,blue)
warning : rgb is out the [0..1] range for some cmyk
"""
c, m, y, k = cmyk
w = 1 - k
return ((1 - c) * w, (1 - m) * w, (1 - y) * w)
[docs]def xyz2xyy(xyz, **kwargs):
"""
Convert from XYZ to xyY
Based on formula from http://brucelindbloom.com/Eqn_XYZ_to_xyY.html
Implementation Notes:
1. Watch out for black, where X = Y = Z = 0. In that case, x and y are set
to the chromaticity coordinates of the reference whitepoint.
2. The output Y value is in the nominal range [0.0, Y[XYZ]].
"""
s = sum(xyz)
if s == 0:
# We can't check for X == Y == Z == 0 because they may actually add up
# to 0, thus resulting in ZeroDivisionError later
x, y, _ = xyz2xyy(color['white'].xyz)
return (x, y, 0.0)
return (xyz[0] / s, xyz[1] / s, xyz[1])
[docs]def xyy2xyz(xyY, **kwargs):
"""
Convert from xyY to XYZ to
Based on formula from http://brucelindbloom.com/Eqn_xyY_to_XYZ.html
Implementation Notes:
1. Watch out for the case where y = 0.
In that case, you may want to set X = Y = Z = 0.
2. The output XYZ values are in the nominal range [0.0, 1.0].
"""
x, y, Y = xyY
if y == 0:
return (0, 0, 0)
X = x * Y / y
Z = (1 - x - y) * Y / y
return (X, Y, Z)
# skimage.color has several useful color conversion routines, but for images
# so here is a generic adapter that allows to call them with colors
def _skadapt(f, **kwargs):
def adapted(arr, **kwargs):
arr = numpy.asanyarray(arr)
if arr.ndim == 1:
a = arr.reshape(1, 1, arr.shape[-1])
try:
res = f(a, **kwargs)
except TypeError: # unsupported params. retry without
res = f(a)
return res.reshape(arr.shape[-1])
else:
return f(arr, **kwargs)
return adapted
# supported colorspaces. need more ? just add it :-)
colorspaces = (
'CMYK',
'XYZ',
'xyY', # for CIE Chromaticity plots
'Lab',
'Luv',
'HSV',
'RGB',
'HEX',
)
# build a graph of available converters
# as in https://github.com/gtaylor/python-colormath
# a nx.DiGraph() would suffice, but my DiGraph are better
converters = graph.DiGraph(multi=False)
for source in colorspaces:
for target in colorspaces:
key = (source.lower(), target.lower())
if key[0] == key[1]:
continue
else:
convname = '%s2%s' % key
converter = getattr(sys.modules[__name__], convname, None)
if converter is None:
converter = getattr(skcolor, convname, None)
if converter: # adapt it:
converter = _skadapt(converter)
if converter:
converters.add_edge(key[0], key[1], f=converter)
[docs]def convert(color, source, target):
"""convert a color between colorspaces,
eventually using intermediary steps
"""
source, target = source.lower(), target.lower()
if source == target:
return color
path = converters.shortest_path(source, target)
for u, v in itertools2.pairwise(path):
color = converters[u][v][0]['f'](color)
return color # isn't it beautiful ?
[docs]class Color(object):
"""A color with math operations and conversions
Color is immutable (._values caches representations)
"""
[docs] def __init__(self, value, space='RGB', name=None, illuminant='D65'):
"""constructor
:param value: string color name, hex string, or values tuple
:param space: string defining the color space of value
:param name: string for color name
:param illuminant: string in {“A”, “D50”, “D55”, “D65”, “D75”, “E”}
* D65 is used by default in skimage, see http://scikit-image.org/docs/dev/api/skimage.color.html
* D50 is used in Pantone and other graphic arts
"""
self._name = name
self.illuminant = illuminant
space = space.lower() # for easier conversions
if isinstance(value, Color): # copy constructor
self._copy_from_(value)
return
if isinstance(value, str):
if value in pantone:
self._copy_from_(pantone[value])
return
value = value.lower()
if value in color:
self._copy_from_(color[value])
return
elif len(value) == 7 and value[0] == '#':
if value in color_lookup:
self._copy_from_(color_lookup[value])
return
else:
space = 'hex'
else:
raise(ValueError("Couldn't create Color(%s,%s)" % (value, space)))
self.space = space # "native" space in which the color was created
if space == 'rgb':
if max(value) > 1:
value = tuple(_ / 255. for _ in value)
# rgb only, not whiter than white...
value = math2.sat(value[:3], 0, 1)
if space != 'hex': # force to floats
value = tuple(float(_) for _ in value)
self._values = OrderedDict() # so native space is always first
self._values[space] = value
def _copy_from_(self, c):
self.space = c.space
self.illuminant = c.illuminant
self._name = c._name
self._values = c._values
@property
def name(self):
if self._name is None:
if self.hex in color_lookup:
self._name = color_lookup[self.hex].name
else:
self._name = '~' + nearest_color(self).name
return self._name
[docs] def convert(self, target, **kwargs):
"""
:param target: str of desired colorspace, or none for default
:return: color in target colorspace
"""
target = target.lower() if target else self.space
if target not in self._values:
try:
path = converters.shortest_path(self.space, target)
except networkx.exception.NetworkXNoPath:
raise NotImplementedError(
'no conversion between %s and %s color spaces'
% (self.space, target)
)
# to avoid incoherent cached values
kwargs['illuminant'] = self.illuminant
for u, v in itertools2.pairwise(path):
if v not in self._values:
edge = converters[u][v][0]
c = edge['f'](self._values[u], **kwargs)
if itertools2.isiterable(c): # but not a string
c = tuple(map(float, c))
self._values[v] = c
return self._values[target]
[docs] def str(self, mode=None):
res = self.convert(mode)
if not isinstance(res, str):
res = ', '.join(map(math2.format, res))
return res
@property
def native(self):
return self.convert(None)
@property
def rgb(self):
return self.convert('rgb')
@property
def hex(self):
return self.convert('hex')
@property
def lab(self):
return self.convert('lab')
@property
def luv(self):
return self.convert('Luv')
@property
def cmyk(self):
return self.convert('cmyk')
@property
def hsv(self):
return self.convert('hsv')
@property
def xyz(self):
return self.convert('xyz')
@property
def xyY(self):
return self.convert('xyY')
[docs] def __hash__(self):
return hash(self.hex)
[docs] def __repr__(self):
return "Color('%s')" % (self.name)
def _repr_html_(self):
return '<span style="color:%s">%s</span>' % (self.hex, self.name)
[docs] def compose(self, other, f, mode='rgb'):
""" compose colors in given mode
"""
if not isinstance(other, Color):
other = Color(other, mode)
res = f(self.convert(mode), other.convert(mode))
min = -1 if mode == 'lab' else 0
max = 1
res = [math2.sat(_, min, max) for _ in res]
return res
[docs] def __add__(self, other):
if isinstance(other, image.Image):
return image.Image(size=other.size, color=self.native, mode=self.space) + other
return Color(self.compose(other, math2.vecadd), illuminant=self.illuminant)
[docs] def __radd__(self, other):
"""only to allow sum(colors) easily"""
assert other == 0
return self
[docs] def __sub__(self, other):
if isinstance(other, image.Image):
mode = other.mode
return image.Image(size=other.size, color=self.convert(mode), mode=mode) - other
return Color(self.compose(other, math2.vecsub), illuminant=self.illuminant)
[docs] def __mul__(self, factor):
if factor < 0:
return (-self) * (-factor)
l, a, b = self.lab
l *= factor
res = Color((l, a, b), 'lab', illuminant=self.illuminant)
return res
[docs] def __neg__(self):
""" complementary color"""
return color['white'] - self
[docs] def deltaE(self, other):
"""color difference according to CIEDE2000
https://en.wikipedia.org/wiki/Color_difference
"""
assert(self.illuminant == other.illuminant)
return skcolor.deltaE_ciede2000(self.lab, other.lab)
[docs] def isclose(self, other, abs_tol=1):
"""
http://zschuessler.github.io/DeltaE/learn/
<= 1.0 Not perceptible by human eyes.
1 - 2 Perceptible through close observation.
2 - 10 Perceptible at a glance.
11 - 49 Colors are more similar than opposite
100 Colors are exact opposite
"""
dE = self.deltaE(other)
if dE <= abs_tol:
return True
else:
return False
[docs] def __eq__(self, other):
other = Color(other)
if self.space == other.space:
if self.native == other.native:
return True
# difference not perceptible to human eye
return self.isclose(other, 1)
[docs]class Palette(OrderedDict):
"""dict of Colors indexed by anything"""
[docs] def __init__(self, data=[], keys=256):
# mandatory http://stackoverflow.com/questions/11174702/how-to-subclass-an-ordereddict
super(Palette, self).__init__()
if data:
self.update(data, keys)
[docs] def update(self, data, keys=256):
"""updates the dictionary with new colors
:param data: colors to add
:param keys: keys to use in dict, or int to discretize the Colormap
"""
if isinstance(data, mplcolors.Colormap):
for i in range(keys):
self[i] = Color(data(i / (keys - 1))) # RGB
elif isinstance(keys, int):
for i, v in itertools2.enumerates(data):
self[i] = Color(v) # v.space of RGB
else:
for i, v in zip(keys, data):
self[i] = Color(v) # v.space of RGB
return self
[docs] def index(self, c, dE=5):
"""
:return: key of c or nearest color, None if distance is larger than deltaE
"""
c = Color(c)
k, v = itertools2.index_min(self, key=lambda c2: deltaE(c, c2))
if k is None or (dE > 0 and deltaE(c, v) > dE):
return None
return k
[docs] def __repr__(self):
return '%s of %d colors' % (self.__class__.__name__, len(self))
def _repr_html_(self):
def tooltip(k):
c = self[k]
res = '[%s] %s (%s)\n' % (k, c.name, c.illuminant)
return res + '\n'.join('%s = %s' % (k, c.str(k)) for k in c._values)
mode = 'inline' if len(self) > 256 else 'flex'
labels = (color['black'], color['white']) # possible colors for labels
res = '<div style="display:%s; width:100%%;">' % mode
style = 'display:%s-block; min-width: 1px; ' % mode
style += ' flex-basis: 90%%;'
style += ' background:%s; color:%s;'
cell = '<div style="' + style + '" title="%s"> </div>'
for k in self:
c = self[k]
# c2=nearest_color(c,labels,opt=max) #chose the label color with max difference to pantone color
res += cell % (c.hex, c.hex, tooltip(k))
return res + '</div>'
[docs] def patches(self, wide=64, size=(16, 16)):
"""Image made of each palette color
"""
n = len(self)
data = itertools2.reshape(range(n), (n // wide, wide))
res = image.Image(data, 'P', palette=self)
res = res.scale(size)
return res
@property
def pil(self):
"""
:return: a sequence of integers, or a string containing a binary
representation of the palette.
In both cases, the palette contents should be ordered (r, g, b, r, g, b, …).
The palette can contain up to 768 entries (3*256).
If a shorter palette is given, it is padded with zeros.
#http://effbot.org/zone/creating-palette-images.htm
"""
res = []
for c in self.values():
r, g, b = c.rgb
res.append(math2.rint(r * 255))
res.append(math2.rint(g * 255))
res.append(math2.rint(b * 255))
return res
[docs] def sorted(self, key=lambda c: c[1].lab[0]):
# http://stackoverflow.com/questions/8031418/how-to-sort-ordereddict-of-ordereddict-python
return Palette(dict(sorted(self.items(), key=key)))
[docs]def ColorTable(colors, key=None, width=10):
def tooltip(c):
return '\n'.join('%s = %s' % (k, v) for k, v in c._values.items())
labels = (color['black'], color['white']) # possible colors for labels
t = []
colors = colors.values()
if key:
colors = list(colors)
colors.sort(key=key)
for c in colors:
# chose the label color with max difference to pantone color
c2 = nearest_color(c, labels, opt=max)
s = '<span title="%s" style="color:%s">%s</span>' % (
tooltip(c), c2.hex, c.name)
t.append(Gtable.Cell(s, style={'background-color': c.hex}))
return Gtable.Table(itertools2.reshape(t, (0, width)))
# dictionaries of standardized colors
path = os.path.dirname(os.path.abspath(__file__))
# http://blog.brunonuttens.com/206-conversion-couleurs-pantone-lab-rvb-hexa-liste-sql-csv/
table = Gtable.Table(path + '/colors.csv')
table.applyf('hex', lambda x: x.lower())
table = table.groupby('System')
color = Palette() # dict of HTML / matplotlib colors, which seem to be the same
color_lookup = Palette() # reverse color dict indexed by hex
pantone = Palette() # dict of pantone colors
# http://www.w3schools.com/colors/colors_names.asp
for _ in table['websafe'].asdict():
id = _['name'].lower()
hex = _['hex']
_ = Color(hex, name=id, illuminant='D65')
color[id] = _
color_lookup[_.hex] = _
for _ in table['Pantone'].asdict():
id = _['name']
pantone[id] = Color((_['L'], _['a'], _['b']),
space='Lab', name=id, illuminant='D50')
# pantones are defined with D50 illuminant
acadcolors = [None] * 256 # table of Autocad indexed colors
for _ in table['autocad'].asdict():
id = _['name']
# color name is a 0..255 number
acadcolors[id] = Color(_['hex'], name=id, illuminant='D65')
[docs]def color_to_aci(x, nearest=True):
"""
:return: int Autocad Color Index of color x
"""
if x is None:
return -1
x = Color(x)
try:
return acadcolors.index(x)
except ValueError:
pass
if nearest:
return nearest_color(x, acadcolors).name # name = int id
return -1
[docs]def aci_to_color(x, block_color=None, layer_color=None):
if x == 0:
return block_color
if x == 256:
return layer_color
return acadcolors[x].hex
[docs]def deltaE(c1, c2):
# http://scikit-image.org/docs/dev/api/skimage.color.html#skimage.color.deltaE_ciede2000
if not isinstance(c1, Color):
c1 = Color(c1)
if not isinstance(c2, Color):
c2 = Color(c2)
return skcolor.deltaE_ciede2000(c1.lab, c2.lab)
[docs]def nearest_color(c, l=None, opt=min, comp=deltaE):
"""
:param x: Color
:param l: list or dict of Color, color by default
:param opt: with opt=max you can find the most different color ...
:return: nearest Color of x in l
"""
if not isinstance(c, Color):
c = Color(c)
l = l or color
if isinstance(l, dict):
l = l.values()
return opt(l, key=lambda c2: comp(c, c2))
# http://stackoverflow.com/questions/876853/generating-color-ranges-in-python
[docs]def color_range(n, start, end, space='hsv'):
""":param n: int number of colors to generate
:param start: string hex color or color name
:param end: string hex color or color name
:result: list of n Color interpolated between start and end, included
"""
start = Color(start).convert(space)
end = Color(end).convert(space)
return [Color(v, space=space) for v in itertools2.linspace(start, end, n)]
"""some (astro) physics calculators"""
_cmf = None
[docs]def blackBody2Color(tempK):
""":param wavelength: black body temperature in K (Sun is 5780)
:result: Color
"""
from scipy.constants import h, c, k
global _cmf
if _cmf is None:
_cmf=numpy.loadtxt(path + '/cie-cmf.txt', usecols=(1,2,3))
# http://www.vendian.org/mncharity/dir3/blackbody/
# https://scipython.com/blog/converting-a-spectrum-to-a-colour/#rating-26
def planck(lam, T):
""" Returns the spectral radiance of a black body at temperature T.
Returns the spectral radiance, B(lam, T), in W.sr-1.m-2 of a black body
at temperature T (in K) at a wavelength lam (in nm), using Planck's law.
"""
lam_m = lam / 1.e9
fac = h * c / lam_m / k / T
B = 2 * h * c ** 2 / lam_m ** 5 / (numpy.exp(fac) - 1)
return B
def spec_to_xyz(spec):
"""Convert a spectrum to an xyz point.
The spectrum must be on the same grid of points as the colour-matching
function, self.cmf: 380-780 nm in 5 nm steps.
"""
XYZ = numpy.sum(spec[:, numpy.newaxis] * _cmf, axis=0)
den = numpy.sum(XYZ)
if den == 0.:
return XYZ
return XYZ / den
# The grid of visible wavelengths corresponding to the grid of colour-matching
# functions used by the ColourSystem instance.
lam = numpy.arange(380., 781., 5)
return Color(spec_to_xyz(planck(lam,tempK)),'xyz')
[docs]def lambda2RGB(w):
""":param w: float wavelength in nanometers (between 380 and 780)
:result: [R,G,B] float
"""
# http://codingmess.blogspot.com/2009/05/conversion-of-nm-in-nanometers.html
# colour
if w >= 380 and w < 440:
R = -(w - 440.) / (440. - 350.)
G = 0.0
B = 1.0
elif w >= 440 and w < 490:
R = 0.0
G = (w - 440.) / (490. - 440.)
B = 1.0
elif w >= 490 and w < 510:
R = 0.0
G = 1.0
B = -(w - 510.) / (510. - 490.)
elif w >= 510 and w < 580:
R = (w - 510.) / (580. - 510.)
G = 1.0
B = 0.0
elif w >= 580 and w < 645:
R = 1.0
G = -(w - 645.) / (645. - 580.)
B = 0.0
elif w >= 645 and w <= 780:
R = 1.0
G = 0.0
B = 0.0
else:
R = 0.0
G = 0.0
B = 0.0
# intensity correction
if w >= 380 and w < 420:
SSS = 0.3 + 0.7 * (w - 350) / (420 - 350)
elif w >= 420 and w <= 700:
SSS = 1.0
elif w > 700 and w <= 780:
SSS = 0.3 + 0.7 * (780 - w) / (780 - 700)
else:
SSS = 0.0
SSS *= 255
return [int(SSS * R), int(SSS * G), int(SSS * B)]
[docs]def RGB2lambda(R, G, B):
# http://codingmess.blogspot.com/2010/02/transforming-rgb-data-to-wavelength.html
"""Returns 0 if indeciferable"""
# selects range by maximum component
# if max is blue - range is 380 - 489
# if max is green - range is 490 - 579
# if max is red - range is 580 - 645
# which colour has highest intensity?
high = float(R)
highind = 1
if G > high:
high = float(G)
highind = 2
if B > high:
high = float(B)
highind = 3
# normalize highest to 1.0
RGBnorm = [R / high, G / high, B / high]
# start decoding
RGBlambda = 0
if highind == 1: # red is highest
if B >= G: # there is more blue than green
return 0 # max red and more blue than green shouldn't happen
# wavelength linearly changes from 645 to 580 as green increases
RGBlambda = 645 - RGBnorm[1] * (645. - 580.)
elif highind == 2: # green is max, range is 510 - 579
if R > B: # range is 510 - 579
RGBlambda = 510 + RGBnorm[0] * (580 - 510)
else: # blue is higher than red, range is 490 - 510
RGBlambda = 510 - RGBnorm[2] * (510 - 490)
elif highind == 3: # blue is max, range is 380 - 490
if G > R: # range is 440 - 490
RGBlambda = RGBnorm[1] * (490 - 440) + 440
else: # there is more red than green, range is 380 - 440
RGBlambda = 440 - RGBnorm[0] * (440 - 380)
return RGBlambda