def dot23(A,B, keepdims = False): """ Dot product of a 2-d ndarray with a (N x K x L) 3-d ndarray using einsum(). Args: :A: | ndarray (.shape = (M,N)) :B: | ndarray (.shape = (N,K,L)) Returns: :returns: | ndarray (.shape = (M,K,L)) """ if (len(A.shape)==2) & (len(B.shape)==3): dotAB = np.einsum('ij,jkl->ikl',A,B) if (len(B.shape)==3) & (keepdims == True): dotAB = np.expand_dims(dotAB,axis=1) elif (len(A.shape)==2) & (len(B.shape)==2): dotAB = np.einsum('ij,jk->ik',A,B) if (len(B.shape)==2) & (keepdims == True): dotAB = np.expand_dims(dotAB,axis=1) return dotAB
def _dot_M_xyz(M,xyz): """ Perform matrix multiplication between M and xyz (M*xyz) using einsum. Args: :xyz: | 2D or 3D ndarray :M: | 2D or 3D ndarray | if 2D: use same matrix M for each xyz, | if 3D: use M[i] for xyz[i,:] (2D xyz) or xyz[:,i,:] (3D xyz) Returns: :M*xyz: | ndarray with same shape as xyz containing dot product of M and xyz. """ # convert xyz to ...: if np.ndim(M)==2: if len(xyz.shape) == 3: return np.einsum('ij,klj->kli', M, xyz) else: return np.einsum('ij,lj->li', M, xyz) else: if len(xyz.shape) == 3: # second dim of x must match dim of 1st of M and 1st dim of xyzw return np.concatenate([np.einsum('ij,klj->kli', M[i], xyz[:,i:i+1,:]) for i in range(M.shape[0])],axis=1) else: # first dim of xyz must match dim of 1st of M and 1st dim of xyzw return np.concatenate([np.einsum('ij,lj->li', M[i], xyz[i:i+1,:]) for i in range(M.shape[0])],axis=0)
def lms_to_xyz(lms, cieobs=_CIEOBS, M=None, **kwargs): """ Convert LMS cone fundamental responses to XYZ tristimulus values. Args: :lms: | ndarray with LMS cone fundamental responses :cieobs: | _CIEOBS or str, optional :M: | None, optional | Conversion matrix for xyz to lms. | If None: use the one defined by :cieobs: Returns: :xyz: | ndarray with tristimulus values """ lms = np2d(lms) if M is None: M = _CMF[cieobs]['M'] # convert from lms to xyz: if len(lms.shape) == 3: xyz = np.einsum('ij,klj->kli', np.linalg.inv(M), lms) else: xyz = np.einsum('ij,lj->li', np.linalg.inv(M), lms) return xyz
def xyz_to_lms(xyz, cieobs=_CIEOBS, M=None, **kwargs): """ Convert XYZ tristimulus values to LMS cone fundamental responses. Args: :xyz: | ndarray with tristimulus values :cieobs: | _CIEOBS or str, optional :M: | None, optional | Conversion matrix for xyz to lms. | If None: use the one defined by :cieobs: Returns: :lms: | ndarray with LMS cone fundamental responses """ xyz = np2d(xyz) if M is None: M = _CMF[cieobs]['M'] # convert xyz to lms: if len(xyz.shape) == 3: lms = np.einsum('ij,klj->kli', M, xyz) else: lms = np.einsum('ij,lj->li', M, xyz) return lms
def Vrb_mb_to_xyz(Vrb, cieobs=_CIEOBS, scaling=[1, 1], M=None, Minverted=False, **kwargs): """ Convert V,r,b (Macleod-Boynton) color coordinates to XYZ tristimulus values. | Macleod Boynton: V = R+G, r = R/V, b = B/V | Note that R,G,B ~ L,M,S Args: :Vrb: | ndarray with V,r,b (Macleod-Boynton) color coordinates :cieobs: | luxpy._CIEOBS, optional | CMF set to use when getting the default M, which is | the xyz to lms conversion matrix. :scaling: | list of scaling factors for r and b dimensions. :M: | None, optional | Conversion matrix for going from XYZ to RGB (LMS) | If None, :cieobs: determines the M (function does inversion) :Minverted: | False, optional | Bool that determines whether M should be inverted. Returns: :xyz: | ndarray with tristimulus values Reference: 1. `MacLeod DI, and Boynton RM (1979). Chromaticity diagram showing cone excitation by stimuli of equal luminance. J. Opt. Soc. Am. 69, 1183–1186. <https://www.osapublishing.org/josa/abstract.cfm?uri=josa-69-8-1183>`_ """ Vrb = np2d(Vrb) RGB = np.empty(Vrb.shape) RGB[..., 0] = Vrb[..., 1] * Vrb[..., 0] / scaling[0] RGB[..., 2] = Vrb[..., 2] * Vrb[..., 0] / scaling[1] RGB[..., 1] = Vrb[..., 0] - RGB[..., 0] if M is None: M = _CMF[cieobs]['M'] if Minverted == False: M = np.linalg.inv(M) if len(RGB.shape) == 3: return np.einsum('ij,klj->kli', M, RGB) else: return np.einsum('ij,lj->li', M, RGB)
def ndinterp1(X, Y, Xnew): """ Perform nd-dimensional linear interpolation using Delaunay triangulation. Args: :X: | ndarray with n-dimensional coordinates (last axis represents dimension). :Y: | ndarray with values at coordinates in X. :Xnew: | ndarray of new coordinates (last axis represents dimension). | When outside of the convex hull of X, then a best estimate is | given based on the closest vertices. Returns: :Ynew: | ndarray with new values at coordinates in Xnew. """ #get dimensions: n = Xnew.shape[-1] # create an object with triangulation tri = sp.spatial.Delaunay(X) # find simplexes that contain interpolated points s = tri.find_simplex(Xnew) # get the vertices for each simplex v = tri.vertices[s] # get transform matrices for each simplex (see explanation bellow) m = tri.transform[s] # for each interpolated point p, mutliply the transform matrix by # vector p-r, where r=m[:,n,:] is one of the simplex vertices to which # the matrix m is related to (again, see below) b = np.einsum('ijk,ik->ij', m[:,:n,:n], Xnew-m[:,n,:]) # get the weights for the vertices; `b` contains an n-dimensional vector # with weights for all but the last vertices of the simplex # (note that for n-D grid, each simplex consists of n+1 vertices); # the remaining weight for the last vertex can be copmuted from # the condition that sum of weights must be equal to 1 w = np.c_[b, 1-b.sum(axis=1)] # normalize weigths: w = w/w.sum(axis=1, keepdims=True) # interpolate: if Y[v].ndim == 3: Ynew = np.einsum('ijk,ij->ik', Y[v], w) else: Ynew = np.einsum('ij,ij->i', Y[v], w) return Ynew
def xyz_to_srgb(xyz, gamma=2.4, **kwargs): """ Calculates IEC:61966 sRGB values from xyz. Args: :xyz: | ndarray with relative tristimulus values. :gamma: | 2.4, optional | compression in sRGB Returns: :rgb: | ndarray with R,G,B values (uint8). """ xyz = np2d(xyz) # define 3x3 matrix M = np.array([[3.2404542, -1.5371385, -0.4985314], [-0.9692660, 1.8760108, 0.0415560], [0.0556434, -0.2040259, 1.0572252]]) if len(xyz.shape) == 3: srgb = np.einsum('ij,klj->kli', M, xyz / 100) else: srgb = np.einsum('ij,lj->li', M, xyz / 100) # perform clipping: srgb[np.where(srgb > 1)] = 1 srgb[np.where(srgb < 0)] = 0 # test for the dark colours in the non-linear part of the function: dark = np.where(srgb <= 0.0031308) # apply gamma function: g = 1 / gamma # and scale to range 0-255: rgb = srgb.copy() rgb = (1.055 * rgb**g - 0.055) * 255 # non-linear bit for dark colours rgb[dark] = (srgb[dark].copy() * 12.92) * 255 # clip to range: rgb[rgb > 255] = 255 rgb[rgb < 0] = 0 return rgb
def xyz_to_Vrb_mb(xyz, cieobs=_CIEOBS, scaling=[1, 1], M=None, **kwargs): """ Convert XYZ tristimulus values to V,r,b (Macleod-Boynton) color coordinates. | Macleod Boynton: V = R+G, r = R/V, b = B/V | Note that R,G,B ~ L,M,S Args: :xyz: | ndarray with tristimulus values :cieobs: | luxpy._CIEOBS, optional | CMF set to use when getting the default M, which is the xyz to lms conversion matrix. :scaling: | list of scaling factors for r and b dimensions. :M: | None, optional | Conversion matrix for going from XYZ to RGB (LMS) | If None, :cieobs: determines the M (function does inversion) Returns: :Vrb: | ndarray with V,r,b (Macleod-Boynton) color coordinates Reference: 1. `MacLeod DI, and Boynton RM (1979). Chromaticity diagram showing cone excitation by stimuli of equal luminance. J. Opt. Soc. Am. 69, 1183–1186. <https://www.osapublishing.org/josa/abstract.cfm?uri=josa-69-8-1183>`_ """ xyz = np2d(xyz) if M is None: M = _CMF[cieobs]['M'] if len(xyz.shape) == 3: RGB = np.einsum('ij,klj->kli', M, xyz) else: RGB = np.einsum('ij,lj->li', M, xyz) Vrb = np.empty(xyz.shape) Vrb[..., 0] = RGB[..., 0] + RGB[..., 1] Vrb[..., 1] = RGB[..., 0] / Vrb[..., 0] * scaling[0] Vrb[..., 2] = RGB[..., 2] / Vrb[..., 0] * scaling[1] return Vrb
def srgb_to_xyz(rgb, gamma=2.4, **kwargs): """ Calculates xyz from IEC:61966 sRGB values. Args: :rgb: | ndarray with srgb values (uint8). :gamma: | 2.4, optional | compression in sRGB Returns: :xyz: | ndarray with relative tristimulus values. """ rgb = np2d(rgb) # define 3x3 matrix M = np.array([[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]]) # scale device coordinates: sRGB = rgb / 255 # test for non-linear part of conversion nonlin = np.where((rgb / 255) < 0.0031308) #0.03928) # apply gamma function to convert to sRGB srgb = sRGB.copy() srgb = ((srgb + 0.055) / 1.055)**gamma srgb[nonlin] = sRGB[nonlin] / 12.92 if len(srgb.shape) == 3: xyz = np.einsum('ij,klj->kli', M, srgb) * 100 else: xyz = np.einsum('ij,lj->li', M, srgb) * 100 return xyz
def _cij_to_gij(xyz,C): """ Convert from matrix elements describing the discrimination ellipses from Cij (XYZ) to gij (Yxy)""" SIG = xyz[...,0] + xyz[...,1] + xyz[...,2] M1 = np.array([SIG, -SIG*xyz[...,0]/xyz[...,1], xyz[...,0]/xyz[...,1]]) M2 = np.array([np.zeros_like(SIG), np.zeros_like(SIG), np.ones_like(SIG)]) M3 = np.array([-SIG, -SIG*(xyz[...,1] + xyz[...,2])/xyz[...,1], xyz[...,2]/xyz[...,1]]) M = np.array((M1,M2,M3)) M = _transpose_02(M) # move stimulus dimension to axis = 0 C = _transpose_02(C) # move stimulus dimension to axis = 0 # convert Cij (XYZ) to gij' (xyY): AM = np.einsum('ij,kjl->kil', _M_XYZ_TO_PQS, M) CAM = np.einsum('kij,kjl->kil', C, AM) # ATCAM = np.einsum('ij,kjl->kil', _M_XYZ_TO_PQS.T, CAM) # gij = np.einsum('kij,kjl->kil', np.transpose(M,(0,2,1)), ATCAM) # gij = M.T*A.T*C**A*M = (AM).T*C*A*M gij = np.einsum('kij,kjl->kil', np.transpose(AM,(0,2,1)), CAM) # gij = M.T*A.T*C**A*M = (AM).T*C*A*M # convert gij' (xyY) to gij (Yxy): gij = np.roll(np.roll(gij,1,axis=2),1,axis=1) return gij
def jabz_to_xyz(jabz, ztype='jabz', **kwargs): """ Convert Jz,az,bz color coordinates to XYZ tristimulus values. Args: :jabz: | ndarray with Jz,az,bz color coordinates :ztype: | 'jabz', optional | String with requested return: | Options: 'jabz', 'iabz' Returns: :xyz: | ndarray with tristimulus values Note: | 1. :xyz: is assumed to be under D65 viewing conditions! If necessary perform chromatic adaptation! | | 2a. Jz represents the 'lightness' relative to a D65 white with luminance = 10000 cd/m² | (note that Jz that not exactly equal 1 for this high value, but rather for 102900 cd/m2) | 2b. az, bz represent respectively a red-green and a yellow-blue opponent axis | (but note that a D65 shows a small offset from (0,0)) Reference: 1. `Safdar, M., Cui, G., Kim,Y. J., and Luo, M. R. (2017). Perceptually uniform color space for image signals including high dynamic range and wide gamut. Opt. Express, vol. 25, no. 13, pp. 15131–15151, June, 2017. <http://www.opticsexpress.org/abstract.cfm?URI=oe-25-13-15131>`_ """ jabz = np2d(jabz) # Convert Jz to Iz: jabz[..., 0] = (jabz[..., 0] + 1.6295499532821566e-11) / ( 1 - 0.56 * (1 - (jabz[..., 0] + 1.6295499532821566e-11))) # Convert Iabz to lmsp: M = np.linalg.inv( np.array([[0.5, 0.5, 0], [3.524000, -4.066708, 0.542708], [0.199076, 1.096799, -1.295875]])) if len(jabz.shape) == 3: lmsp = np.einsum('ij,klj->kli', M, jabz) else: lmsp = np.einsum('ij,lj->li', M, jabz) # Convert lmsp to lms: lms = 10000 * (((3424 / 2**12) - lmsp**(1 / (1.7 * 2523 / 2**5))) / (((2392 / 2**7) * lmsp**(1 / (1.7 * 2523 / 2**5))) - (2413 / 2**7)))**(1 / (2610 / (2**14))) # Convert lms to xyz: # Setup X',Y',Z' from X,Y,Z transform as matrix: b = 1.15 g = 0.66 M_to_xyzp = np.array([[b, 0, 1 - b], [1 - g, g, 0], [0, 0, 1]]) # Define X',Y',Z' to L,M,S conversion matrix: M_to_lms = np.array([[0.41478972, 0.579999, 0.0146480], [-0.2015100, 1.120649, 0.0531008], [-0.0166008, 0.264800, 0.6684799]]) # Premultiply M_to_xyzp and M_to_lms and invert: M = M_to_lms @ M_to_xyzp M = np.linalg.inv(M) # Transform L,M,S to X,Y,Z: if len(jabz.shape) == 3: xyz = np.einsum('ij,klj->kli', M, lms) else: xyz = np.einsum('ij,lj->li', M, lms) return xyz
def discrimination_hotelling_t2(Yxy1, Yxy2, etype = 'fmc2', ellipsoid = True, Y1 = None, Y2 = None, cspace = 'Yxy'): """ Check 'significance' of difference using Hotelling's T2 test on the centers Yxy1 and Yxy2 and their associate FMC-1/2 discrimination ellipses. Args: :Yxy1, Yxy2: | 2D ndarrays with [Y,]x,y coordinate centers. | If Yxy.shape[-1]==2: Y is added using the value from the Y-input argument. :etype: | 'fmc2', optional | Type of FMC color discrimination equations to use (see references below). | options: 'fmc1', fmc2' :Y1, Y2: | None, optional | Only affects FMC-2 (see note below). | If not None: Yi = 10.69 and overrides values in Yxyi. :ellipsoid: | True, optional | If True: return ellipsoids, else return ellipses (only if cspace == 'Yxy')! :cspace: | 'Yxy', optional | Return coefficients for Yxy-ellipses/ellipsoids ('Yxy') or XYZ ellipsoids ('xyz') Returns: :p: | Chi-square based p-value :T2: | T2 test statistic (= mahalanobis distance on summed standard error cov. matrices) Steps: 1. For each center coordinate, the standard error covariance matrix gij^-1 = Si/ni is determined using the FMC-1 or FMC-2 equations (see refs. 1 & 2). 2. Calculate sum of covariance matrices: SIG = S1/n1 + S2/n2 = gij1^-1 + gij2^-1 3. These are then used in Hotelling's T2 test: T2 = (xy1 - xy2).T*(SIG^-1)*(xy1_xy2) 4. The T2 statistic is then tested against a Chi-square distribution with 2 or 3 degrees of freedom. References: 1. Chickering, K.D. (1967), Optimization of the MacAdam-Modified 1965 Friele Color-Difference Formula, 57(4):537-541 2. Chickering, K.D. (1971), FMC Color-Difference Formulas: Clarification Concerning Usage, 61(1):118-122 """ if Yxy1.shape[-1] == 2: Yxy1 = np.hstack((100*np.ones((Yxy1.shape[0],1)),Yxy1)) if Y1 is not None: Yxy1[...,0] = Y1 if Yxy2.shape[-1] == 2: Yxy2 = np.hstack((100*np.ones((Yxy2.shape[0],1)),Yxy2)) if Y2 is not None: Yxy2[...,0] = Y2 # Get gij matrices (i.e. inverse covariance matrices): gij1 = get_gij_fmc(Yxy1, etype = etype, ellipsoid=ellipsoid, Y = Y1, cspace = cspace) gij2 = get_gij_fmc(Yxy2, etype = etype, ellipsoid=ellipsoid, Y = Y2, cspace = cspace) df = gij1.shape[1] # get degrees of freedom for Chi2 # Calculate T2 statistic: D12 = (Yxy1[...,(3-df):] - Yxy2[...,(3-df):]) SIG12 = np.linalg.inv(np.linalg.inv(gij1) + np.linalg.inv(gij2)) T2 = np.einsum('ki,ki->k',D12,np.einsum('kij,kj->ki',SIG12,D12)) #T2 = np.atleast_2d(T2).T # Get p-value: p = sp.stats.distributions.chi2.sf(T2, df) return p, T2
def ipt_to_xyz(ipt, cieobs=_CIEOBS, xyzw=None, M=None, **kwargs): """ Convert XYZ tristimulus values to IPT color coordinates. | I: Lightness axis, P, red-green axis, T: yellow-blue axis. Args: :ipt: | ndarray with IPT color coordinates :xyzw: | None or ndarray with tristimulus values of white point, optional | None defaults to xyz of CIE D65 using the :cieobs: observer. :cieobs: | luxpy._CIEOBS, optional | CMF set to use when calculating xyzw for rescaling Mxyz2lms | (only when not None). :M: | None, optional | None defaults to xyz to lms conversion matrix determined by:cieobs: Returns: :xyz: | ndarray with tristimulus values Note: :xyz: is assumed to be under D65 viewing conditions! If necessary perform chromatic adaptation ! Reference: 1. `Ebner F, and Fairchild MD (1998). Development and testing of a color space (IPT) with improved hue uniformity. In IS&T 6th Color Imaging Conference, (Scottsdale, Arizona, USA), pp. 8–13. <http://www.ingentaconnect.com/content/ist/cic/1998/00001998/00000001/art00003?crawler=true>`_ """ ipt = np2d(ipt) # get M to convert xyz to lms and apply normalization to matrix or input your own: if M is None: M = _IPT_M['xyz2lms'][cieobs].copy( ) # matrix conversions from xyz to lms if xyzw is None: xyzw = spd_to_xyz(_CIE_ILLUMINANTS['D65'], cieobs=cieobs, out=1) / 100.0 else: xyzw = xyzw / 100.0 M = math.normalize_3x3_matrix(M, xyzw) # convert from ipt to lms': if len(ipt.shape) == 3: lmsp = np.einsum('ij,klj->kli', np.linalg.inv(_IPT_M['lms2ipt']), ipt) else: lmsp = np.einsum('ij,lj->li', np.linalg.inv(_IPT_M['lms2ipt']), ipt) # reverse response compression: lms' to lms lms = lmsp**(1.0 / 0.43) p = np.where(lmsp < 0.0) lms[p] = -np.abs(lmsp[p])**(1.0 / 0.43) # convert from lms to xyz: if np.ndim(M) == 2: if len(ipt.shape) == 3: xyz = np.einsum('ij,klj->kli', np.linalg.inv(M), lms) else: xyz = np.einsum('ij,lj->li', np.linalg.inv(M), lms) else: if len( ipt.shape ) == 3: # second dim of lms must match dim of 1st of M and 1st dim of xyzw xyz = np.concatenate([ np.einsum('ij,klj->kli', np.linalg.inv(M[i]), lms[:, i:i + 1, :]) for i in range(M.shape[0]) ], axis=1) else: # first dim of lms must match dim of 1st of M and 1st dim of xyzw xyz = np.concatenate([ np.einsum('ij,lj->li', np.linalg.inv(M[i]), lms[i:i + 1, :]) for i in range(M.shape[0]) ], axis=0) #xyz = np.dot(np.linalg.inv(M),lms.T).T xyz = xyz * 100.0 xyz[np.where(xyz < 0.0)] = 0.0 return xyz
def jabz_to_xyz(jabz, ztype='jabz', use_zcam_parameters=False, **kwargs): """ Convert Jz,az,bz color coordinates to XYZ tristimulus values. Args: :jabz: | ndarray with Jz,az,bz color coordinates :ztype: | 'jabz', optional | String with requested return: | Options: 'jabz', 'iabz' :use_zcam_parameters: | False, optional | ZCAM uses a slightly different values (see notes) Returns: :xyz: | ndarray with tristimulus values Note: | 1. :xyz: is assumed to be under D65 viewing conditions! If necessary perform chromatic adaptation! | | 2a. Jz represents the 'lightness' relative to a D65 white with luminance = 10000 cd/m² | (note that Jz that not exactly equal 1 for this high value, but rather for 102900 cd/m2) | 2b. az, bz represent respectively a red-green and a yellow-blue opponent axis | (but note that a D65 shows a small offset from (0,0)) | 3. ZCAM: uses a slightly different value for b (=0.7 instead of 0.66) | and calculates Iz as M' -epsilon (instead L'/2 + M'/2). Reference: 1. `Safdar, M., Cui, G., Kim,Y. J., and Luo, M. R. (2017). Perceptually uniform color space for image signals including high dynamic range and wide gamut. Opt. Express, vol. 25, no. 13, pp. 15131–15151, June, 2017. <http://www.opticsexpress.org/abstract.cfm?URI=oe-25-13-15131>`_ 2. Safdar, M., Hardeberg, J.Y., Luo, M.R. (2021) "ZCAM, a psychophysical model for colour appearance prediction", Optics Express. """ jabz = np2d(jabz) # Convert Jz to Iz: if ztype == 'jabz': jabz[..., 0] = (jabz[..., 0] + 1.6295499532821566e-11) / ( 1 - 0.56 * (1 - (jabz[..., 0] + 1.6295499532821566e-11))) # Convert Iabz to lmsp: # Transform L',M',S' to Iabz: if use_zcam_parameters: epsilon = 3.70352262101900054e-11 M = _M_LMSP_TO_IAB_ZCAM else: epsilon = 0 M = _M_LMSP_TO_IAB_JABZ jabz[..., 0] += epsilon if len(jabz.shape) == 3: lmsp = np.einsum('ij,klj->kli', np.linalg.inv(M), jabz) else: lmsp = np.einsum('ij,lj->li', np.linalg.inv(M), jabz) # Convert lmsp to lms: c1, c2, c3, n, p = [ _PQ_PARAMETERS[x] for x in sorted(_PQ_PARAMETERS.keys()) ] lms = 10000 * ((c1 - lmsp**(1 / p)) / ((c3 * lmsp**(1 / p)) - c2))**(1 / n) # Convert lms to xyz: # Setup X',Y',Z' from X,Y,Z transform as matrix: b = 1.15 g = 0.66 M_to_xyzp = np.array([[b, 0, 1 - b], [1 - g, g, 0], [0, 0, 1]]) # Premultiply M_to_xyzp and M_to_lms and invert: M = np.linalg.inv(_M_XYZP_TO_LMS @ M_to_xyzp) # Transform L,M,S to X,Y,Z: if len(jabz.shape) == 3: xyz = np.einsum('ij,klj->kli', M, lms) else: xyz = np.einsum('ij,lj->li', M, lms) return xyz
def xyz_to_jabz(xyz, ztype='jabz', use_zcam_parameters=False, **kwargs): """ Convert XYZ tristimulus values to Jz,az,bz color coordinates. Args: :xyz: | ndarray with absolute tristimulus values (Y in cd/m²!) :ztype: | 'jabz', optional | String with requested return: | Options: 'jabz', 'iabz' :use_zcam_parameters: | False, optional | ZCAM uses a slightly different values (see notes) Returns: :jabz: | ndarray with Jz (or Iz), az, bz color coordinates Notes: | 1. :xyz: is assumed to be under D65 viewing conditions! If necessary perform chromatic adaptation! | | 2a. Jz represents the 'lightness' relative to a D65 white with luminance = 10000 cd/m² | (note that Jz that not exactly equal 1 for this high value, but rather for 102900 cd/m2) | 2b. az, bz represent respectively a red-green and a yellow-blue opponent axis | (but note that a D65 shows a small offset from (0,0)) | 3. ZCAM: uses a slightly different value for b (=0.7 instead of 0.66) | and calculates Iz as M' -epsilon (instead L'/2 + M'/2). Reference: 1. `Safdar, M., Cui, G., Kim,Y. J., and Luo, M. R. (2017). Perceptually uniform color space for image signals including high dynamic range and wide gamut. Opt. Express, vol. 25, no. 13, pp. 15131–15151, June 2017. <http://www.opticsexpress.org/abstract.cfm?URI=oe-25-13-15131>`_ 2. Safdar, M., Hardeberg, J.Y., Luo, M.R. (2021) "ZCAM, a psychophysical model for colour appearance prediction", Optics Express. """ xyz = np2d(xyz) # Setup X,Y,Z to X',Y',Z' transform as matrix: b = 1.15 g = 0.66 M_to_xyzp = np.array([[b, 0, 1 - b], [1 - g, g, 0], [0, 0, 1]]) # Premultiply _M_XYZP_TO_LMS and M_to_lms: M = _M_XYZP_TO_LMS @ M_to_xyzp # Transform X,Y,Z to L,M,S: if len(xyz.shape) == 3: lms = np.einsum('ij,klj->kli', M, xyz) else: lms = np.einsum('ij,lj->li', M, xyz) # response compression: lms to lms' c1, c2, c3, n, p = [ _PQ_PARAMETERS[x] for x in sorted(_PQ_PARAMETERS.keys()) ] lmsp = ((c1 + c2 * (lms / 10000)**n) / (1 + c3 * (lms / 10000)**n))**p # Transform L',M',S' to Iabz: if use_zcam_parameters: epsilon = 3.70352262101900054e-11 M = _M_LMSP_TO_IAB_ZCAM else: epsilon = 0 M = _M_LMSP_TO_IAB_JABZ if len(lms.shape) == 3: Iabz = np.einsum('ij,klj->kli', M, lmsp) else: Iabz = np.einsum('ij,lj->li', M, lmsp) Iabz[..., 0] -= epsilon # correct to ensure zero output # convert Iabz' to Jabz coordinates: if ztype == 'jabz': Iabz[..., 0] = ((1 - 0.56) * Iabz[..., 0] / (1 - 0.56 * Iabz[..., 0])) - 1.6295499532821566e-11 return Iabz