├── test_colorspace.py └── colorspace.py /test_colorspace.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import colorspace as cs 4 | 5 | COLORS = { 6 | 'indigo': { 7 | 'HEX': '#4b0082', 8 | 'RGB': [0.29412, 0.00000, 0.50980], 9 | 'CIEXYZ': [0.06931, 0.03108, 0.21354], 10 | 'CIEXYY': [0.22079, 0.09899, 0.03108], 11 | 'CIELAB': [20.470, 51.695, -53.320], 12 | 'CIELCH': [20.470, 74.265, 314.113], 13 | 'CIELUV': [20.470, 10.084, -61.343],}, 14 | 'crimson': { 15 | 'HEX': '#dc143c', 16 | 'RGB': [0.86275, 0.07843, 0.23529], 17 | 'CIEXYZ': [0.30581, 0.16042, 0.05760], 18 | 'CIEXYY': [0.58380, 0.30625, 0.16042], 19 | 'CIELAB': [47.030, 70.936, 33.595], 20 | 'CIELCH': [47.030, 78.489, 25.342], 21 | 'CIELUV': [47.030, 138.278, 19.641],}, 22 | 'white': { 23 | 'HEX': '#ffffff', 24 | 'RGB': [1, 1, 1], 25 | 'CIEXYZ': [0.95050, 1.00000, 1.08900], 26 | 'CIEXYY': [0.31272, 0.32900, 1], 27 | 'CIELAB': [100, 0.005, -0.010], 28 | 'CIELUV': [100, 0.001, -0.017],}, 29 | # CIELCH omitted because Hue is almost completely}, 30 | # irrelevant for white and its big rounding error 31 | # is acceptable here. Hue is better tested with 32 | # more saturated colors, like the two above 33 | 'black': { 34 | 'HEX': '#000000', 35 | 'RGB': [0, 0, 0], 36 | 'CIEXYZ': [0, 0, 0], 37 | 'CIEXYY': [0, 0, 0], 38 | 'CIELAB': [0, 0, 0], 39 | 'CIELUV': [0, 0, 0], 40 | # CIELCH omitted 41 | } 42 | } 43 | 44 | 45 | PERMISSIBLE_ERROR = { 46 | 'CIELAB': 0.01, 47 | 'CIEXYZ': 0.001, 48 | 'CIEXYY': 0.001, 49 | 'CIELCH': 0.01, 50 | 'CIELUV': 0.01, 51 | 'RGB': 0.001, 52 | } 53 | 54 | class TestCs(unittest.TestCase): 55 | 56 | def te2st_simple_roundtrip(self): 57 | green = 0,1,0 58 | xyz = cs.CONV['RGB']['CIEXYZ'](*green) 59 | self.assertEquals(xyz, (0.3576, 0.7152, 0.1192)) 60 | lab = cs.CONV['CIEXYZ']['CIELAB'](*xyz) 61 | self.assertEquals([round(x, 3) for x in lab], [87.737, -86.185, 83.181]) 62 | lch = cs.CONV['CIELAB']['CIELCH'](*lab) 63 | self.assertEquals([round(x, 3) for x in lch], [87.737, 119.779, 136.016] ) 64 | hcl = cs.CONV['RGB']['CIELCH'](*green) 65 | self.assertEquals([round(x, 3) for x in hcl], [87.737, 119.779, 136.016] ) 66 | rgb = cs.CONV['CIELCH']['RGB'](*hcl) 67 | self.assertEquals([round(x, 3) for x in rgb], [0,1,0]) 68 | # diff = big_dif(hcl, (0,0,0)) 69 | # self.assertLessEqual(diff, PERMISSIBLE_ERROR[space2], 'conv:{0} to {1} input:{2} output:{3} should 70 | 71 | def t2est_conversion(self): 72 | for name, definitions in COLORS.items(): 73 | for space1, data1 in definitions.items(): 74 | for space2, data2 in definitions.items(): 75 | #try: 76 | print "\nP", space1, space2, data2 77 | output = cs.CONV[space1][space2](*data1) 78 | print "OUTPUT!", output 79 | # except TypeError as e: 80 | # print " TE CONV", space1, space2, data1 81 | # print e 82 | # continue 83 | # except KeyError as e: 84 | # print " CONV", space1, space2, data1 85 | # print "KE", e 86 | # raise 87 | if space2 == 'HEX': 88 | print 'HEX conv:{0} to {1} input:{2} output:{3} shouldbe:{4} types:{5}{6}'.format(space1, space2, data1, output, data2, type(output), type(data2)) 89 | self.assertEquals(output, data2) 90 | continue 91 | diff = big_dif(output, data2) 92 | self.assertLessEqual(diff, PERMISSIBLE_ERROR[space2], 'conv:{0} to {1} input:{2} output:{3} shouldbe:{4}'.format(space1, space2, data1, output, data2)) 93 | 94 | def big_dif(a,b): 95 | ret = 0 96 | for i in range(len(a)): 97 | dif = abs(a[i] - b[i]) 98 | if dif > ret: 99 | ret = dif 100 | return ret 101 | 102 | if __name__ == '__main__': 103 | unittest.main() 104 | 105 | -------------------------------------------------------------------------------- /colorspace.py: -------------------------------------------------------------------------------- 1 | r""" 2 | Supported Color Spaces 3 | 4 | * RGB: Standard RGB, the color space used on the web. All values 5 | range between 0 and 1. Be careful, rounding errors can result in 6 | values just outside this range. 7 | 8 | * CIEXYZ: One of the first mathematically defined color spaces. Values 9 | range between 0 and 0.95047, 1.0 and 1.08883 for X, Y and Z 10 | respectively. These three numbers together define the white point, 11 | which can be different depending on the chosen illuminant. The 12 | commonly used illuminant D65 was chosen for this project. 13 | 14 | * CIEXYY: Normalized version of the above. 15 | 16 | * CIELAB: A color space made for perceptual uniformity. Recommended 17 | for characterization of color surfaces and dyes. L is lightness, 18 | spans from 0 to 100. 19 | 20 | (a*, negative values indicate green while positive values indicate 21 | magenta) and its position between yellow and blue (b*, negative 22 | values indicate blue and positive values indicate yellow). 23 | 24 | * CIELCH: A cylindrical representation of CIELAB. L is lightness (0 25 | back to 100 white), C is chroma (think saturation) (0 grey/center to 26 | 100/color purity) and H is hue. H spans from 0 to 360. 27 | 28 | * CIELUV: Another color space made for perceptual 29 | uniformity. Recommended for characterization of color displays. 30 | 31 | * CIELCHUV: Same as CIELCH, but based on CIELUV. 32 | 33 | * HEX: A representation of RGB. 34 | 35 | >>> import colorspace 36 | >>> colorspace.DEBUG = True 37 | >>> start = HCL(0, 50, 70) 38 | >>> end = HCL(0, 90, 20) 39 | >>> for step in start.sequential(steps=5): 40 | ... print "\nHCL", step, CIELCH_to_RGB(step.l, step.c, step.h) 41 | 42 | >>> for step in start.diverging(90, steps=6): 43 | ... print step 44 | 45 | >>> rgb = 1,0,0 #255, 0, 0 46 | >>> xyz = RGB_to_CIEXYZ(*rgb) 47 | >>> xyz 48 | >>> rgb2 = CIEXYZ_to_RGB(*xyz) 49 | >>> rgb2 50 | >>> luv = CIEXYZ_to_CIELUV(*xyz) 51 | >>> luv 52 | >>> xyz2 = CIELUV_to_CIEXYZ(*luv) 53 | >>> xyz2 54 | >>> rgb3 = CIEXYZ_to_RGB(*xyz2) 55 | >>> rgb3 56 | 57 | >>> f(0) 58 | >>> f(1) 59 | >>> f(99) 60 | >>> f(100) 61 | 62 | >>> f_inv(f(0)) 63 | 0.0 64 | >>> f_inv(f(1)) 65 | 1.0 66 | >>> round(f_inv(f(99))) 67 | 99.0 68 | >>> round(f_inv(f(100))) 69 | 100.0 70 | 71 | >>> round(f(f_inv(0))) 72 | 0.0 73 | >>> round(f(f_inv(1))) 74 | 1.0 75 | >>> round(f(f_inv(99))) 76 | 99.0 77 | >>> round(f(f_inv(100))) 78 | 100.0 79 | 80 | >>> REF_U 81 | >>> REF_V 82 | 83 | """ 84 | 85 | import math 86 | 87 | DEBUG=False 88 | 89 | def debug_func(func): 90 | def wrapper3(*args, **kwargs): 91 | if DEBUG: print "CALLING", func, args 92 | res = func(*args, **kwargs) 93 | if DEBUG: print '\t result', res 94 | return res 95 | wrapper3.__name__ = func.__name__ 96 | return wrapper3 97 | 98 | # port of boronine / colorspaces.js to python 99 | 100 | def dot_product(a,b): 101 | #print "AB", a, b 102 | assert len(a) == len(b) 103 | ret = 0 104 | for i in range(len(a)): 105 | ret += a[i] * b[i] 106 | return ret 107 | 108 | # The D65 standard illuminant 109 | REF_X = 0.95047 110 | REF_Y = 1.0 111 | REF_Z = 1.08883 112 | REF_U = (4 * REF_X) / (REF_X + (15 * REF_Y) + (3 * REF_Z)) 113 | REF_V = (9 * REF_Y) / (REF_X + (15 * REF_Y) + (3 * REF_Z)) 114 | 115 | # CIE L*a*b* constants 116 | LAB_E = 0.008856 117 | LAB_K = 903.3 118 | 119 | M = [ 120 | [3.2406, -1.5372, -0.4986], 121 | [-0.9689, 1.8758, 0.0415], 122 | [0.0557, -0.2040, 1.0570], 123 | ] 124 | M2 =[ 125 | [0.4124, 0.3576, 0.1805], 126 | [0.2126, 0.7152, 0.0722], 127 | [0.0193, 0.1192, 0.9505], 128 | ] 129 | # Used for Lab 130 | def f(t): 131 | if t > LAB_E: 132 | return math.pow(t, 1.0/3) 133 | else: 134 | return 7.787 * t + 16.0/116 135 | 136 | def f_inv(t): 137 | if math.pow(t, 3) > LAB_E: 138 | return math.pow(t, 3) 139 | else: 140 | return (116 * t - 16.0) / LAB_K 141 | 142 | 143 | CONV = {} 144 | SPACES = ['CIEXYZ', 145 | 'CIEXYY', 146 | 'CIELAB', 147 | 'CIELCH', 148 | 'CIELUV', 149 | 'CIELCHUV', 150 | 'RGB', 151 | 'HEX'] 152 | for space in SPACES: 153 | CONV[space] = {} 154 | 155 | @debug_func 156 | def CIEXYZ_to_RGB(*xyz): 157 | def from_linear(c): 158 | a = 0.055 159 | if c <= 0.0031308: 160 | return 12.92 * c 161 | else: 162 | return 1.055 * math.pow(c, 1/2.4) - 0.055 163 | r = from_linear(dot_product(M[0], xyz)) 164 | g = from_linear(dot_product(M[1], xyz)) 165 | b = from_linear(dot_product(M[2], xyz)) 166 | #return [round(num) for num in r,g,b] 167 | #hackY??? 168 | mins = [min(x,1.) for x in [r,g,b]] 169 | return [max(x,0.0) for x in mins] 170 | 171 | 172 | CONV['CIEXYZ']['RGB'] = CIEXYZ_to_RGB 173 | 174 | @debug_func 175 | def RGB_to_CIEXYZ(*rgb): 176 | def to_linear(c): 177 | a = 0.055 178 | if c > 0.04045: 179 | return math.pow((c+a)/(1+a), 2.4) 180 | else: 181 | return c/12.92 182 | rgbl = [to_linear(x) for x in rgb] 183 | x = dot_product(M2[0], rgbl) 184 | y = dot_product(M2[1], rgbl) 185 | z = dot_product(M2[2], rgbl) 186 | return x,y,z 187 | 188 | 189 | CONV['RGB']['CIEXYZ'] = RGB_to_CIEXYZ 190 | 191 | @debug_func 192 | def CIEXYZ_to_CIEXYY(*xyz): 193 | total = float(sum(xyz)) 194 | x,y,z = xyz 195 | if total == 0: 196 | return 0,0,y 197 | return x/total, y/total, y 198 | 199 | 200 | CONV['CIEXYZ']['CIEXYY'] = CIEXYZ_to_CIEXYY 201 | 202 | @debug_func 203 | def CIEXYY_to_CIEXYZ(*xyy): 204 | x,y,y2 = xyy 205 | if y == 0: 206 | return 0,0,0 207 | return (x*y2/float(y)), y2, (1-x-y)*y2/float(y) 208 | 209 | 210 | CONV['CIEXYY']['CIEXYZ'] = CIEXYY_to_CIEXYZ 211 | 212 | @debug_func 213 | def CIEXYZ_to_CIELAB(*xyz): 214 | x,y,z = xyz 215 | fx = f(x/REF_X) 216 | fy = f(y/REF_Y) 217 | fz = f(z/REF_Z) 218 | l = 116*fy - 16 219 | a = 500*(fx-fy) 220 | b = 200*(fy-fz) 221 | return l,a,b 222 | 223 | 224 | CONV['CIEXYZ']['CIELAB'] = CIEXYZ_to_CIELAB 225 | 226 | @debug_func 227 | def CIELAB_to_CIEXYZ(*lab): 228 | 229 | l,a,b = lab 230 | y2 = (l+16)/116.0 231 | z2 = y2 - b/200.0 232 | x2 = a / 500.0 + y2 233 | x = REF_X * f_inv(x2) 234 | y = REF_Y * f_inv(y2) 235 | z = REF_Z * f_inv(z2) 236 | return x,y,z 237 | 238 | 239 | CONV['CIELAB']['CIEXYZ'] = CIELAB_to_CIEXYZ 240 | 241 | @debug_func 242 | def CIEXYZ_to_CIELUV(*xyz): 243 | x,y,z = xyz 244 | if all(var == 0 for var in xyz): 245 | # black 246 | return 0,0,0 247 | u2 = 4*x / (x+(15.0 * y) + (3.0 * z)) 248 | v2 = 9*y / (x+(15.0 * y) + (3.0 * z)) 249 | 250 | l = 116.0 * f(y/REF_Y) - 16 251 | # black will create a divide by zero error 252 | if l == 0: 253 | return 0,0,0 254 | u = 13 * l * (u2 - REF_U) 255 | v = 13 * l * (v2 - REF_V) 256 | return l, u, v 257 | 258 | 259 | CONV['CIEXYZ']['CIELUV'] = CIEXYZ_to_CIELUV 260 | 261 | @debug_func 262 | def CIELUV_to_CIEXYZ(*luv): 263 | l,u,v = luv 264 | # black will create a divide by zero error 265 | if l == 0: 266 | return 0,0,0 267 | 268 | y2 = f_inv((l+16.0)/116.0) 269 | u2 = u/(13.0*l) + REF_U 270 | v2 = v/(13.0*l) + REF_V 271 | y = y2 * REF_Y 272 | x = 0 - (9.0 * y * u2)/((u2 - 4) * v2 - u2 * v2) 273 | z = (9.0 * y - (15 * v2 * y) - (v2 * x))/(3*v2) 274 | return x,y,z 275 | 276 | 277 | CONV['CIELUV']['CIEXYZ'] = CIELUV_to_CIEXYZ 278 | 279 | @debug_func 280 | def scalar_to_polar(*data): 281 | l, v1, v2 = data 282 | c = math.pow(math.pow(v1,2)+math.pow(v2,2), .5) 283 | h_rad = math.atan2(v2,v1) 284 | h = h_rad *360/2.0/math.pi 285 | if h < 0: 286 | h += 360 287 | #capping at 100 - bug?/feature? 288 | return min(l,100), min(c,100), h 289 | #return l, c, h 290 | 291 | def copy_func(func, name): 292 | def wrapper5(*args, **kwargs): 293 | if DEBUG: print "FOO!", name 294 | return func(*args, **kwargs) 295 | wrapper5.__name__ = name 296 | return wrapper5 297 | 298 | 299 | CIELAB_to_CIELCH = copy_func(scalar_to_polar, 'CIELAB_to_CIELCH') 300 | CIELUV_to_CIELCHUV = copy_func(scalar_to_polar, 'CIELUV_to_CIELCHUV') 301 | 302 | 303 | CONV['CIELAB']['CIELCH'] = CIELAB_to_CIELCH 304 | CONV['CIELUV']['CIELCHUV'] = CIELUV_to_CIELCHUV 305 | 306 | @debug_func 307 | def polar_to_scaler(*data): 308 | l,c,h = data 309 | h_rad = h/360.0 * 2 * math.pi 310 | var1 = math.cos(h_rad) * c 311 | var2 = math.sin(h_rad) * c 312 | return l, var1, var2 313 | 314 | 315 | CIELCH_to_CIELAB = copy_func(polar_to_scaler, 'CIELCH_to_CIELAB') 316 | CIELCHUV_to_CIELUV = copy_func(polar_to_scaler, 'CIELCHUV_to_CIELUV') 317 | CONV['CIELCH']['CIELAB'] = CIELCH_to_CIELAB 318 | CONV['CIELCHUV']['CIELUV'] = CIELCH_to_CIELAB 319 | 320 | def to_256(*data): 321 | data = [round(n,3) for n in data] 322 | for ch in data: 323 | assert 0 <= ch <= 1 324 | return [int(round(ch * 255)) for ch in data] 325 | 326 | @debug_func 327 | def RGB_to_HEX(*rgb): 328 | result = ['#'] 329 | rgb = to_256(*rgb) 330 | result.extend( ['%02x'%x for x in rgb]) 331 | result = ''.join(result) 332 | return result 333 | 334 | CONV['RGB']['HEX'] = RGB_to_HEX 335 | 336 | @debug_func 337 | def HEX_to_RGB(*hex): 338 | hex = ''.join(hex) 339 | if hex.startswith('#'): 340 | hex = hex[1:] 341 | r = hex[:2] 342 | g = hex[2:4] 343 | b = hex[4:6] 344 | return [int(x,16)/255.0 for x in [r,g,b]] 345 | CONV['HEX']['RGB'] = HEX_to_RGB 346 | 347 | def iden_gen(space): 348 | def wrapper4(*args): 349 | if DEBUG: print "running IDEN", space 350 | if len(args) > 4: 351 | #RGB hack 352 | result = ''.join(args) 353 | if DEBUG: print result 354 | return result 355 | if DEBUG: print args 356 | return args 357 | return wrapper4 358 | 359 | 360 | def identity(*args): 361 | if len(args) > 4: 362 | #RGB hack 363 | return ''.join(args) 364 | return args 365 | 366 | def parent_to_child(p, child, parent): 367 | def wrapper1(*x): 368 | if DEBUG: print "CONVERTING", child, parent 369 | r1 = CONV[child][parent](*x) 370 | if DEBUG: print "\t",r1 371 | if DEBUG: print "RUNNING P" 372 | r2 = p(*r1) 373 | if DEBUG: print "\t",r2 374 | return r2 375 | wrapper1.__name__ = '{0}_to_{1} WRAPPER'.format(child,parent) 376 | return wrapper1 377 | 378 | def from_to_parent(p, child, parent): 379 | def wrapper2(*x): 380 | if DEBUG: print "RUNNING1", p 381 | r1 = p(*x) 382 | if DEBUG: print "\t",r1 383 | if DEBUG: print "CONV2", parent, child 384 | r1 = CONV[parent][child](*r1) 385 | if DEBUG: print "\t",r1 386 | return r1 387 | wrapper2.__name__ = '{0}_to_{1} WRAPPER'.format(child,parent) 388 | return wrapper2 389 | 390 | def converter(from_,to_): 391 | tree = [ 392 | ['CIELCH', 'CIELAB'], 393 | ['CIELCHUV', 'CIELUV'], 394 | ['HEX', 'RGB'], 395 | ['CIEXYY', 'CIEXYZ'], 396 | ['CIELAB', 'CIEXYZ'], 397 | ['CIELUV', 'CIEXYZ'], 398 | ['RGB', 'CIEXYZ'], 399 | ] 400 | def path(tree, from2, to2): 401 | if from2 == to2: 402 | #return identity #lambda *x:x 403 | return iden_gen(from2) #lambda *x:x 404 | child, parent = tree[0] 405 | if from2 == child: 406 | p = path(tree[1:], parent, to2) 407 | return parent_to_child(p, child, parent) 408 | #return lambda *x: p(*CONV[child][parent](*x)) 409 | if to2 == child: 410 | p = path(tree[1:], from2, parent) 411 | return from_to_parent(p, child, parent) 412 | #return lambda *x: CONV[parent][child](*p(*x)) 413 | p = path(tree[1:], from2, to2) 414 | return p 415 | func = path(tree, from_, to_) 416 | return func 417 | 418 | 419 | 420 | 421 | for space in SPACES: 422 | for space2 in SPACES: 423 | if space in CONV and space2 not in CONV[space]: 424 | name = '{0}_to_{1}'.format(space, space2) 425 | func = converter(space,space2) 426 | locals()[name] = func 427 | CONV[space][space2] = func 428 | 429 | 430 | 431 | class Color(object): 432 | pass 433 | 434 | class RBG(Color): 435 | def __init__(self, r, g, b): 436 | self.r = r 437 | self.b = b 438 | self.g = g 439 | 440 | def float_range(start, end, step): 441 | num = start 442 | while num <= end: 443 | yield num 444 | num += step 445 | 446 | 447 | class HCL(Color): 448 | """CIEHUV""" 449 | def __init__(self, h, c, l): 450 | # assert 0 <= h <= 360 451 | # assert 0 <= c <= 100 452 | # assert 0 <= l <= 100 453 | self.h = h 454 | self.c = c 455 | self.l = l 456 | 457 | def __str__(self): 458 | return 'HCL({0},{1},{2})'.format(self.h, self.c, self.l) 459 | 460 | def _delta(self, steps, c_target, l_target): 461 | c_delta = (c_target - self.c)/float(steps) 462 | l_delta = (l_target - self.l)/float(steps) 463 | c_next = self.c 464 | l_next = self.l 465 | for i in range(steps): 466 | c_next += c_delta 467 | l_next += l_delta 468 | yield HCL(self.h, c_next, l_next) 469 | 470 | def darker(self, steps): 471 | for x in self._delta(steps, c_target=0, l_target=0): 472 | yield x 473 | def lighter(self, steps): 474 | for x in self._delta(steps, c_target=0, l_target=100): 475 | yield x 476 | 477 | 478 | def darker2(self, steps): 479 | "move c and l toward 0" 480 | c_delta = self.c/float(steps) 481 | l_delta = self.l/float(steps) 482 | c_next = self.c 483 | l_next = self.l 484 | for i in range(steps): 485 | c_next -= c_delta 486 | l_next -= l_delta 487 | yield HCL(self.h, c_next, l_next) 488 | 489 | def diverging(self, steps, end_hue=None, l_max=99, l_min=40): 490 | """ 491 | Go from saturated to lighter and back to saturated 492 | """ 493 | if end_hue is None: 494 | end_hue = (self.h + 180)%360 495 | for c in reversed(list(self.sequential(steps/2, l_max=l_max,l_min=l_min))): 496 | yield c 497 | c_end = HCL(end_hue, c.c, c.l) 498 | for d in c_end.sequential(steps/2, #self.c, self.l, 499 | l_min=l_min): 500 | yield d 501 | 502 | 503 | def sequential(self, steps, c_max=100, l_max=90, l_min=30, i_gen=None): 504 | """ 505 | Goes from light to dark 506 | l_max, and l_min default to avoid white (100) and black (0) 507 | """ 508 | if i_gen is None: 509 | i_gen = float_range(0, 1, 1.0/(steps-1)) 510 | c_delta = c_max - self.c 511 | for i in i_gen: 512 | assert 0<=i<=1 513 | c_next = self.c + i*c_delta 514 | l_next = l_max - i*(l_max-l_min) 515 | yield HCL(self.h, c_next, l_next) 516 | 517 | def qualitative(self, steps): 518 | h = self.h 519 | for i in range(steps): 520 | yield HCL(h, self.c, self.l) 521 | h = (h+(360.0/steps)) %360 522 | 523 | class HSV(Color): 524 | """ Problematic because brightness is not consistent over hues and 525 | saturations. """ 526 | def __init__(self, h, s, v): 527 | assert 0 <= h <= 360 528 | assert 0 <= s <= 100 529 | assert 0 <= v <= 100 530 | self.h = h 531 | self.s = s 532 | self.v = v 533 | 534 | 535 | def test(): 536 | import doctest 537 | doctest.testmod() 538 | 539 | 540 | if __name__ == '__main__': 541 | test() 542 | 543 | 544 | 545 | --------------------------------------------------------------------------------