diff options
Diffstat (limited to 'crl/color.py')
-rw-r--r-- | crl/color.py | 566 |
1 files changed, 566 insertions, 0 deletions
diff --git a/crl/color.py b/crl/color.py new file mode 100644 index 0000000..d6240ff --- /dev/null +++ b/crl/color.py @@ -0,0 +1,566 @@ +import numpy as np, scipy.interpolate, scipy.optimize +import crl.tables as tables +import crl.tables2 as tables2 + +class Spectrum: + def __init__(self, X, Y): + self.X = X + self.Y = Y + self.Yi = scipy.interpolate.interp1d(self.X, self.Y, \ + "linear", fill_value=0, bounds_error=False) + + def dot(A, B, Xmin=380, Xmax=780, Xstep=0.01): + if not isinstance(B, Spectrum): + raise TypeError("B is an instance of %s, not Spectrum" \ + % type(B)) + + if isinstance(A, PointSpectrum): + if isinstance(B, PointSpectrum): + raise TypeError("dotting two instances of PointSpectum isn't supported") + # ^ could be, but who cares? + return B.dot(A) + + if isinstance(B, PointSpectrum): + return A.Yi(B.x_0) * B.S + + X = np.arange(Xmin, Xmax, Xstep) + dX = X[1] - X[0] + return dX * np.sum(A.Yi(X) * B.Yi(X)) + + def normed(self, norm=100): + return NormedSpectrum(self, norm) + + def __str__(self): + return "a tabulated Spectrum" + +class NormedSpectrum(Spectrum): + def __init__(self, spec, norm=100): + self.spec = spec + + _, Y, _ = XYZ(self.spec) + self.factor = norm / Y + + def Yi(self, wvl): + return self.factor * self.spec.Yi(wvl) + + def __repr__(self): + return "NormedSpectrum(" + repr(self.spec) + ")" + + def __str__(self): + return "normed " + str(self.spec) + +class PointSpectrum(Spectrum): + # the "S" stands for "sum" (being the integral of the Dirac delta) + def __init__(self, x_0, S=1): + self.x_0 = x_0 + self.S = S + + # a crappy hack for easy plotting + self.X = np.array([380, x_0 - 0.001, x_0, x_0 + 0.001, 780]) + self.Y = np.array([0, 0, S, 0, 0]) + + def __repr__(self): + return "PointSpectrum(x_0=%g, S=%g)" % (self.x_0, self.S) + + def __str__(self): + return self.__repr__() + +class BlackBodySpectrum(Spectrum): + def __init__(self, T): + self.T = T + + def Yi(self, wvl): + h = 6.62607015e-34 # kg m^2 / s + c = 299792458 # m / s + k_B = 1.3806485e-23 # kg m^2 / K s^2 + return 2 * h * c ** 2 / ((wvl / 1e9) ** 5) \ + / (np.exp(h * c / (wvl / 1e9 * k_B * self.T)) - 1) \ + * 1e-9 # kg / (nm s^3) + + def __repr__(self): + return "BlackBodySpectrum(T=%g)" % self.T + + def __str__(self): + return repr(self) + +# Schanda, page 41 +class CIEDaylightSpectrum(Spectrum): + S0 = Spectrum(tables.D_eigenvectors[:, 0], tables.D_eigenvectors[:, 1]) + S1 = Spectrum(tables.D_eigenvectors[:, 0], tables.D_eigenvectors[:, 2]) + S2 = Spectrum(tables.D_eigenvectors[:, 0], tables.D_eigenvectors[:, 3]) + + def __init__(self, T): + if T < 4000 or T > 25000: + raise ValueError("D illuminants are not defined for CCTs outside the 4000 K-25000 K range") + elif T <= 7000: + x = -4.6070e9 / T**3 \ + + 2.9678e6 / T**2 \ + + 0.09911e3 / T \ + + 0.244063 + else: + x = -2.0064e9 / T**3 \ + + 1.9018e6 / T**2 \ + + 0.0247483 / T \ + + 0.237040 + + y = -3 * x**2 + 2.870 * x - 0.275 + + self.M1 = (-1.3515 - 1.7703 * x + 5.9114 * y) \ + / (0.0241 + 0.2562 * x - 0.7341 * y) + self.M2 = (0.0300 - 31.4424 * x + 30.0717 * y) \ + / (0.0241 + 0.2562 * x - 0.7341 * y) + + self.M1 = round(self.M1, 3) + self.M2 = round(self.M2, 3) + + def Yi(self, wvl): + return self.S0.Yi(wvl) \ + + self.M1 * self.S1.Yi(wvl) \ + + self.M2 * self.S2.Yi(wvl) + +class CombinedSpectrum(Spectrum): + def __init__(self, A, B): + self.A = A + self.B = B + + def Yi(self, X): + return self.A.Yi(X) * self.B.Yi(X) + + def __repr__(self): + return "CombinedSpectrum(%s, %s)" % (repr(self.A), repr(self.B)) + +def combine_spectra(A, B): + if isinstance(B, PointSpectrum): + A, B = B, A + + if isinstance(A, PointSpectrum): + if isinstance(B, PointSpectrum): + # technically this would make sense but would be pretty useless + raise TypeError("can't combine two instances of PointSpectrum") + + return PointSpectrum(A.x_0, A.S * B.Ef(A.x_0)) + + return CombinedSpectrum(A, B) + +# +# Tables, standard observers, basic colors +# + +class Illuminants: + # 0 l,nm + # 1 Standard Illuminant A + # 2 Standard Illuminant D65 + # 3 Illuminant C + # 4 IlluminantD50 + # 5 IlluminantD55 + # 6 IlluminantD75 + A = Spectrum(tables.illuminants[:, 0], tables.illuminants[:, 1]) + D50 = Spectrum(tables.illuminants[:, 0], tables.illuminants[:, 4]) + D55 = Spectrum(tables.illuminants[:, 0], tables.illuminants[:, 5]) + D65 = Spectrum(tables.illuminants[:, 0], tables.illuminants[:, 2]) + D75 = Spectrum(tables.illuminants[:, 0], tables.illuminants[:, 6]) + + _wvl = np.linspace(380, 780, 801) + _E = np.ones(_wvl.size) + E = Spectrum(_wvl, _E) + + _E = np.zeros(_wvl.size) + _E[120] = 3 # 440 nm + _E[376] = 5 # 568 nm + RedmanTest1 = Spectrum(_wvl, _E) # 6500K, CRI -12 + +class Observer: + def __init__(self, name, wvl, cmf_x, cmf_y, cmf_z): + self.name = name + self.cmf_x = Spectrum(wvl, cmf_x) + self.cmf_y = Spectrum(wvl, cmf_y) + self.cmf_z = Spectrum(wvl, cmf_z) + + def __str__(self): + return self.name + +Observer_2deg = Observer("2-deg Standard Observer", + *(tables.cmf[:, i] for i in range(4))) +Observer_10deg = Observer("10-deg Standard Observer", + *(tables.cmf_1964[:, i] for i in range(4))) + +class Color: + full_name = "Color" + observer = Observer_2deg + + # Call either: + # __init__(self, x, y, z) + # __init__(self, color) + # __init__(self, spectrum) + def __init__(self, *args, **kwargs): + observer = kwargs.get("observer", Observer_2deg) + + if len(args) == 3: + self.array = np.array(args) + elif len(args) == 1: + if isinstance(args[0], Color): + self.array = args[0].to(self).array + elif isinstance(args[0], Spectrum): + self.array = self.from_spectrum(args[0], observer).array + else: + raise TypeError("argument is not a Color or a Spectrum") + else: + raise TypeError("bad number of arguments") + + def __repr__(self): + return "%s(%g, %g, %g)" % (type(self).__name__, *self) + + def __str__(self): + return "%s (%g, %g, %g), %s" % (self.full_name, *self, + self.observer) + + @classmethod + def from_spectrum(cls, S, observer): + return cls.from_XYZ(XYZ.from_spectrum(S, observer)) + + def to(self, CS): + return CS.from_XYZ(self.to_XYZ()) + + @staticmethod + def from_XYZ(C): + raise NotImplementedError + + def to_XYZ(C): + raise NotImplementedError + + def __getitem__(self, index): + return self.array[index] + + def __setitem__(self, index, value): + self.array[index] = value + + def __iter__(self): + return self.array.__iter__() + + def __add__(A, B): + return type(A)(*(A.array + B.array)) + +class sRGB(Color): + full_name = "sRGB" + matrix = np.array([ + [ 3.240479, -1.537150, -0.498353], + [-0.969256, 1.875992, 0.041556], + [ 0.055648, -0.204043, 1.057331]]) + matrix_inverse = np.linalg.inv(matrix) + + @classmethod + def from_XYZ(cls, C): + def gamma(x): + if x <= 0.0031308: + return 12.92 * x + return 1.055 * x ** (1 / 2.4) - 0.055 + + RGB = np.dot(cls.matrix, C.array) + return sRGB(*[gamma(x) for x in RGB]) + + def to_XYZ(self): + def gamma_inverse(x): + if x <= 0.040449936: + return x / 12.92; + return ((x + 0.055) / 1.055) ** 2.4 + + RGB = [gamma_inverse(x) for x in self] + return XYZ(*np.dot(self.matrix_inverse, RGB)) + +class sRGB100(sRGB): + full_name = "sRGB (with Y=100 as white)" + matrix = 0.01 * sRGB.matrix + matrix_inverse = np.linalg.inv(matrix) + +# +# CIE 1931 +# + +class XYZ(Color): + full_name = "CIE 1931 XYZ" + + @staticmethod + def from_XYZ(C): + return C + + def to_XYZ(self): + return self + + @staticmethod + def from_spectrum(S, observer): + X = S.dot(observer.cmf_x) + Y = S.dot(observer.cmf_y) + Z = S.dot(observer.cmf_z) + return XYZ(X, Y, Z) + + def normed(self, norm=100): + X, Y, Z = self + return XYZ(X * norm / Y, norm, Z * norm / Y) + +class xyY(Color): + full_name = "CIE 1931 xyY" + + @staticmethod + def from_XYZ(C): + x = C[0] / sum(C) + y = C[1] / sum(C) + return xyY(x, y, C[1]) + + def to_XYZ(self): + x, y, Y = self + try: + X = Y / y * x + Z = Y / y * (1 - x - y) + except ZeroDivisionError: + X = 0 + Z = 0 + return XYZ(X, Y, Z) + +# +# CIE 1960 +# + +class UVW(Color): + full_name = "CIE 1960 UCS (McAdam) UVW" + + @staticmethod + def from_XYZ(C): + X, Y, Z = C + U = 2 * X / 3 + V = Y + W = 1/2 * (-X + 3 * Y + Z) + return UVW(U, V, W) + + def to_XYZ(self): + U, V, W = self + X = 3/2 * U + Y = V + Z = 3/2 * U - 3 * V + 2 * W + return XYZ(X, Y, Z) + + def to_uvY(self): + U, V, W = self + u = U / sum(UVW) + v = V / sum(UVW) + return uvY(u, v, V) + +class uvY(Color): + full_name = "CIE 1960 uvY" + + @staticmethod + def from_XYZ(C): + X, Y, Z = C + u = 4 * X / (X + 15 * Y + 3 * Z) + v = 6 * Y / (X + 15 * Y + 3 * Z) + return uvY(u, v, Y) + + def to_UVW(self): + u, v, V = self + try: + U = V * u / v + W = -(V * v + V * u - V) / v + except ZeroDivisionError: + U = 0 + W = 0 + return UVW(U, V, W) + + def to_XYZ(self): + return self.to_UVW().to_XYZ() + +# +# CIE 1964 +# + +class UVWstar(Color): + full_name = "CIE 1964 U*V*W*" + + @classmethod + def from_uvY(cls, C, CAT=True): + u, v, Y = C + un, vn, _ = cls.white_uvY if CAT else (0, 0, 0) + + Wstar = 25 * Y ** (1/3) - 17 + Ustar = 13 * Wstar * (u - un) + Vstar = 13 * Wstar * (v - vn) + + return cls(Ustar, Vstar, Wstar) + + @classmethod + def from_XYZ(cls, C): + return cls.from_uvY(C.to(uvY)) + + def to_uvY(self, CAT=True): + Ustar, Vstar, Wstar = self + un, vn, _ = self.white_uvY if CAT else (0, 0, 0) + + u = Ustar / (13 * Wstar) + un + v = Vstar / (13 * Wstar) + vn + Y = (Wstar**3 + 51 * Wstar**2 + 867 * Wstar + 4913) / 15625 + + return uvY(u, v, Y) + + def to_XYZ(self): + return self.to_uvY().to(XYZ) + + + +# +# CIE 1976 +# + +class uvY76(Color): + full_name = "CIE 1976 u'v'Y" + + @staticmethod + def from_XYZ(C): + X, Y, Z = C + u = 4 * X / (X + 15 * Y + 3 * Z) + v = 9 * Y / (X + 15 * Y + 3 * Z) + return uvY76(u, v, Y) + + def to_XYZ(self): + u, v, Y = self + X = 9 * u / (4 * v) * Y + Z = (12 - 3 * u - 20 * v) / (4 * v) * Y + return XYZ(X, Y, Z) + +# The magic CIE 1976 function for mapping Y to L* +def CIE1976_f(t): + if t > (6/29) ** 3: + return t ** (1/3) + + return 841 * t / 108 + 4/29 + +# The inverse of the above +def CIE1976_f_inverse(t): + if t > 6/29: + return t ** 3 + + return (3132 * t - 432) / 24389 + +class Luvstar(Color): + full_name = "CIE 1976 L*u*v*" + + @classmethod + def from_uvY76(cls, C): + u, v, Y = C + un, vn, Yn = cls.white_uvY76 + + Lstar = 116 * CIE1976_f(Y / Yn) - 16 + ustar = 13 * Lstar * (u - un) + vstar = 13 * Lstar * (v - vn) + + return cls(Lstar, ustar, vstar) + + @classmethod + def from_XYZ(cls, C): + return cls.from_uvY76(C.to(uvY76)) + + def to_uvY76(self): + Lstar, ustar, vstar = self + un, vn, Yn = self.white_uvY76 + + u = ustar / (13 * Lstar) + un + v = vstar / (13 * Lstar) + vn + Y = Yn * CIE1976_f_inverse((Lstar + 16) / 116) + + return uvY76(u, v, Y) + + def to_XYZ(self): + return self.to_uvY76().to_XYZ() + +class Labstar(Color): + full_name = "CIE 1976 L*a*b*" + + @classmethod + def from_XYZ(cls, C): + X, Y, Z = C + Xn, Yn, Zn = cls.white + + Lstar = 116 * CIE1976_f(Y / Yn) - 16 + astar = 500 * (CIE1976_f(X / Xn) - CIE1976_f(Y / Yn)) + bstar = 200 * (CIE1976_f(Y / Yn) - CIE1976_f(Z / Zn)) + + return cls(Lstar, astar, bstar) + + def to_XYZ(self): + Lstar, astar, bstar = self + Xn, Yn, Zn = self.white + + X = Xn * CIE1976_f_inverse(astar / 500 + (Lstar + 16) / 116) + Y = Yn * CIE1976_f_inverse((Lstar + 16) / 116) + Z = Zn * CIE1976_f_inverse(-bstar / 200 + (Lstar + 16) / 116) + + return XYZ(X, Y, Z) + +# +# Some useful predefined color spaces +# + +class UVWstarD65(UVWstar): + white_uvY = uvY(Illuminants.D65) + +class LuvstarD65(Luvstar): + white_uvY76 = uvY76(Illuminants.D65) + +class LabstarD65(Labstar): + white = XYZ(Illuminants.D65).normed() + +class UVstarNormed(Color): + full_name = "CIE 1964 U*V*W*, normed (plots only!)" + + @staticmethod + def from_UVWstar(C): + if not issubclass(type(C), UVWstar): + raise TypeError(f"{type(C)} is not a subclass of UVWstar") + + Ustar, Vstar, Wstar = C + + Unorm = Ustar / Wstar + Vnorm = Vstar / Wstar + + return UVstarNormed(Unorm, Vnorm, Wstar) + + @staticmethod + def from_XYZ(C): + raise NotImplementedError("use the from_UVWstar method directly") + + def to_UVWstar(self): + Unorm, Vnorm, Wstar = self + + Ustar = Unorm * Wstar + Vstar = Vnorm * Wstar + + return UVWstarD65(Ustar, Vstar, Wstar) + + def to_XYZ(self): + return self.to_UVWstar().to_XYZ() + +class UVstarNormedD65(UVstarNormed): + @staticmethod + def from_XYZ(C): + return UVstarNormedD65.from_UVWstar(UVWstarD65(C)) + +# +# CCT +# + +def cct(C): + u, v, _ = uvY(C) + uv = np.array([u, v]) + + uv_planck = scipy.interpolate.interp1d( + tables2.locus_uv[:, 0], tables2.locus_uv[:, 1:3], axis=0, + bounds_error=False, fill_value="extrapolate") + + def delta(T): + return np.linalg.norm(uv_planck(T) - uv) + + res = scipy.optimize.minimize_scalar(delta, bounds=[1000, 20000]) + return res.x, res.fun + +def cct_McCamy(C): + x, y, _ = xyY(C) + + n = (x - 0.3320) / (y - 0.1858) + return -449 * n**3 + 3525 * n**2 - 6823.3 * n + 5520.33 |