Source code for Goulib.colors

#!/usr/bin/env python
# coding: utf8
color conversion in various colorspaces and palettes

from __future__ import division #"true division" everywhere

__author__ = "Philippe Guglielmetti"
__copyright__ = "Copyright 2012-, Philippe Guglielmetti"
__license__ = "LGPL"
__credits__ = ['Colormath',
               'Bruno Nuttens Pantone color table',

#get if you need more

import six, os, sys, logging
import numpy as np

from collections import OrderedDict
from Goulib import math2, itertools2

# 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 """ # 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 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 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 = np.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 from .graph import DiGraph converters=DiGraph(multi=False) # a nx.DiGraph() would suffice, but my DiGraph are better 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 * 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, six.string_types): 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))) # "native" space in which the color was created if space=='rgb': if max(value)>1: value=tuple(_/255. for _ in value) value=math2.sat(value[:3],0,1) # rgb only, not whiter than white... 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.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 """ import networkx as nx target=target.lower() if target else if target not in self._values: try: path=converters.shortest_path(, target) except nx.exception.NetworkXNoPath: raise NotImplementedError( 'no conversion between %s and %s color spaces' %(, target) ) kwargs['illuminant']=self.illuminant # to avoid incoherent cached values 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, six.string_types): 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')"%(
def _repr_html_(self): return '<span style="color:%s">%s</span>'%(self.hex,
[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): from Goulib.image import Image if isinstance(other, Image): return Image(size=other.size,color=self.native, 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): from Goulib.image import Image if isinstance(other, Image): mode=other.mode return 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 """ assert(self.illuminant==other.illuminant) return skcolor.deltaE_ciede2000(self.lab, other.lab)
[docs] def isclose(self,other,abs_tol=1): """ <= 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 if self.native==other.native: return True return self.isclose(other,1) #difference not perceptible to human eye
[docs]class Palette(OrderedDict): """dict of Colors indexed by anything"""
[docs] def __init__(self, data=[], keys=256): super(Palette, self).__init__() #mandatory 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 """ from matplotlib.colors import Colormap if isinstance(data, Colormap): for i in range(keys): self[i]=Color(data(i/(keys-1))) #RGB elif isinstance(keys, six.integer_types): for i,v in itertools2.enumerates(data): self[i]=Color(v) # of RGB else: for i,v in,data): self[i]=Color(v) # 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.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">&nbsp;</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 """ from Goulib.image import Image n=len(self) data=itertools2.reshape(range(n),(n//wide,wide)) res=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. # """ 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]): # return Palette(dict(sorted(self.items(), key=key)))
[docs]def ColorTable(colors,key=None,width=10): from Goulib.table import Table, Cell from Goulib.itertools2 import reshape 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: c2=nearest_color(c,labels,opt=max) #chose the label color with max difference to pantone color s='<span title="%s" style="color:%s">%s</span>'%(tooltip(c),c2.hex, t.append(Cell(s,style={'background-color':c.hex})) return Table(reshape(t,(0,width)))
# dictionaries of standardized colors from Goulib.table import Table path=os.path.dirname(os.path.abspath(__file__)) # table=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 # for c in table['websafe'].asdict(): id=c['name'].lower() hex=c['hex'] c=Color(hex,name=id,illuminant='D65') color[id]=c color_lookup[c.hex]=c for c in table['Pantone'].asdict(): id=c['name'] pantone[id]=Color((c['L'],c['a'],c['b']),space='Lab',name=id,illuminant='D50') # pantones are defined with D50 illuminant acadcolors=[None]*256 #table of Autocad indexed colors for c in table['autocad'].asdict(): id=c['name'] acadcolors[id]=Color(c['hex'],name=id,illuminant='D65') #color name is a 0..255 number
[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): # 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))
[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 """ from .itertools2 import linspace start=Color(start).convert(space) end=Color(end).convert(space) return [Color(v, space=space) for v in linspace(start,end,n)]
[docs]def lambda2RGB(wavelength): # w = int(wavelength) # 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): # """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