|
| 1 | +# This file is part of pycam02ucs |
| 2 | +# Copyright (C) 2014-2015 Nathaniel Smith <njs@pobox.com> |
| 3 | +# See file LICENSE.txt for license information. |
| 4 | + |
| 5 | +# Basic colorspaces: conversions between sRGB, XYZ, xyY, CIELab |
| 6 | + |
| 7 | +import numpy as np |
| 8 | + |
| 9 | +from .util import stacklast, color_cart2polar, color_polar2cart |
| 10 | +from .illuminants import as_XYZ100_w |
| 11 | +from .testing import check_conversion |
| 12 | + |
| 13 | +################################################################ |
| 14 | +# sRGB <-> sRGB-linear <-> XYZ100 |
| 15 | +################################################################ |
| 16 | + |
| 17 | +# https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation |
| 18 | +def C_linear(C_srgb): |
| 19 | + out = np.empty(C_srgb.shape, dtype=float) |
| 20 | + linear_portion = (C_srgb < 0.04045) |
| 21 | + a = 0.055 |
| 22 | + out[linear_portion] = C_srgb[linear_portion] / 12.92 |
| 23 | + out[~linear_portion] = ((C_srgb[~linear_portion] + a) / (a + 1)) ** 2.4 |
| 24 | + return out |
| 25 | + |
| 26 | +def C_srgb(C_linear): |
| 27 | + out = np.empty(C_linear.shape, dtype=float) |
| 28 | + linear_portion = (C_linear <= 0.0031308) |
| 29 | + a = 0.055 |
| 30 | + out[linear_portion] = C_linear[linear_portion] * 12.92 |
| 31 | + out[~linear_portion] = (1+a) * C_linear[~linear_portion] ** (1/2.4) - a |
| 32 | + return out |
| 33 | + |
| 34 | +XYZ100_to_sRGB1_matrix = np.array([ |
| 35 | + # This is the exact matrix specified in IEC 61966-2-1:1999 |
| 36 | + [ 3.2406, -1.5372, -0.4986], |
| 37 | + [-0.9689, 1.8758, 0.0415], |
| 38 | + [ 0.0557, -0.2040, 1.0570], |
| 39 | + ]) |
| 40 | + |
| 41 | +# Condition number is 4.3, inversion is safe: |
| 42 | +sRGB1_to_XYZ100_matrix = np.linalg.inv(XYZ100_to_sRGB1_matrix) |
| 43 | + |
| 44 | +def XYZ100_to_sRGB1_linear(XYZ100): |
| 45 | + """Convert XYZ to linear sRGB, where XYZ is normalized so that reference |
| 46 | + white D65 is X=95.05, Y=100, Z=108.90 and sRGB is on the 0-1 scale. Linear |
| 47 | + sRGB has a linear relationship to actual light, so it is an appropriate |
| 48 | + space for simulating light (e.g. for alpha blending). |
| 49 | +
|
| 50 | + """ |
| 51 | + XYZ100 = np.asarray(XYZ100, dtype=float) |
| 52 | + # this is broadcasting matrix * array-of-vectors, where the vector is the |
| 53 | + # last dim |
| 54 | + RGB_linear = np.einsum("...ij,...j->...i", XYZ100_to_sRGB1_matrix, XYZ100 / 100) |
| 55 | + return RGB_linear |
| 56 | + |
| 57 | +def sRGB1_linear_to_sRGB1(sRGB1_linear): |
| 58 | + return C_srgb(np.asarray(sRGB1_linear, dtype=float)) |
| 59 | + |
| 60 | +def sRGB1_to_sRGB1_linear(sRGB1): |
| 61 | + """Convert sRGB (as floats in the 0-to-1 range) to linear sRGB.""" |
| 62 | + sRGB1 = np.asarray(sRGB1, dtype=float) |
| 63 | + sRGB1_linear = C_linear(sRGB1) |
| 64 | + return sRGB1_linear |
| 65 | + |
| 66 | +def sRGB1_linear_to_XYZ100(sRGB1_linear): |
| 67 | + sRGB1_linear = np.asarray(sRGB1_linear, dtype=float) |
| 68 | + # this is broadcasting matrix * array-of-vectors, where the vector is the |
| 69 | + # last dim |
| 70 | + XYZ100 = np.einsum("...ij,...j->...i", sRGB1_to_XYZ100_matrix, sRGB1_linear) |
| 71 | + XYZ100 *= 100 |
| 72 | + return XYZ100 |
| 73 | + |
| 74 | +def test_sRGB1_to_sRGB1_linear(): |
| 75 | + from .gold_values import sRGB1_sRGB1_linear_gold |
| 76 | + check_conversion(sRGB1_to_sRGB1_linear, sRGB1_linear_to_sRGB1, |
| 77 | + sRGB1_sRGB1_linear_gold, |
| 78 | + a_max=1, b_max=1) |
| 79 | + |
| 80 | +def test_sRGB1_linear_to_XYZ100(): |
| 81 | + from .gold_values import sRGB1_linear_XYZ100_gold |
| 82 | + check_conversion(sRGB1_linear_to_XYZ100, XYZ100_to_sRGB1_linear, |
| 83 | + sRGB1_linear_XYZ100_gold, |
| 84 | + a_max=1, b_max=100) |
| 85 | + |
| 86 | +################################################################ |
| 87 | +# XYZ <-> xyY |
| 88 | +################################################################ |
| 89 | + |
| 90 | +# These functions work identically for both the 0-100 and 0-1 versions of |
| 91 | +# XYZ/xyY. |
| 92 | +def XYZ_to_xyY(XYZ): |
| 93 | + XYZ = np.asarray(XYZ, dtype=float) |
| 94 | + norm = np.sum(XYZ, axis=-1, keepdims=True) |
| 95 | + xy = XYZ[..., :2] / norm |
| 96 | + return np.concatenate((xy, XYZ[..., 1:2]), axis=-1) |
| 97 | + |
| 98 | +def xyY_to_XYZ(xyY): |
| 99 | + xyY = np.asarray(xyY, dtype=float) |
| 100 | + x = xyY[..., 0] |
| 101 | + y = xyY[..., 1] |
| 102 | + Y = xyY[..., 2] |
| 103 | + X = Y / y * x |
| 104 | + Z = Y / y * (1 - x - y) |
| 105 | + return stacklast(X, Y, Z) |
| 106 | + |
| 107 | +_XYZ100_to_xyY100_test_vectors = [ |
| 108 | + ([10, 20, 30], [ 10. / 60, 20. / 60, 20]), |
| 109 | + ([99, 98, 3], [99. / 200, 98. / 200, 98]), |
| 110 | + ] |
| 111 | + |
| 112 | +_XYZ1_to_xyY1_test_vectors = [ |
| 113 | + ([0.10, 0.20, 0.30], [ 0.10 / 0.60, 0.20 / 0.60, 0.20]), |
| 114 | + ([0.99, 0.98, 0.03], [0.99 / 2.00, 0.98 / 2.00, 0.98]), |
| 115 | + ] |
| 116 | + |
| 117 | +def test_XYZ_to_xyY(): |
| 118 | + check_conversion(XYZ_to_xyY, xyY_to_XYZ, |
| 119 | + _XYZ100_to_xyY100_test_vectors, b_max=[1, 1, 100]) |
| 120 | + |
| 121 | + check_conversion(XYZ_to_xyY, xyY_to_XYZ, |
| 122 | + _XYZ1_to_xyY1_test_vectors, b_max=[1, 1, 1]) |
| 123 | + |
| 124 | +################################################################ |
| 125 | +# XYZ100 <-> CIEL*a*b* |
| 126 | +################################################################ |
| 127 | + |
| 128 | +# https://en.wikipedia.org/wiki/Lab_color_space#CIELAB-CIEXYZ_conversions |
| 129 | +def _f(t): |
| 130 | + out = np.empty(t.shape, dtype=float) |
| 131 | + linear_portion = (t < (6. / 29) ** 3) |
| 132 | + out[linear_portion] = ((1. / 3) * (29. / 6) ** 2 * t[linear_portion] |
| 133 | + + 4. / 29) |
| 134 | + out[~linear_portion] = t[~linear_portion] ** (1. / 3) |
| 135 | + return out |
| 136 | + |
| 137 | +def XYZ100_to_CIELab(XYZ100, XYZ100_w): |
| 138 | + XYZ100 = np.asarray(XYZ100, dtype=float) |
| 139 | + XYZ100_w = as_XYZ100_w(XYZ100_w) |
| 140 | + |
| 141 | + fXYZ100_norm = _f(XYZ100 / XYZ100_w) |
| 142 | + L = 116 * fXYZ100_norm[..., 1:2] - 16 |
| 143 | + a = 500 * (fXYZ100_norm[..., 0:1] - fXYZ100_norm[..., 1:2]) |
| 144 | + b = 200 * (fXYZ100_norm[..., 1:2] - fXYZ100_norm[..., 2:3]) |
| 145 | + return np.concatenate((L, a, b), axis=-1) |
| 146 | + |
| 147 | +def _finv(t): |
| 148 | + linear_portion = (t <= 6. / 29) |
| 149 | + out = np.select([linear_portion, ~linear_portion], |
| 150 | + [3 * (6. / 29) ** 2 * (t - 4. / 29), |
| 151 | + t ** 3]) |
| 152 | + return out |
| 153 | + |
| 154 | +def CIELab_to_XYZ100(CIELab, XYZ100_w): |
| 155 | + CIELab = np.asarray(CIELab, dtype=float) |
| 156 | + XYZ100_w = as_XYZ100_w(XYZ100_w) |
| 157 | + |
| 158 | + L = CIELab[..., 0] |
| 159 | + a = CIELab[..., 1] |
| 160 | + b = CIELab[..., 2] |
| 161 | + X_w = XYZ100_w[..., 0] |
| 162 | + Y_w = XYZ100_w[..., 1] |
| 163 | + Z_w = XYZ100_w[..., 2] |
| 164 | + |
| 165 | + l_piece = 1. / 116 * (L + 16) |
| 166 | + X = X_w * _finv(l_piece + 1. / 500 * a) |
| 167 | + Y = Y_w * _finv(l_piece) |
| 168 | + Z = Z_w * _finv(l_piece - 1. / 200 * b) |
| 169 | + |
| 170 | + return stacklast(X, Y, Z) |
| 171 | + |
| 172 | +def test_XYZ100_to_CIELab(): |
| 173 | + from .gold_values import XYZ100_CIELab_gold_D65, XYZ100_CIELab_gold_D50 |
| 174 | + |
| 175 | + check_conversion(XYZ100_to_CIELab, CIELab_to_XYZ100, |
| 176 | + XYZ100_CIELab_gold_D65, |
| 177 | + # Stick to randomized values in the mid-range to avoid |
| 178 | + # hitting negative luminances |
| 179 | + b_min=[10, -30, -30], b_max=[90, 30, 30], |
| 180 | + XYZ100_w="D65") |
| 181 | + |
| 182 | + check_conversion(XYZ100_to_CIELab, CIELab_to_XYZ100, |
| 183 | + XYZ100_CIELab_gold_D50, |
| 184 | + # Stick to randomized values in the mid-range to avoid |
| 185 | + # hitting negative luminances |
| 186 | + b_min=[10, -30, -30], b_max=[90, 30, 30], |
| 187 | + XYZ100_w="D50") |
| 188 | + |
| 189 | + XYZ100_1 = np.asarray(XYZ100_CIELab_gold_D65[0][0]) |
| 190 | + CIELab_1 = np.asarray(XYZ100_CIELab_gold_D65[0][1]) |
| 191 | + |
| 192 | + XYZ100_2 = np.asarray(XYZ100_CIELab_gold_D50[1][0]) |
| 193 | + CIELab_2 = np.asarray(XYZ100_CIELab_gold_D50[1][1]) |
| 194 | + |
| 195 | + XYZ100_mixed = np.concatenate((XYZ100_1[np.newaxis, :], |
| 196 | + XYZ100_2[np.newaxis, :])) |
| 197 | + CIELab_mixed = np.concatenate((CIELab_1[np.newaxis, :], |
| 198 | + CIELab_2[np.newaxis, :])) |
| 199 | + |
| 200 | + XYZ100_w_mixed = np.row_stack((as_XYZ100_w("D65"), as_XYZ100_w("D50"))) |
| 201 | + |
| 202 | + assert np.allclose(XYZ100_to_CIELab(XYZ100_mixed, XYZ100_w=XYZ100_w_mixed), |
| 203 | + CIELab_mixed, rtol=0.001) |
| 204 | + assert np.allclose(CIELab_to_XYZ100(CIELab_mixed, XYZ100_w=XYZ100_w_mixed), |
| 205 | + XYZ100_mixed, rtol=0.001) |
| 206 | + |
| 207 | +################################################################ |
| 208 | +# CIELab <-> CIELCh |
| 209 | +################################################################ |
| 210 | + |
| 211 | +def CIELab_to_CIELCh(CIELab): |
| 212 | + CIELab = np.asarray(CIELab) |
| 213 | + L = CIELab[..., 0] |
| 214 | + a = CIELab[..., 1] |
| 215 | + b = CIELab[..., 2] |
| 216 | + C, h = color_cart2polar(a, b) |
| 217 | + return stacklast(L, C, h) |
| 218 | + |
| 219 | +def CIELCh_to_CIELab(CIELCh): |
| 220 | + CIELCh = np.asarray(CIELCh) |
| 221 | + L = CIELCh[..., 0] |
| 222 | + C = CIELCh[..., 1] |
| 223 | + h = CIELCh[..., 2] |
| 224 | + a, b = color_polar2cart(C, h) |
| 225 | + return stacklast(L, a, b) |
0 commit comments