def plotcircle(center = np.array([0.,0.]),\ radii = np.arange(0,60,10), \ angles = np.arange(0,350,10),\ color = 'k',linestyle = '--', out = None): """ Plot one or more concentric circles. Args: :center: | np.array([0.,0.]) or ndarray with center coordinates, optional :radii: | np.arange(0,60,10) or ndarray with radii of circle(s), optional :angles: | np.arange(0,350,10) or ndarray with angles (°), optional :color: | 'k', optional | Color for plotting. :linestyle: | '--', optional | Linestyle of circles. :out: | None, optional | If None: plot circles, return (x,y) otherwise. """ xs = np.array([0]) ys = xs.copy() for ri in radii: x = ri*np.cos(angles*np.pi/180) y = ri*np.sin(angles*np.pi/180) xs = np.hstack((xs,x)) ys = np.hstack((ys,y)) if out != 'x,y': plt.plot(x,y,color = color, linestyle = linestyle) if out == 'x,y': return xs,ys
def fit_ellipse(xy): """ Fit an ellipse to supplied data points. Args: :xy: | coordinates of points to fit (Nx2 array) Returns: :v: | vector with ellipse parameters [Rmax,Rmin, xc,yc, theta] """ # remove centroid: center = xy.mean(axis=0) xy = xy - center # Fit ellipse: x, y = xy[:, 0:1], xy[:, 1:2] D = np.hstack((x * x, x * y, y * y, x, y, np.ones_like(x))) S, C = np.dot(D.T, D), np.zeros([6, 6]) C[0, 2], C[2, 0], C[1, 1] = 2, 2, -1 U, s, V = np.linalg.svd(np.dot(np.linalg.inv(S), C)) e = U[:, 0] # get ellipse axis lengths, center and orientation: b, c, d, f, g, a = e[1] / 2, e[2], e[3] / 2, e[4] / 2, e[5], e[0] # get ellipse center: num = b * b - a * c xc = ((c * d - b * f) / num) + center[0] yc = ((a * f - b * d) / num) + center[1] # get ellipse orientation: theta = np.arctan2(np.array(2 * b), np.array((a - c))) / 2 # axis lengths: up = 2 * (a * f * f + c * d * d + g * b * b - 2 * b * d * f - a * c * g) down1 = (b * b - a * c) * ((c - a) * np.sqrt(1 + 4 * b * b / ((a - c) * (a - c))) - (c + a)) down2 = (b * b - a * c) * ((a - c) * np.sqrt(1 + 4 * b * b / ((a - c) * (a - c))) - (c + a)) a, b = np.sqrt(up / down1), np.sqrt(up / down2) # assert that a is the major axis (otherwise swap and correct angle) if (b > a): b, a = a, b # ensure the angle is betwen 0 and 2*pi theta = fmod(theta, 2.0 * np.pi) return np.hstack((a, b, xc, yc, theta))
def xyz_to_cct_HA(xyzw): """ Convert XYZ tristimulus values to correlated color temperature (CCT). Args: :xyzw: | ndarray of tristimulus values Returns: :cct: | ndarray of correlated color temperatures estimates References: 1. `Hernández-Andrés, Javier; Lee, RL; Romero, J (September 20, 1999). Calculating Correlated Color Temperatures Across the Entire Gamut of Daylight and Skylight Chromaticities. Applied Optics. 38 (27), 5703–5709. P <https://www.osapublishing.org/ao/abstract.cfm?uri=ao-38-27-5703>`_ Notes: According to paper small error from 3000 - 800 000 K, but a test with Planckians showed errors up to 20% around 500 000 K; e>0.05 for T>200 000, e>0.1 for T>300 000, ... """ if len(xyzw.shape)>2: raise Exception('xyz_to_cct_HA(): Input xyzw.ndim must be <= 2 !') out_of_range_code = np.nan xe = [0.3366, 0.3356] ye = [0.1735, 0.1691] A0 = [-949.86315, 36284.48953] A1 = [6253.80338, 0.00228] t1 = [0.92159, 0.07861] A2 = [28.70599, 5.4535*1e-36] t2 = [0.20039, 0.01543] A3 = [0.00004, 0.0] t3 = [0.07125,1.0] cct_ranges = np.array([[3000.0,50000.0],[50000.0,800000.0]]) Yxy = xyz_to_Yxy(xyzw) CCT = np.ones((1,Yxy.shape[0]))*out_of_range_code for i in range(2): n = (Yxy[:,1]-xe[i])/(Yxy[:,2]-ye[i]) CCT_i = np2d(np.array(A0[i] + A1[i]*np.exp(np.divide(-n,t1[i])) + A2[i]*np.exp(np.divide(-n,t2[i])) + A3[i]*np.exp(np.divide(-n,t3[i])))) p = (CCT_i >= (1.0-0.05*(i == 0))*cct_ranges[i][0]) & (CCT_i < (1.0+0.05*(i == 0))*cct_ranges[i][1]) CCT[p] = CCT_i[p] p = (CCT_i < (1.0-0.05)*cct_ranges[0][0]) #smaller than smallest valid CCT value CCT[p] = -1 if (np.isnan(CCT.sum()) == True) | (np.any(CCT == -1)): print("Warning: xyz_to_cct_HA(): one or more CCTs out of range! --> (CCT < 3 kK, CCT >800 kK) coded as (-1, NaN) 's") return CCT.T
def normalize_3x3_matrix(M, xyz0=np.array([[1.0, 1.0, 1.0]])): """ Normalize 3x3 matrix M to xyz0 -- > [1,1,1] If M.shape == (1,9): M is reshaped to (3,3) Args: :M: | ndarray((3,3) or ndarray((1,9)) :xyz0: | 2darray, optional Returns: :returns: | normalized matrix such that M*xyz0 = [1,1,1] """ M = np2d(M) if M.shape[-1] == 9: M = M.reshape(3, 3) if xyz0.shape[0] == 1: return np.dot(np.diagflat(1 / (np.dot(M, xyz0.T))), M) else: return np.concatenate([ np.dot(np.diagflat(1 / (np.dot(M, xyz0[1].T))), M) for i in range(xyz0.shape[0]) ], axis=0).reshape(xyz0.shape[0], 3, 3)
def line_intersect(a1, a2, b1, b2): """ Line intersections of series of two line segments a and b. Args: :a1: | ndarray (.shape = (N,2)) specifying end-point 1 of line a :a2: | ndarray (.shape = (N,2)) specifying end-point 2 of line a :b1: | ndarray (.shape = (N,2)) specifying end-point 1 of line b :b2: | ndarray (.shape = (N,2)) specifying end-point 2 of line b Note: N is the number of line segments a and b. Returns: :returns: | ndarray with line-intersections (.shape = (N,2)) References: 1. https://stackoverflow.com/questions/3252194/numpy-and-line-intersections """ T = np.array([[0.0, -1.0], [1.0, 0.0]]) da = np.atleast_2d(a2 - a1) db = np.atleast_2d(b2 - b1) dp = np.atleast_2d(a1 - b1) dap = np.dot(da, T) denom = np.sum(dap * db, axis=1) num = np.sum(dap * dp, axis=1) return np.atleast_2d(num / denom).T * db + b1
def smet2017_D(xyzw, Dmax=None, cieobs='1964_10'): """ Calculate the degree of adaptation based on chromaticity following Smet et al. (2017) Args: :xyzw: | ndarray with white point data :Dmax: | None or float, optional | Defaults to 0.6539 (max D obtained under experimental conditions, but probably too low due to dark surround leading to incomplete chromatic adaptation even for neutral illuminants resulting in background luminance (fov~50°) of 760 cd/m²)) :cieobs: | '1964_10', optional | CMF set used in deriving model in cited paper. Returns: :D: | ndarray with degrees of adaptation References: 1. `Smet, K.A.G.*, Zhai, Q., Luo, M.R., Hanselaer, P., (2017), Study of chromatic adaptation using memory color matches, Part II: colored illuminants, Opt. Express, 25(7), pp. 8350-8365. <https://www.osapublishing.org/oe/abstract.cfm?uri=oe-25-7-8350&origin=search)>`_ """ # Convert xyzw to log-compressed Macleod_Boyton coordinates: Vl, rl, bl = asplit(np.log(xyz_to_Vrb_mb(xyzw, cieobs=cieobs))) # apply Dmodel (technically only for cieobs = '1964_10') pD = (1.0e7) * np.array([ 0.021081326530436, 4.751255762876845, -0.000000071025181, -0.000000063627042, -0.146952821492957, 3.117390441655821 ]) #D model parameters for gaussian model in log(MB)-space (july 2016) if Dmax is None: Dmax = 0.6539 # max D obtained under experimental conditions (probably too low due to dark surround leading to incomplete chromatic adaptation even for neutral illuminants resulting in background luminance (fov~50°) of 760 cd/m²) return Dmax * math.bvgpdf(x=rl, y=bl, mu=pD[2:4], sigmainv=np.linalg.inv( np.array([[pD[0], pD[4]], [pD[4], pD[1]] ])))**pD[5]
def normalize_to_Lw(Ill, Lw, cieobs, rflM): xyzw = lx.spd_to_xyz(Ill, cieobs = cieobs, relative = False) for i in range(Ill.shape[0]-1): Ill[i+1] = Lw*Ill[i+1]/xyzw[i,1] IllM = [] for i in range(Ill.shape[0]-1): IllM.append(np.vstack((Ill1[0],Ill[i+1]*rflM[1:,:]))) IllM = np.transpose(np.array(IllM),(1,0,2)) return Ill, IllM
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 np3d(data): """ Make a tuple, list or numpy array at least a 3d numpy array. Args: :data: | tuple, list, ndarray Returns: :returns: | ndarray with .ndim >= 3 """ if isinstance(data, np.ndarray):# assume already atleast_3d when nd.array (user has to ensure input is an array) if (len(data.shape)>=3): return data else: return np.expand_dims(np.atleast_2d(data),axis=0) else: return np.expand_dims(np.atleast_2d(np.array(data)),axis=0)
def np2dT(data): """ Make a tuple, list or numpy array at least a 2D numpy array and transpose. Args: :data: | tuple, list, ndarray Returns: :returns: | ndarray with .ndim >= 2 and with transposed axes. """ if isinstance(data, np.ndarray):# assume already atleast_2d when nd.array (user has to ensure input is an array) if (len(data.shape)>=2): return data.T else: return np.atleast_2d(data).T else: return np.atleast_2d(np.array(data)).T
def normalize_3x3_matrix(M, xyz0 = np.array([[1.0,1.0,1.0]])): """ Normalize 3x3 matrix M to xyz0 -- > [1,1,1] If M.shape == (1,9): M is reshaped to (3,3) Args: :M: | ndarray((3,3) or ndarray((1,9)) :xyz0: | 2darray, optional Returns: :returns: | normalized matrix such that M*xyz0 = [1,1,1] """ M = np2d(M) if M.shape[-1]==9: M = M.reshape(3,3) return np.dot(np.diagflat(1/(np.dot(M,xyz0.T))),M)
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 generate_grid(jab_ranges = None, out = 'grid', \ ax = np.arange(-_VF_MAXR,_VF_MAXR+_VF_DELTAR,_VF_DELTAR),\ bx = np.arange(-_VF_MAXR,_VF_MAXR+_VF_DELTAR,_VF_DELTAR), \ jx = None, limit_grid_radius = 0): """ Generate a grid of color coordinates. Args: :out: | 'grid' or 'vectors', optional | - 'grid': outputs a single 2d numpy.nd-vector with the grid coordinates | - 'vector': outputs each dimension seperately. :jab_ranges: | None or ndarray, optional | Specifies the pixelization of color space. (ndarray.shape = (3,3), with first axis: J,a,b, and second axis: min, max, delta) :ax: | default ndarray or user defined ndarray, optional | default = np.arange(-_VF_MAXR,_VF_MAXR+_VF_DELTAR,_VF_DELTAR) :bx: | default ndarray or user defined ndarray, optional | default = np.arange(-_VF_MAXR,_VF_MAXR+_VF_DELTAR,_VF_DELTAR) :jx: | None, optional | Note that not-None :jab_ranges: override :ax:, :bx: and :jx input. :limit_grid_radius: | 0, optional | A value of zeros keeps grid as specified by axr,bxr. | A value > 0 only keeps (a,b) coordinates within :limit_grid_radius: Returns: :returns: | single ndarray with ax,bx [,jx] | or | seperate ndarrays for each dimension specified. """ # generate grid from jab_ranges array input, otherwise use ax, bx, jx input: if jab_ranges is not None: if jab_ranges.shape[0] == 3: jx = np.arange(jab_ranges[0][0],jab_ranges[0][1],jab_ranges[0][2]) ax = np.arange(jab_ranges[1][0],jab_ranges[1][1],jab_ranges[1][2]) bx = np.arange(jab_ranges[2][0],jab_ranges[2][1],jab_ranges[2][2]) else: jx = None ax = np.arange(jab_ranges[0][0],jab_ranges[0][1],jab_ranges[0][2]) bx = np.arange(jab_ranges[1][0],jab_ranges[1][1],jab_ranges[1][2]) # Generate grid from (jx), ax, bx: Ax,Bx = np.meshgrid(ax,bx) grid = np.dstack((Ax,Bx)) grid = np.reshape(grid,(np.array(grid.shape[:-1]).prod(),grid.ndim-1)) if jx is not None: for i,v in enumerate(jx): gridi = np.hstack((np.ones((grid.shape[0],1))*v,grid)) if i == 0: gridwithJ = gridi else: gridwithJ = np.vstack((gridwithJ,gridi)) grid = gridwithJ if jx is None: ax = grid[:,0:1] bx = grid[:,1:2] else: jx = grid[:,0:1] ax = grid[:,1:2] bx = grid[:,2:3] if limit_grid_radius > 0:# limit radius of grid: Cr = (ax**2+bx**2)**0.5 ax = ax[Cr<=limit_grid_radius,None] bx = bx[Cr<=limit_grid_radius,None] if jx is not None: jx = jx[Cr<=limit_grid_radius,None] # create output: if out == 'grid': if jx is None: return np.hstack((ax,bx)) else: return np.hstack((jx,ax,bx)) else: if jx is None: return ax, bx else: return jx, ax, bx
def cam15u(data, fov=10.0, inputtype='xyz', direction='forward', outin='Q,aW,bW', parameters=None): """ Convert between CIE 2006 10° XYZ tristimulus values (or spectral data) and CAM15u color appearance correlates. Args: :data: | ndarray of CIE 2006 10° XYZ tristimulus values or spectral data or color appearance attributes :fov: | 10.0, optional | Field-of-view of stimulus (for size effect on brightness) :inputtpe: | 'xyz' or 'spd', optional | Specifies the type of input: | tristimulus values or spectral data for the forward mode. :direction: | 'forward' or 'inverse', optional | -'forward': xyz -> cam15u | -'inverse': cam15u -> xyz :outin: | 'Q,aW,bW' or str, optional | 'Q,aW,bW' (brightness and opponent signals for amount-of-neutral) | other options: 'Q,aM,bM' (colorfulness) and 'Q,aS,bS' (saturation) | Str specifying the type of | input (:direction: == 'inverse') and | output (:direction: == 'forward') :parameters: | None or dict, optional | Set of model parameters. | - None: defaults to luxpy.cam._CAM15U_PARAMETERS | (see references below) Returns: :returns: | ndarray with color appearance correlates (:direction: == 'forward') | or | XYZ tristimulus values (:direction: == 'inverse') References: 1. `M. Withouck, K. A. G. Smet, W. R. Ryckaert, and P. Hanselaer, “Experimental driven modelling of the color appearance of unrelated self-luminous stimuli: CAM15u,” Opt. Express, vol. 23, no. 9, pp. 12045–12064, 2015. <https://www.osapublishing.org/oe/abstract.cfm?uri=oe-23-9-12045&origin=search>`_ 2. `M. Withouck, K. A. G. Smet, and P. Hanselaer, (2015), “Brightness prediction of different sized unrelated self-luminous stimuli,” Opt. Express, vol. 23, no. 10, pp. 13455–13466. <https://www.osapublishing.org/oe/abstract.cfm?uri=oe-23-10-13455&origin=search>`_ """ if parameters is None: parameters = _CAM15U_PARAMETERS outin = outin.split(',') #unpack model parameters: Mxyz2rgb, cA, cAlms, cHK, cM, cW, ca, calms, cb, cblms, cfov, cp, k, unique_hue_data = [ parameters[x] for x in sorted(parameters.keys()) ] # precomputations: invMxyz2rgb = np.linalg.inv(Mxyz2rgb) MAab = np.array([cAlms, calms, cblms]) invMAab = np.linalg.inv(MAab) #initialize data and camout: data = np2d(data) if len(data.shape) == 2: data = np.expand_dims(data, axis=0) # avoid looping if not necessary if (data.shape[0] > data.shape[1]): # loop over shortest dim. flipaxis0and1 = True data = np.transpose(data, axes=(1, 0, 2)) else: flipaxis0and1 = False dshape = list(data.shape) dshape[-1] = len(outin) # requested number of correlates if (inputtype != 'xyz') & (direction == 'forward'): dshape[-2] = dshape[ -2] - 1 # wavelength row doesn't count & only with forward can the input data be spectral camout = np.nan * np.ones(dshape) for i in range(data.shape[0]): if (inputtype != 'xyz') & (direction == 'forward'): xyz = spd_to_xyz(data[i], cieobs='2006_10', relative=False) lms = np.dot(_CMF['2006_10']['M'], xyz.T).T # convert to l,m,s rgb = (lms / _CMF['2006_10']['K']) * k # convert to rho, gamma, beta elif (inputtype == 'xyz') & (direction == 'forward'): rgb = np.dot(Mxyz2rgb, data[i].T).T if direction == 'forward': # apply cube-root compression: rgbc = rgb**(cp) # calculate achromatic and color difference signals, A, a, b: Aab = np.dot(MAab, rgbc.T).T A, a, b = asplit(Aab) A = cA * A a = ca * a b = cb * b # calculate colorfullness like signal M: M = cM * ((a**2.0 + b**2.0)**0.5) # calculate brightness Q: Q = A + cHK[0] * M**cHK[ 1] # last term is contribution of Helmholtz-Kohlrausch effect on brightness # calculate saturation, s: s = M / Q # calculate amount of white, W: W = 100.0 / (1.0 + cW[0] * (s**cW[1])) # adjust Q for size (fov) of stimulus (matter of debate whether to do this before or after calculation of s or W, there was no data on s, M or W for different sized stimuli: after) Q = Q * (fov / 10.0)**cfov # calculate hue, h and Hue quadrature, H: h = hue_angle(a, b, htype='deg') if 'H' in outin: H = hue_quadrature(h, unique_hue_data=unique_hue_data) else: H = None # calculate cart. co.: if 'aM' in outin: aM = M * np.cos(h * np.pi / 180.0) bM = M * np.sin(h * np.pi / 180.0) if 'aS' in outin: aS = s * np.cos(h * np.pi / 180.0) bS = s * np.sin(h * np.pi / 180.0) if 'aW' in outin: aW = W * np.cos(h * np.pi / 180.0) bW = W * np.sin(h * np.pi / 180.0) if (outin != ['Q', 'aW', 'bW']): camout[i] = eval('ajoin((' + ','.join(outin) + '))') else: camout[i] = ajoin((Q, aW, bW)) elif direction == 'inverse': # get Q, M and a, b depending on input type: if 'aW' in outin: Q, a, b = asplit(data[i]) Q = Q / ( (fov / 10.0)**cfov ) #adjust Q for size (fov) of stimulus back to that 10° ref W = (a**2.0 + b**2.0)**0.5 s = (((100 / W) - 1.0) / cW[0])**(1.0 / cW[1]) M = s * Q if 'aM' in outin: Q, a, b = asplit(data[i]) Q = Q / ( (fov / 10.0)**cfov ) #adjust Q for size (fov) of stimulus back to that 10° ref M = (a**2.0 + b**2.0)**0.5 if 'aS' in outin: Q, a, b = asplit(data[i]) Q = Q / ( (fov / 10.0)**cfov ) #adjust Q for size (fov) of stimulus back to that 10° ref s = (a**2.0 + b**2.0)**0.5 M = s * Q if 'h' in outin: Q, WsM, h = asplit(data[i]) Q = Q / ( (fov / 10.0)**cfov ) #adjust Q for size (fov) of stimulus back to that 10° ref if 'W' in outin: s = (((100.0 / WsM) - 1.0) / cW[0])**(1.0 / cW[1]) M = s * Q elif 's' in outin: M = WsM * Q elif 'M' in outin: M = WsM # calculate achromatic signal, A from Q and M: A = Q - cHK[0] * M**cHK[1] A = A / cA # calculate hue angle: h = hue_angle(a, b, htype='rad') # calculate a,b from M and h: a = (M / cM) * np.cos(h) b = (M / cM) * np.sin(h) a = a / ca b = b / cb # create Aab: Aab = ajoin((A, a, b)) # calculate rgbc: rgbc = np.dot(invMAab, Aab.T).T # decompress rgbc to rgb: rgb = rgbc**(1 / cp) # convert rgb to xyz: xyz = np.dot(invMxyz2rgb, rgb.T).T camout[i] = xyz if flipaxis0and1 == True: # loop over shortest dim. camout = np.transpose(camout, axes=(1, 0, 2)) if camout.shape[0] == 1: camout = np.squeeze(camout, axis=0) return camout
def genMonteCarloObs(n_obs=1, fieldsize=10, list_Age=[32], out='LMS', wl=None, allow_negative_values=False): """ Monte-Carlo generation of individual observer cone fundamentals. Args: :n_obs: | 1, optional | Number of observer CMFs to generate. :list_Age: | list of observer ages or str, optional | Defaults to 32 (cfr. CIE2006 CMFs) | If 'us_census': use US population census of 2010 to generate list_Age. :fieldsize: | fieldsize in degrees (between 2° and 10°), optional | Defaults to 10°. :out: | 'LMS' or str, optional | Determines output. :wl: | None, optional | Interpolation/extraplation of :LMS: output to specified wavelengths. | None: output original _WL = np.array([390,780,5]) :allow_negative_values: | False, optional | Cone fundamentals or color matching functions | should not have negative values. | If False: X[X<0] = 0. Returns: :returns: | LMS [,var_age, vAll] | - LMS: ndarray with population LMS functions. | - var_age: ndarray with population observer ages. | - vAll: dict with population physiological factors (see .keys()) References: 1. `Asano Y, Fairchild MD, and Blondé L (2016). Individual Colorimetric Observer Model. PLoS One 11, 1–19. <http://journals.plos.org/plosone/article?id=10.1371/journal.pone.0145671>`_ 2. `Asano Y, Fairchild MD, Blondé L, and Morvan P (2016). Color matching experiment for highlighting interobserver variability. Color Res. Appl. 41, 530–539. <https://onlinelibrary.wiley.com/doi/abs/10.1002/col.21975>`_ 3. `CIE, and CIE (2006). Fundamental Chromaticity Diagram with Physiological Axes - Part I (Vienna: CIE). <http://www.cie.co.at/publications/fundamental-chromaticity-diagram-physiological-axes-part-1>`_ 4. `Asano's Individual Colorimetric Observer Model <https://www.rit.edu/cos/colorscience/re_AsanoObserverFunctions.php>`_ """ # Scale down StdDev by scalars optimized using Asano's 75 observers # collected in Germany: stdDevAllParam = _INDVCMF_STD_DEV_ALL_PARAM.copy() scale_factors = [0.98, 0.98, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] scale_factors = dict(zip(list(stdDevAllParam.keys()), scale_factors)) stdDevAllParam = { k: v * scale_factors[k] for (k, v) in stdDevAllParam.items() } # Get Normally-distributed Physiological Factors: vAll = getMonteCarloParam(n_obs=n_obs) if list_Age is 'us_census': list_Age = getUSCensusAgeDist() # Generate Random Ages with the same probability density distribution # as color matching experiment: sz_interval = 1 list_AgeRound = np.round(np.array(list_Age) / sz_interval) * sz_interval h = math.histogram(list_AgeRound, bins=np.unique(list_AgeRound), bin_center=True)[0] p = h / h.sum() # probability density distribution var_age = np.random.choice(np.unique(list_AgeRound), \ size = n_obs, replace = True,\ p = p) # Set requested wavelength range: if wl is not None: wl = getwlr(wl3=wl) else: wl = _WL LMS_All = np.nan * np.ones((3 + 1, wl.shape[0], n_obs)) for k in range(n_obs): t_LMS, t_trans_lens, t_trans_macula, t_sens_photopig = cie2006cmfsEx(age = var_age[k], fieldsize = fieldsize, wl = wl,\ var_od_lens = vAll['od_lens'][k], var_od_macula = vAll['od_macula'][k], \ var_od_L = vAll['od_L'][k], var_od_M = vAll['od_M'][k], var_od_S = vAll['od_S'][k],\ var_shft_L = vAll['shft_L'][k], var_shft_M = vAll['shft_M'][k], var_shft_S = vAll['shft_S'][k],\ out = 'LMS,trans_lens,trans_macula,sens_photopig') LMS_All[:, :, k] = t_LMS # listout = out.split(',') # if ('trans_lens' in listout) | ('trans_macula' in listout) | ('trans_photopig' in listout): # trans_lens[:,k] = t_trans_lens # trans_macula[:,k] = t_trans_macula # sens_photopig[:,:,k] = t_sens_photopig if n_obs == 1: LMS_All = np.squeeze(LMS_All, axis=2) if ('xyz' in out.lower().split(',')): LMS_All = lmsb_to_xyzb(LMS_All, fieldsize, out='xyz', allow_negative_values=allow_negative_values) out = out.replace('xyz', 'LMS').replace('XYZ', 'LMS') if ('lms' in out.lower().split(',')): out = out.replace('lms', 'LMS') if (out == 'LMS'): return LMS_All elif (out == 'LMS,var_age,vAll'): return LMS_All, var_age, vAll else: return eval(out)
#_INDVCMF_STD_DEV_ALL_PARAM_GE['shft_L'] = 2.0 #_INDVCMF_STD_DEV_ALL_PARAM_GE['shft_M'] = 1.5 #_INDVCMF_STD_DEV_ALL_PARAM_GE['shft_S'] = 1.3 # Define dict with Iteratively Derived Cat.Obs.: t_data = getdata(_INDVCMF_DATA_PATH + 'CatObsPfctr.dat', header=None).T dict_values = [t_data[:, i + 1] for i in range(t_data.shape[1] - 1)] dict_keys = list(_INDVCMF_STD_DEV_ALL_PARAM.keys()) _INDVCMF_CATOBSPFCTR = dict(zip(dict_keys, dict_values)) _INDVCMF_CATOBSPFCTR['age'] = t_data[:, 0] # Matrices for conversion from LMS cone fundamentals to XYZ CMFs: # (https://www.rit.edu/cos/colorscience/re_AsanoObserverFunctions.php) # For 2-degree, the 3x3 matrix is: _INDVCMF_M_2d = np.array([[0.4151, -0.2424, 0.0425], [0.1355, 0.0833, -0.0043], [-0.0093, 0.0125, 0.2136]]) # For 10-degree, the 3x3 matrix is: _INDVCMF_M_10d = np.array([[0.4499, -0.2630, 0.0460], [0.1617, 0.0726, -0.0011], [-0.0036, 0.0054, 0.2291]]) _WL_CRIT = 620 # Asano: 620 nm: wavelenght at which interpolation fails for S-cones _WL = getwlr([390, 780, 5]) # wavelength range of specrtal data in _INDVCMF_DATA def cie2006cmfsEx(age = 32,fieldsize = 10, wl = None,\ var_od_lens = 0, var_od_macula = 0, \ var_od_L = 0, var_od_M = 0, var_od_S = 0,\ var_shft_L = 0, var_shft_M = 0, var_shft_S = 0,\ out = 'LMS', allow_negative_values = False):
def cct_to_xyz(ccts, duv=None, cieobs=_CIEOBS, wl=None, mode='lut', out=None, accuracy=0.1, force_out_of_lut=True, upper_cct_max=10.0 * 20, approx_cct_temp=True): """ Convert correlated color temperature (CCT) and Duv (distance above (>0) or below (<0) the Planckian locus) to XYZ tristimulus values. | Finds xyzw_estimated by minimization of: | | F = numpy.sqrt(((100.0*(cct_min - cct)/(cct))**2.0) | + (((duv_min - duv)/(duv))**2.0)) | | with cct,duv the input values and cct_min, duv_min calculated using | luxpy.xyz_to_cct(xyzw_estimated,...). Args: :ccts: | ndarray of cct values :duv: | None or ndarray of duv values, optional | Note that duv can be supplied together with cct values in :ccts: as ndarray with shape (N,2) :cieobs: | luxpy._CIEOBS, optional | CMF set used to calculated xyzw. :mode: | 'lut' or 'search', optional | Determines what method to use. :out: | None (or 1), optional | If not None or 1: output a ndarray that contains estimated xyz and minimization results: | (cct_min, duv_min, F_min (objective fcn value)) :wl: | None, optional | Wavelengths used when calculating Planckian radiators. :accuracy: | float, optional | Stop brute-force search when cct :accuracy: is reached. :upper_cct_max: | 10.0**20, optional | Limit brute-force search to this cct. :approx_cct_temp: | True, optional | If True: use xyz_to_cct_HA() to get a first estimate of cct to speed up search. :force_out_of_lut: | True, optional | If True and cct is out of range of the LUT, then switch to brute-force search method, else return numpy.nan values. Returns: :returns: | ndarray with estimated XYZ tristimulus values Note: If duv is not supplied (:ccts:.shape is (N,1) and :duv: is None), source is assumed to be on the Planckian locus. """ # make ccts a min. 2d np.array: if isinstance(ccts, list): ccts = np2dT(np.array(ccts)) else: ccts = np2d(ccts) if len(ccts.shape) > 2: raise Exception('cct_to_xyz(): Input ccts.shape must be <= 2 !') # get cct and duv arrays from :ccts: cct = np2d(ccts[:, 0, None]) if (duv is None) & (ccts.shape[1] == 2): duv = np2d(ccts[:, 1, None]) elif duv is not None: duv = np2d(duv) #get estimates of approximate xyz values in case duv = None: BB = cri_ref(ccts=cct, wl3=wl, ref_type=['BB']) xyz_est = spd_to_xyz(data=BB, cieobs=cieobs, out=1) results = np.ones([ccts.shape[0], 3]) * np.nan if duv is not None: # optimization/minimization setup: def objfcn(uv_offset, uv0, cct, duv, out=1): #, cieobs = cieobs, wl = wl, mode = mode): uv0 = np2d(uv0 + uv_offset) Yuv0 = np.concatenate((np2d([100.0]), uv0), axis=1) cct_min, duv_min = xyz_to_cct(Yuv_to_xyz(Yuv0), cieobs=cieobs, out='cct,duv', wl=wl, mode=mode, accuracy=accuracy, force_out_of_lut=force_out_of_lut, upper_cct_max=upper_cct_max, approx_cct_temp=approx_cct_temp) F = np.sqrt(((100.0 * (cct_min[0] - cct[0]) / (cct[0]))**2.0) + (((duv_min[0] - duv[0]) / (duv[0]))**2.0)) if out == 'F': return F else: return np.concatenate((cct_min, duv_min, np2d(F)), axis=1) # loop through each xyz_est: for i in range(xyz_est.shape[0]): xyz0 = xyz_est[i] cct_i = cct[i] duv_i = duv[i] cct_min, duv_min = xyz_to_cct(xyz0, cieobs=cieobs, out='cct,duv', wl=wl, mode=mode, accuracy=accuracy, force_out_of_lut=force_out_of_lut, upper_cct_max=upper_cct_max, approx_cct_temp=approx_cct_temp) if np.abs(duv[i]) > _EPS: # find xyz: Yuv0 = xyz_to_Yuv(xyz0) uv0 = Yuv0[0][1:3] OptimizeResult = minimize(fun=objfcn, x0=np.zeros((1, 2)), args=(uv0, cct_i, duv_i, 'F'), method='Nelder-Mead', options={ "maxiter": np.inf, "maxfev": np.inf, 'xatol': 0.000001, 'fatol': 0.000001 }) betas = OptimizeResult['x'] #betas = np.zeros(uv0.shape) if out is not None: results[i] = objfcn(betas, uv0, cct_i, duv_i, out=3) uv0 = np2d(uv0 + betas) Yuv0 = np.concatenate((np2d([100.0]), uv0), axis=1) xyz_est[i] = Yuv_to_xyz(Yuv0) else: xyz_est[i] = xyz0 if (out is None) | (out == 1): return xyz_est else: # Also output results of minimization: return np.concatenate((xyz_est, results), axis=1)
def cam18sl(data, datab = None, Lb = [100], fov = 10.0, inputtype = 'xyz', direction = 'forward', outin = 'Q,aW,bW', parameters = None): """ Convert between CIE 2006 10° XYZ tristimulus values (or spectral data) and CAM18sl color appearance correlates. Args: :data: | ndarray of CIE 2006 10° absolute XYZ tristimulus values or spectral data or color appearance attributes of stimulus :datab: | ndarray of CIE 2006 10° absolute XYZ tristimulus values or spectral data of stimulus background :Lb: | [100], optional | Luminance (cd/m²) value(s) of background(s) calculated using the CIE 2006 10° CMFs | (only used in case datab == None and the background is assumed to be an Equal-Energy-White) :fov: | 10.0, optional | Field-of-view of stimulus (for size effect on brightness) :inputtpe: | 'xyz' or 'spd', optional | Specifies the type of input: | tristimulus values or spectral data for the forward mode. :direction: | 'forward' or 'inverse', optional | -'forward': xyz -> cam18sl | -'inverse': cam18sl -> xyz :outin: | 'Q,aW,bW' or str, optional | 'Q,aW,bW' (brightness and opponent signals for amount-of-neutral) | other options: 'Q,aM,bM' (colorfulness) and 'Q,aS,bS' (saturation) | Str specifying the type of | input (:direction: == 'inverse') and | output (:direction: == 'forward') :parameters: | None or dict, optional | Set of model parameters. | - None: defaults to luxpy.cam._CAM18SL_PARAMETERS | (see references below) Returns: :returns: | ndarray with color appearance correlates (:direction: == 'forward') | or | XYZ tristimulus values (:direction: == 'inverse') Notes: | * Instead of using the CIE 1964 10° CMFs in some places of the model, | the CIE 2006 10° CMFs are used througout, making it more self_consistent. | This has an effect on the k scaling factors (now different those in CAM15u) | and the illuminant E normalization for use in the chromatic adaptation transform. | (see future erratum to Hermans et al., 2018) | * The paper also used an equation for the amount of white W, which is | based on a Q value not expressed in 'bright' ('cA' = 0.937 instead of 123). | This has been corrected for in the luxpy version of the model, i.e. | _CAM18SL_PARAMETERS['cW'][0] has been changed from 2.29 to 1/11672. | (see future erratum to Hermans et al., 2018) References: 1. `Hermans, S., Smet, K. A. G., & Hanselaer, P. (2018). "Color appearance model for self-luminous stimuli." Journal of the Optical Society of America A, 35(12), 2000–2009. <https://doi.org/10.1364/JOSAA.35.002000>`_ """ if parameters is None: parameters = _CAM18SL_PARAMETERS outin = outin.split(',') #unpack model parameters: cA, cAlms, cHK, cM, cW, ca, calms, cb, cblms, cfov, k, naka, unique_hue_data = [parameters[x] for x in sorted(parameters.keys())] # precomputations: Mlms2xyz = np.linalg.inv(_CMF['2006_10']['M']) MAab = np.array([cAlms,calms,cblms]) invMAab = np.linalg.inv(MAab) #------------------------------------------------- # setup EEW reference field and default background field (Lr should be equal to Lb): # Get Lb values: if datab is not None: if inputtype != 'xyz': Lb = spd_to_xyz(datab, cieobs = '2006_10', relative = False)[...,1:2] else: Lb = datab[...,1:2] else: if isinstance(Lb,list): Lb = np2dT(Lb) # Setup EEW ref of same luminance as datab: if inputtype == 'xyz': wlr = getwlr(_CAM18SL_WL3) else: if datab is None: wlr = data[0] # use wlr of stimulus data else: wlr = datab[0] # use wlr of background data datar = np.vstack((wlr,np.ones((Lb.shape[0], wlr.shape[0])))) # create eew xyzr = spd_to_xyz(datar, cieobs = '2006_10', relative = False) # get abs. tristimulus values datar[1:] = datar[1:]/xyzr[...,1:2]*Lb # Create datab if None: if (datab is None): if inputtype != 'xyz': datab = datar.copy() else: datab = spd_to_xyz(datar, cieobs = '2006_10', relative = False) datar = datab.copy() # prepare data and datab for loop over backgrounds: # make axis 1 of datab have 'same' dimensions as data: if (data.ndim == 2): data = np.expand_dims(data, axis = 1) # add light source axis 1 if inputtype == 'xyz': if datab.shape[0] == 1: #make datab and datar have same lights source dimension (used to store different backgrounds) size as data datab = np.repeat(datab,data.shape[1],axis=0) datar = np.repeat(datar,data.shape[1],axis=0) else: if datab.shape[0] == 2: datab = np.vstack((datab[0],np.repeat(datab[1:], data.shape[1], axis = 0))) if datar.shape[0] == 2: datar = np.vstack((datar[0],np.repeat(datar[1:], data.shape[1], axis = 0))) # Flip light source/ background dim to axis 0: data = np.transpose(data, axes = (1,0,2)) #------------------------------------------------- #initialize camout: dshape = list(data.shape) dshape[-1] = len(outin) # requested number of correlates if (inputtype != 'xyz') & (direction == 'forward'): dshape[-2] = dshape[-2] - 1 # wavelength row doesn't count & only with forward can the input data be spectral camout = np.nan*np.ones(dshape) for i in range(data.shape[0]): # get rho, gamma, beta of background and reference white: if (inputtype != 'xyz'): xyzb = spd_to_xyz(np.vstack((datab[0], datab[i+1:i+2,:])), cieobs = '2006_10', relative = False) xyzr = spd_to_xyz(np.vstack((datar[0], datar[i+1:i+2,:])), cieobs = '2006_10', relative = False) else: xyzb = datab[i:i+1,:] xyzr = datar[i:i+1,:] lmsb = np.dot(_CMF['2006_10']['M'],xyzb.T).T # convert to l,m,s rgbb = (lmsb / _CMF['2006_10']['K']) * k # convert to rho, gamma, beta #lmsr = np.dot(_CMF['2006_10']['M'],xyzr.T).T # convert to l,m,s #rgbr = (lmsr / _CMF['2006_10']['K']) * k # convert to rho, gamma, beta #rgbr = rgbr/rgbr[...,1:2]*Lb[i] # calculated EEW cone excitations at same luminance values as background rgbr = np.ones(xyzr.shape)*Lb[i] # explicitely equal EEW cone excitations at same luminance values as background if direction == 'forward': # get rho, gamma, beta of stimulus: if (inputtype != 'xyz'): xyz = spd_to_xyz(data[i], cieobs = '2006_10', relative = False) elif (inputtype == 'xyz'): xyz = data[i] lms = np.dot(_CMF['2006_10']['M'],xyz.T).T # convert to l,m,s rgb = (lms / _CMF['2006_10']['K']) * k # convert to rho, gamma, beta # apply von-kries cat with D = 1: if (rgbb == 0).any(): Mcat = np.eye(3) else: Mcat = np.diag((rgbr/rgbb)[0]) rgba = np.dot(Mcat,rgb.T).T # apply naka-rushton compression: rgbc = naka_rushton(rgba, n = naka['n'], sig = naka['sig'](rgbr.mean()), noise = naka['noise'], scaling = naka['scaling']) #rgbc = np.ones(rgbc.shape)*rgbc.mean() # test if eew ends up at origin # calculate achromatic and color difference signals, A, a, b: Aab = np.dot(MAab, rgbc.T).T A,a,b = asplit(Aab) a = ca*a b = cb*b # calculate colorfullness like signal M: M = cM*((a**2.0 + b**2.0)**0.5) # calculate brightness Q: Q = cA*(A + cHK[0]*M**cHK[1]) # last term is contribution of Helmholtz-Kohlrausch effect on brightness # calculate saturation, s: s = M / Q # calculate amount of white, W: W = 1 / (1.0 + cW[0]*(s**cW[1])) # adjust Q for size (fov) of stimulus (matter of debate whether to do this before or after calculation of s or W, there was no data on s, M or W for different sized stimuli: after) Q = Q*(fov/10.0)**cfov # calculate hue, h and Hue quadrature, H: h = hue_angle(a,b, htype = 'deg') if 'H' in outin: H = hue_quadrature(h, unique_hue_data = unique_hue_data) else: H = None # calculate cart. co.: if 'aM' in outin: aM = M*np.cos(h*np.pi/180.0) bM = M*np.sin(h*np.pi/180.0) if 'aS' in outin: aS = s*np.cos(h*np.pi/180.0) bS = s*np.sin(h*np.pi/180.0) if 'aW' in outin: aW = W*np.cos(h*np.pi/180.0) bW = W*np.sin(h*np.pi/180.0) if (outin != ['Q','aW','bW']): camout[i] = eval('ajoin(('+','.join(outin)+'))') else: camout[i] = ajoin((Q,aW,bW)) elif direction == 'inverse': # get Q, M and a, b depending on input type: if 'aW' in outin: Q,a,b = asplit(data[i]) Q = Q / ((fov/10.0)**cfov) #adjust Q for size (fov) of stimulus back to that 10° ref W = (a**2.0 + b**2.0)**0.5 s = (((1.0 / W) - 1.0)/cW[0])**(1.0/cW[1]) M = s*Q if 'aM' in outin: Q,a,b = asplit(data[i]) Q = Q / ((fov/10.0)**cfov) #adjust Q for size (fov) of stimulus back to that 10° ref M = (a**2.0 + b**2.0)**0.5 if 'aS' in outin: Q,a,b = asplit(data[i]) Q = Q / ((fov/10.0)**cfov) #adjust Q for size (fov) of stimulus back to that 10° ref s = (a**2.0 + b**2.0)**0.5 M = s*Q if 'h' in outin: Q, WsM, h = asplit(data[i]) Q = Q / ((fov/10.0)**cfov) #adjust Q for size (fov) of stimulus back to that 10° ref if 'W' in outin: s = (((1.0 / WsM) - 1.0)/cW[0])**(1.0/cW[1]) M = s*Q elif 's' in outin: M = WsM*Q elif 'M' in outin: M = WsM # calculate achromatic signal, A from Q and M: A = Q/cA - cHK[0]*M**cHK[1] # calculate hue angle: h = hue_angle(a,b, htype = 'rad') # calculate a,b from M and h: a = (M/cM)*np.cos(h) b = (M/cM)*np.sin(h) a = a/ca b = b/cb # create Aab: Aab = ajoin((A,a,b)) # calculate rgbc: rgbc = np.dot(invMAab, Aab.T).T # decompress rgbc to (adapted) rgba : rgba = naka_rushton(rgbc, n = naka['n'], sig = naka['sig'](rgbr.mean()), noise = naka['noise'], scaling = naka['scaling'], direction = 'inverse') # apply inverse von-kries cat with D = 1: rgb = np.dot(np.diag((rgbb/rgbr)[0]),rgba.T).T # convert rgb to lms to xyz: lms = rgb/k*_CMF['2006_10']['K'] xyz = np.dot(Mlms2xyz,lms.T).T camout[i] = xyz if camout.shape[0] == 1: camout = np.squeeze(camout,axis = 0) return camout
_CSPACE_AXES['xyz'] = ['X', 'Y', 'Z'] _CSPACE_AXES['lms'] = ['L', 'M', 'S'] _CSPACE_AXES['lab'] = ['L*', "a*", "b*"] _CSPACE_AXES['luv'] = ['L*', "u*", "v*"] _CSPACE_AXES['ipt'] = ['I', "P", "T"] _CSPACE_AXES['wuv'] = ['W*', "U*", "V*"] _CSPACE_AXES['Vrb_mb'] = [ 'V (Macleod-Boyton)', "r (Macleod-Boyton)", "b (Macleod-Boyton)" ] _CSPACE_AXES['cct'] = ['', 'cct', 'duv'] _CSPACE_AXES['srgb'] = ['sR', 'sG', 'sB'] # pre-calculate matrices for conversion of xyz to lms and back for use in xyz_to_ipt() and ipt_to_xyz(): _IPT_M = { 'lms2ipt': np.array([[0.4000, 0.4000, 0.2000], [4.4550, -4.8510, 0.3960], [0.8056, 0.3572, -1.1628]]), 'xyz2lms': { x: math.normalize_3x3_matrix( _CMF[x]['M'], spd_to_xyz(_CIE_ILLUMINANTS['D65'], cieobs=x)) for x in sorted(_CMF['types']) } } _COLORTF_DEFAULT_WHITE_POINT = np.array([[100.0, 100.0, 100.0]]) # ill. E white point #------------------------------------------------------------------------------ #---chromaticity coordinates--------------------------------------------------- #------------------------------------------------------------------------------ def xyz_to_Yxy(xyz, **kwargs): """
# load TM30 spd data base: _IESTM30 = { 'S': { 'data': getdata(_S_PATH + 'IESTM30_Sspds.dat', kind='np').transpose() } } _IESTM30['S']['info'] = getdata(_S_PATH + 'IESTM30_Sinfo.txt', kind='np', header='infer', verbosity=False) _IESTM30_S = _IESTM30['S'] #------------------------------------------------------------------------------ # Illuminant library: set some typical CIE illuminants: E = np.array([np.linspace(380, 780, 401), np.ones(401)]) D65 = np.array( [[ 380, 381, 382, 383, 384, 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 461, 462, 463, 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500, 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 524, 525, 526, 527, 528, 529, 530, 531, 532, 533, 534, 535, 536, 537, 538, 539, 540, 541, 542, 543, 544, 545, 546, 547, 548, 549, 550, 551, 552, 553, 554, 555, 556, 557, 558, 559, 560, 561,
def calculate_VF_PX_models(S, cri_type = _VF_CRI_DEFAULT, sampleset = None, pool = False, \ pcolorshift = {'href': np.arange(np.pi/10,2*np.pi,2*np.pi/10),\ 'Cref' : _VF_MAXR, 'sig' : _VF_SIG, 'labels' : '#'},\ vfcolor = 'k', verbosity = 0): """ Calculate Vector Field and Pixel color shift models. Args: :cri_type: | _VF_CRI_DEFAULT or str or dict, optional | Specifies type of color fidelity model to use. | Controls choice of ref. ill., sample set, averaging, scaling, etc. | See luxpy.cri.spd_to_cri for more info. :sampleset: | None or str or ndarray, optional | Sampleset to be used when calculating vector field model. :pool: | False, optional | If :S: contains multiple spectra, True pools all jab data before modeling the vector field, while False models a different field for each spectrum. :pcolorshift: | default dict (see below) or user defined dict, optional | Dict containing the specification input for apply_poly_model_at_hue_x(). | Default dict = {'href': np.arange(np.pi/10,2*np.pi,2*np.pi/10), | 'Cref' : _VF_MAXR, | 'sig' : _VF_SIG, | 'labels' : '#'} | The polynomial models of degree 5 and 6 can be fully specified or summarized by the model parameters themselved OR by calculating the dCoverC and dH at resp. 5 and 6 hues. :vfcolor: | 'k', optional | For plotting the vector fields. :verbosity: | 0, optional | Report warnings or not. Returns: :returns: | :dataVF:, :dataPX: | Dicts, for more info, see output description of resp.: luxpy.cri.VF_colorshift_model() and luxpy.cri.PX_colorshift_model() """ # calculate VectorField cri_color_shift model: dataVF = VF_colorshift_model(S, cri_type = cri_type, sampleset = sampleset, vfcolor = vfcolor, pcolorshift = pcolorshift, pool = pool, verbosity = verbosity) # Set jab_ranges and _deltas for PX-model pixel calculations: PX_jab_deltas = np.array([_VF_DELTAR,_VF_DELTAR,_VF_DELTAR]) #set same as for vectorfield generation PX_jab_ranges = np.vstack(([0,100,_VF_DELTAR],[-_VF_MAXR,_VF_MAXR+_VF_DELTAR,_VF_DELTAR], [-_VF_MAXR,_VF_MAXR+_VF_DELTAR,_VF_DELTAR]))#IES4880 gamut # Calculate shift vectors using vectorfield and pixel methods: delta_SvsVF_vshift_ab_mean = np.nan*np.ones((len(dataVF),1)) delta_SvsVF_vshift_ab_mean_normalized = delta_SvsVF_vshift_ab_mean.copy() delta_PXvsVF_vshift_ab_mean = np.nan*np.ones((len(dataVF),1)) delta_PXvsVF_vshift_ab_mean_normalized = delta_PXvsVF_vshift_ab_mean.copy() dataPX = [[] for k in range(len(dataVF))] for Snr in range(len(dataVF)): # Calculate shifts using pixel method, PX: dataPX[Snr] = PX_colorshift_model(dataVF[Snr]['Jab']['Jabt'][:,0,:],dataVF[Snr]['Jab']['Jabr'][:,0,:], jab_ranges = PX_jab_ranges, jab_deltas = PX_jab_deltas,limit_grid_radius = _VF_MAXR) # Calculate shift difference between Samples (S) and VectorField model predictions (VF): delta_SvsVF_vshift_ab = dataVF[Snr]['vshifts']['vshift_ab_s'] - dataVF[Snr]['vshifts']['vshift_ab_s_vf'] delta_SvsVF_vshift_ab_mean[Snr] = np.nanmean(np.sqrt((delta_SvsVF_vshift_ab[...,1:3]**2).sum(axis = delta_SvsVF_vshift_ab[...,1:3].ndim-1)), axis=0) delta_SvsVF_vshift_ab_mean_normalized[Snr] = delta_SvsVF_vshift_ab_mean[Snr]/dataVF[Snr]['Jab']['DEi'].mean(axis=0) # Calculate shift difference between PiXel method (PX) and VectorField (VF): delta_PXvsVF_vshift_ab = dataPX[Snr]['vshifts']['vectorshift_ab_J0'] - dataVF[Snr]['vshifts']['vshift_ab_vf'] delta_PXvsVF_vshift_ab_mean[Snr] = np.nanmean(np.sqrt((delta_PXvsVF_vshift_ab[...,1:3]**2).sum(axis = delta_PXvsVF_vshift_ab[...,1:3].ndim-1)), axis=0) delta_PXvsVF_vshift_ab_mean_normalized[Snr] = delta_PXvsVF_vshift_ab_mean[Snr]/dataVF[Snr]['Jab']['DEi'].mean(axis=0) dataVF[Snr]['vshifts']['delta_PXvsVF_vshift_ab_mean'] = delta_PXvsVF_vshift_ab_mean[Snr] dataVF[Snr]['vshifts']['delta_SvsVF_vshift_ab_mean'] = delta_SvsVF_vshift_ab_mean[Snr] dataVF[Snr]['vshifts']['delta_SvsVF_vshift_ab_mean_normalized'] = delta_SvsVF_vshift_ab_mean_normalized[Snr] dataVF[Snr]['vshifts']['delta_PXvsVF_vshift_ab_mean_normalized'] = delta_PXvsVF_vshift_ab_mean_normalized[Snr] dataPX[Snr]['vshifts']['delta_PXvsVF_vshift_ab_mean'] = dataVF[Snr]['vshifts']['delta_PXvsVF_vshift_ab_mean'] dataPX[Snr]['vshifts']['delta_PXvsVF_vshift_ab_mean_normalized'] = dataVF[Snr]['vshifts']['delta_PXvsVF_vshift_ab_mean_normalized'] return dataVF, dataPX
def spd_to_ies_tm30_metrics(SPD, cri_type = None, \ hbins = 16, start_hue = 0.0,\ scalef = 100, \ vf_model_type = _VF_MODEL_TYPE, \ vf_pcolorshift = _VF_PCOLORSHIFT,\ scale_vf_chroma_to_sample_chroma = False): """ Calculates IES TM30 metrics from spectral data. Args: :data: | numpy.ndarray with spectral data :cri_type: | None, optional | If None: defaults to cri_type = 'iesrf'. | Not none values of :hbins:, :start_hue: and :scalef: overwrite input in cri_type['rg_pars'] :hbins: | None or numpy.ndarray with sorted hue bin centers (°), optional :start_hue: | None, optional :scalef: | None, optional | Scale factor for reference circle. :vf_pcolorshift: | _VF_PCOLORSHIFT or user defined dict, optional | The polynomial models of degree 5 and 6 can be fully specified or summarized by the model parameters themselved OR by calculating the dCoverC and dH at resp. 5 and 6 hues. :VF_pcolorshift: specifies these hues and chroma level. :scale_vf_chroma_to_sample_chroma: | False, optional | Scale chroma of reference and test vf fields such that average of binned reference chroma equals that of the binned sample chroma before calculating hue bin metrics. Returns: :data: | dict with color rendering data: | - 'SPD' : ndarray test SPDs | - 'bjabt': ndarray with binned jab data under test SPDs | - 'bjabr': ndarray with binned jab data under reference SPDs | - 'cct' : ndarray with CCT of test SPD | - 'duv' : ndarray with distance to blackbody locus of test SPD | - 'Rf' : ndarray with general color fidelity indices | - 'Rg' : ndarray with gamut area indices | - 'Rfi' : ndarray with specific color fidelity indices | - 'Rfhi' : ndarray with local (hue binned) fidelity indices | - 'Rcshi': ndarray with local chroma shifts indices | - 'Rhshi': ndarray with local hue shifts indices | - 'Rt' : ndarray with general metameric uncertainty index Rt | - 'Rti' : ndarray with specific metameric uncertainty indices Rti | - 'Rfhi_vf' : ndarray with local (hue binned) fidelity indices | obtained from VF model predictions at color space | pixel coordinates | - 'Rcshi_vf': ndarray with local chroma shifts indices | (same as above) | - 'Rhshi_vf': ndarray with local hue shifts indices | (same as above) """ if cri_type is None: cri_type = 'iesrf' #Calculate color rendering measures for SPDs in data: out = 'Rf,Rg,cct,duv,Rfi,jabt,jabr,Rfhi,Rcshi,Rhshi,cri_type' if isinstance(cri_type, str): # get dict cri_type = _CRI_DEFAULTS[cri_type].copy() if hbins is not None: cri_type['rg_pars']['nhbins'] = hbins if start_hue is not None: cri_type['rg_pars']['start_hue'] = start_hue if scalef is not None: cri_type['rg_pars']['normalized_chroma_ref'] = scalef Rf, Rg, cct, duv, Rfi, jabt, jabr, Rfhi, Rcshi, Rhshi, cri_type = spd_to_cri( SPD, cri_type=cri_type, out=out) rg_pars = cri_type['rg_pars'] #Calculate Metameric uncertainty and base color shifts: dataVF = VF_colorshift_model(SPD, cri_type=cri_type, model_type=vf_model_type, cspace=cri_type['cspace'], sampleset=eval(cri_type['sampleset']), pool=False, pcolorshift=vf_pcolorshift, vfcolor=0) Rf_ = np.array([dataVF[i]['metrics']['Rf'] for i in range(len(dataVF))]).T Rt = np.array([dataVF[i]['metrics']['Rt'] for i in range(len(dataVF))]).T Rti = np.array([dataVF[i]['metrics']['Rti'] for i in range(len(dataVF))][0]) # Get normalized and sliced sample data for plotting: rg_pars = cri_type['rg_pars'] nhbins, normalize_gamut, normalized_chroma_ref, start_hue = [ rg_pars[x] for x in sorted(rg_pars.keys()) ] normalized_chroma_ref = scalef # np.sqrt((jabr[...,1]**2 + jabr[...,2]**2)).mean(axis = 0).mean() if scale_vf_chroma_to_sample_chroma == True: normalize_gamut = False bjabt, bjabr = gamut_slicer( jabt, jabr, out='jabt,jabr', nhbins=nhbins, start_hue=start_hue, normalize_gamut=normalize_gamut, normalized_chroma_ref=normalized_chroma_ref, close_gamut=True) Cr_s = (np.sqrt(bjabr[:-1, ..., 1]**2 + bjabr[:-1, ..., 2]**2)).mean( axis=0) # for rescaling vector field average reference chroma normalize_gamut = True #(for plotting) bjabt, bjabr = gamut_slicer(jabt, jabr, out='jabt,jabr', nhbins=nhbins, start_hue=start_hue, normalize_gamut=normalize_gamut, normalized_chroma_ref=normalized_chroma_ref, close_gamut=True) Rfhi_vf = np.empty(Rfhi.shape) Rcshi_vf = np.empty(Rcshi.shape) Rhshi_vf = np.empty(Rhshi.shape) for i in range(cct.shape[0]): # Get normalized and sliced VF data for hue specific metrics: vfjabt = np.hstack( (np.ones(dataVF[i]['fielddata']['vectorfield']['axt'].shape), dataVF[i]['fielddata']['vectorfield']['axt'], dataVF[i]['fielddata']['vectorfield']['bxt'])) vfjabr = np.hstack( (np.ones(dataVF[i]['fielddata']['vectorfield']['axr'].shape), dataVF[i]['fielddata']['vectorfield']['axr'], dataVF[i]['fielddata']['vectorfield']['bxr'])) nhbins, normalize_gamut, normalized_chroma_ref, start_hue = [ rg_pars[x] for x in sorted(rg_pars.keys()) ] vfbjabt, vfbjabr, vfbDEi = gamut_slicer( vfjabt, vfjabr, out='jabt,jabr,DEi', nhbins=nhbins, start_hue=start_hue, normalize_gamut=normalize_gamut, normalized_chroma_ref=normalized_chroma_ref, close_gamut=False) if scale_vf_chroma_to_sample_chroma == True: #rescale vfbjabt and vfbjabr to same chroma level as bjabr. Cr_vfb = np.sqrt(vfbjabr[..., 1]**2 + vfbjabr[..., 2]**2) Cr_vf = np.sqrt(vfjabr[..., 1]**2 + vfjabr[..., 2]**2) hr_vf = np.arctan2(vfjabr[..., 2], vfjabr[..., 1]) Ct_vf = np.sqrt(vfjabt[..., 1]**2 + vfjabt[..., 2]**2) ht_vf = np.arctan2(vfjabt[..., 2], vfjabt[..., 1]) fC = Cr_s.mean() / Cr_vfb.mean() vfjabr[..., 1] = fC * Cr_vf * np.cos(hr_vf) vfjabr[..., 2] = fC * Cr_vf * np.sin(hr_vf) vfjabt[..., 1] = fC * Ct_vf * np.cos(ht_vf) vfjabt[..., 2] = fC * Ct_vf * np.sin(ht_vf) vfbjabt, vfbjabr, vfbDEi = gamut_slicer( vfjabt, vfjabr, out='jabt,jabr,DEi', nhbins=nhbins, start_hue=start_hue, normalize_gamut=normalize_gamut, normalized_chroma_ref=normalized_chroma_ref, close_gamut=False) scale_factor = cri_type['scale']['cfactor'] scale_fcn = cri_type['scale']['fcn'] vfRfhi, vfRcshi, vfRhshi = jab_to_rhi( jabt=vfbjabt, jabr=vfbjabr, DEi=vfbDEi, cri_type=cri_type, scale_factor=scale_factor, scale_fcn=scale_fcn, use_bin_avg_DEi=True ) # [:-1,...] removes last row from jab as this was added to close the gamut. Rfhi_vf[:, i:i + 1] = vfRfhi Rhshi_vf[:, i:i + 1] = vfRhshi Rcshi_vf[:, i:i + 1] = vfRcshi # Create dict with CRI info: data = {'SPD' : SPD, 'cct' : cct, 'duv' : duv, 'bjabt' : bjabt, 'bjabr' : bjabr,\ 'Rf' : Rf, 'Rg' : Rg, 'Rfi': Rfi, 'Rfhi' : Rfhi, 'Rchhi' : Rcshi, 'Rhshi' : Rhshi, \ 'Rt' : Rt, 'Rti' : Rti, 'Rfhi_vf' : Rfhi_vf, 'Rfcshi_vf' : Rcshi_vf, 'Rfhshi_vf' : Rhshi_vf, \ 'dataVF' : dataVF,'cri_type' : cri_type} return data
'ca': 1.0, 'calms': [1.0, -12 / 11, 1 / 11], 'cb': 0.117, 'cblms': [1.0, 1.0, -2.0], 'unique_hue_data': _CAM15U_UNIQUE_HUE_DATA, 'cM': 135.52, 'cHK': [2.559, 0.561], 'cW': [2.29, 2.68], 'cfov': 0.271, 'Mxyz2rgb': np.array([[0.211831, 0.815789, -0.042472], [-0.492493, 1.378921, 0.098745], [0.0, 0.0, 0.985188]]) } _CAM15U_NAKA_RUSHTON_PARAMETERS = { 'n': None, 'sig': None, 'scaling': None, 'noise': None } _CAM15U_SURROUND_PARAMETERS = { 'surrounds': ['dark'], 'dark': { 'c': None, 'Nc': None, 'F': None,
def jabz_to_xyz(jabz, **kwargs): """ Convert Jz,az,bz color coordinates to XYZ tristimulus values. Args: :jabz: | ndarray with Jz,az,bz color coordinates 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, Jun. 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 get_poly_model(jabt, jabr, modeltype = _VF_MODEL_TYPE): """ Setup base color shift model (delta_a, delta_b), determine model parameters and accuracy. | Calculates a base color shift (delta) from the ref. chromaticity ar, br. Args: :jabt: | ndarray with jab color coordinates under the test SPD. :jabr: | ndarray with jab color coordinates under the reference SPD. :modeltype: | _VF_MODEL_TYPE or 'M6' or 'M5', optional | Specifies degree 5 or degree 6 polynomial model in ab-coordinates. (see notes below) Returns: :returns: | (poly_model, | pmodel, | dab_model, | dab_res, | dCHoverC_res, | dab_std, | dCHoverC_std) | | :poly_model: function handle to model | :pmodel: ndarray with model parameters | :dab_model: ndarray with ab model predictions from ar, br. | :dab_res: ndarray with residuals between 'da,db' of samples and | 'da,db' predicted by the model. | :dCHoverC_res: ndarray with residuals between 'dCoverC,dH' | of samples and 'dCoverC,dH' predicted by the model. | Note: dCoverC = (Ct - Cr)/Cr and dH = ht - hr | (predicted from model, see notes below) | :dab_std: ndarray with std of :dab_res: | :dCHoverC_std: ndarray with std of :dCHoverC_res: Notes: 1. Model types: | poly5_model = lambda a,b,p: p[0]*a + p[1]*b + p[2]*(a**2) + p[3]*a*b + p[4]*(b**2) | poly6_model = lambda a,b,p: p[0] + p[1]*a + p[2]*b + p[3]*(a**2) + p[4]*a*b + p[5]*(b**2) 2. Calculation of dCoverC and dH: | dCoverC = (np.cos(hr)*da + np.sin(hr)*db)/Cr | dHoverC = (np.cos(hr)*db - np.sin(hr)*da)/Cr """ at = jabt[...,1] bt = jabt[...,2] ar = jabr[...,1] br = jabr[...,2] # A. Calculate da, db: da = at - ar db = bt - br # B.1 Calculate model matrix: # 5-parameter model: M5 = np.array([[np.sum(ar*ar), np.sum(ar*br), np.sum(ar*ar**2),np.sum(ar*ar*br),np.sum(ar*br**2)], [np.sum(br*ar), np.sum(br*br), np.sum(br*ar**2),np.sum(br*ar*br),np.sum(br*br**2)], [np.sum((ar**2)*ar), np.sum((ar**2)*br), np.sum((ar**2)*ar**2),np.sum((ar**2)*ar*br),np.sum((ar**2)*br**2)], [np.sum(ar*br*ar), np.sum(ar*br*br), np.sum(ar*br*ar**2),np.sum(ar*br*ar*br),np.sum(ar*br*br**2)], [np.sum((br**2)*ar), np.sum((br**2)*br), np.sum((br**2)*ar**2),np.sum((br**2)*ar*br),np.sum((br**2)*br**2)]]) #6-parameters model M6 = np.array([[ar.size,np.sum(1.0*ar), np.sum(1.0*br), np.sum(1.0*ar**2),np.sum(1.0*ar*br),np.sum(1.0*br**2)], [np.sum(ar*1.0),np.sum(ar*ar), np.sum(ar*br), np.sum(ar*ar**2),np.sum(ar*ar*br),np.sum(ar*br**2)], [np.sum(br*1.0),np.sum(br*ar), np.sum(br*br), np.sum(br*ar**2),np.sum(br*ar*br),np.sum(br*br**2)], [np.sum((ar**2)*1.0),np.sum((ar**2)*ar), np.sum((ar**2)*br), np.sum((ar**2)*ar**2),np.sum((ar**2)*ar*br),np.sum((ar**2)*br**2)], [np.sum(ar*br*1.0),np.sum(ar*br*ar), np.sum(ar*br*br), np.sum(ar*br*ar**2),np.sum(ar*br*ar*br),np.sum(ar*br*br**2)], [np.sum((br**2)*1.0),np.sum((br**2)*ar), np.sum((br**2)*br), np.sum((br**2)*ar**2),np.sum((br**2)*ar*br),np.sum((br**2)*br**2)]]) # B.2 Define model function: poly5_model = lambda a,b,p: p[0]*a + p[1]*b + p[2]*(a**2) + p[3]*a*b + p[4]*(b**2) poly6_model = lambda a,b,p: p[0] + p[1]*a + p[2]*b + p[3]*(a**2) + p[4]*a*b + p[5]*(b**2) if modeltype == 'M5': M = M5 poly_model = poly5_model else: M = M6 poly_model = poly6_model M = np.linalg.inv(M) # C.1 Data a,b analysis output: if modeltype == 'M5': da_model_parameters = np.dot(M, np.array([np.sum(da*ar), np.sum(da*br), np.sum(da*ar**2),np.sum(da*ar*br),np.sum(da*br**2)])) db_model_parameters = np.dot(M, np.array([np.sum(db*ar), np.sum(db*br), np.sum(db*ar**2),np.sum(db*ar*br),np.sum(db*br**2)])) else: da_model_parameters = np.dot(M, np.array([np.sum(da*1.0),np.sum(da*ar), np.sum(da*br), np.sum(da*ar**2),np.sum(da*ar*br),np.sum(da*br**2)])) db_model_parameters = np.dot(M, np.array([np.sum(db*1.0),np.sum(db*ar), np.sum(db*br), np.sum(db*ar**2),np.sum(db*ar*br),np.sum(db*br**2)])) pmodel = np.vstack((da_model_parameters,db_model_parameters)) # D.1 Calculate model da, db: da_model = poly_model(ar,br,pmodel[0]) db_model = poly_model(ar,br,pmodel[1]) dab_model = np.hstack((da_model,db_model)) # D.2 Calculate residuals for da & db: da_res = da - da_model db_res = db - db_model dab_res = np.hstack((da_res,db_res)) dab_std = np.vstack((np.std(da_res,axis=0),np.std(db_res,axis=0))) # E Calculate href, Cref: href = np.arctan2(br,ar) Cref = (ar**2 + br**2)**0.5 # F Calculate dC/C, dH/C for data and model and calculate residuals: dCoverC = (np.cos(href)*da + np.sin(href)*db)/Cref dHoverC = (np.cos(href)*db - np.sin(href)*da)/Cref dCoverC_model = (np.cos(href)*da_model + np.sin(href)*db_model)/Cref dHoverC_model = (np.cos(href)*db_model - np.sin(href)*da_model)/Cref dCoverC_res = dCoverC - dCoverC_model dHoverC_res = dHoverC - dHoverC_model dCHoverC_std = np.vstack((np.std(dCoverC_res,axis = 0),np.std(dHoverC_res,axis = 0))) dCHoverC_res = np.hstack((href,dCoverC_res,dHoverC_res)) return poly_model, pmodel, dab_model, dab_res, dCHoverC_res, dab_std, dCHoverC_std
def xyz_to_Ydlep(xyz, cieobs=_CIEOBS, xyzw=_COLORTF_DEFAULT_WHITE_POINT, flip_axes=False, **kwargs): """ Convert XYZ tristimulus values to Y, dominant (complementary) wavelength and excitation purity. Args: :xyz: | ndarray with tristimulus values :xyzw: | None or ndarray with tristimulus values of a single (!) native white point, optional | None defaults to xyz of CIE D65 using the :cieobs: observer. :cieobs: | luxpy._CIEOBS, optional | CMF set to use when calculating spectrum locus coordinates. :flip_axes: | False, optional | If True: flip axis 0 and axis 1 in Ydelep to increase speed of loop in function. | (single xyzw with is not flipped!) Returns: :Ydlep: | ndarray with Y, dominant (complementary) wavelength and excitation purity """ xyz3 = np3d(xyz).copy().astype(np.float) # flip axis so that shortest dim is on axis0 (save time in looping): if (xyz3.shape[0] < xyz3.shape[1]) & (flip_axes == True): axes12flipped = True xyz3 = xyz3.transpose((1, 0, 2)) else: axes12flipped = False # convert xyz to Yxy: Yxy = xyz_to_Yxy(xyz3) Yxyw = xyz_to_Yxy(xyzw) # get spectrum locus Y,x,y and wavelengths: SL = _CMF[cieobs]['bar'] wlsl = SL[0] Yxysl = xyz_to_Yxy(SL[1:4].T)[:, None] # center on xyzw: Yxy = Yxy - Yxyw Yxysl = Yxysl - Yxyw Yxyw = Yxyw - Yxyw #split: Y, x, y = asplit(Yxy) Yw, xw, yw = asplit(Yxyw) Ysl, xsl, ysl = asplit(Yxysl) # calculate hue: h = math.positive_arctan(x, y, htype='deg') hsl = math.positive_arctan(xsl, ysl, htype='deg') hsl_max = hsl[0] # max hue angle at min wavelength hsl_min = hsl[-1] # min hue angle at max wavelength dominantwavelength = np.empty(Y.shape) purity = np.empty(Y.shape) for i in range(xyz3.shape[1]): # find index of complementary wavelengths/hues: pc = np.where( (h[:, i] >= hsl_max) & (h[:, i] <= hsl_min + 360.0) ) # hue's requiring complementary wavelength (purple line) h[:, i][pc] = h[:, i][pc] - np.sign( h[:, i][pc] - 180.0 ) * 180.0 # add/subtract 180° to get positive complementary wavelength # find 2 closest hues in sl: #hslb,hib = meshblock(hsl,h[:,i:i+1]) hib, hslb = np.meshgrid(h[:, i:i + 1], hsl) dh = np.abs(hslb - hib) q1 = dh.argmin(axis=0) # index of closest hue dh[q1] = 1000.0 q2 = dh.argmin(axis=0) # index of second closest hue dominantwavelength[:, i] = wlsl[q1] + np.divide( np.multiply((wlsl[q2] - wlsl[q1]), (h[:, i] - hsl[q1, 0])), (hsl[q2, 0] - hsl[q1, 0]) ) # calculate wl corresponding to h: y = y1 + (y2-y1)*(x-x1)/(x2-x1) dominantwavelength[:, i][pc] = -dominantwavelength[:, i][ pc] #complementary wavelengths are specified by '-' sign # calculate excitation purity: x_dom_wl = xsl[q1, 0] + (xsl[q2, 0] - xsl[q1, 0]) * (h[:, i] - hsl[ q1, 0]) / (hsl[q2, 0] - hsl[q1, 0]) # calculate x of dom. wl y_dom_wl = ysl[q1, 0] + (ysl[q2, 0] - ysl[q1, 0]) * (h[:, i] - hsl[ q1, 0]) / (hsl[q2, 0] - hsl[q1, 0]) # calculate y of dom. wl d_wl = (x_dom_wl**2.0 + y_dom_wl**2.0)**0.5 # distance from white point to sl d = (x[:, i]**2.0 + y[:, i]**2.0)**0.5 # distance from white point to test point purity[:, i] = d / d_wl # correct for those test points that have a complementary wavelength # calculate intersection of line through white point and test point and purple line: xy = np.vstack((x[:, i], y[:, i])).T xyw = np.hstack((xw, yw)) xypl1 = np.hstack((xsl[0, None], ysl[0, None])) xypl2 = np.hstack((xsl[-1, None], ysl[-1, None])) da = (xy - xyw) db = (xypl2 - xypl1) dp = (xyw - xypl1) T = np.array([[0.0, -1.0], [1.0, 0.0]]) dap = np.dot(da, T) denom = np.sum(dap * db, axis=1, keepdims=True) num = np.sum(dap * dp, axis=1, keepdims=True) xy_linecross = (num / denom) * db + xypl1 d_linecross = np.atleast_2d( (xy_linecross[:, 0]**2.0 + xy_linecross[:, 1]**2.0)**0.5).T #[0] purity[:, i][pc] = d[pc] / d_linecross[pc][:, 0] Ydlep = np.dstack((xyz3[:, :, 1], dominantwavelength, purity)) if axes12flipped == True: Ydlep = Ydlep.transpose((1, 0, 2)) else: Ydlep = Ydlep.transpose((0, 1, 2)) return Ydlep.reshape(xyz.shape)
def xyz_to_cct_search(xyzw, cieobs=_CIEOBS, out='cct', wl=None, accuracy=0.1, upper_cct_max=10.0**20, approx_cct_temp=True): """ Convert XYZ tristimulus values to correlated color temperature (CCT) and Duv(distance above (> 0) or below ( < 0) the Planckian locus) by a brute-force search. | The algorithm uses an approximate cct_temp (HA approx., see xyz_to_cct_HA) as starting point or uses the middle of the allowed cct-range (1e2 K - 1e20 K, higher causes overflow) on a log-scale, then constructs a 4-step section of the blackbody (Planckian) locus on which to find the minimum distance to the 1960 uv chromaticity of the test source. Args: :xyzw: | ndarray of tristimulus values :cieobs: | luxpy._CIEOBS, optional | CMF set used to calculated xyzw. :out: | 'cct' (or 1), optional | Determines what to return. | Other options: 'duv' (or -1), 'cct,duv'(or 2), "[cct,duv]" (or -2) :wl: | None, optional | Wavelengths used when calculating Planckian radiators. :accuracy: | float, optional | Stop brute-force search when cct :accuracy: is reached. :upper_cct_max: | 10.0**20, optional | Limit brute-force search to this cct. :approx_cct_temp: | True, optional | If True: use xyz_to_cct_HA() to get a first estimate of cct to speed up search. Returns: :returns: | ndarray with: | cct: out == 'cct' (or 1) | duv: out == 'duv' (or -1) | cct, duv: out == 'cct,duv' (or 2) | [cct,duv]: out == "[cct,duv]" (or -2) Notes: This program is more accurate, but slower than xyz_to_cct_ohno! Note that cct must be between 1e3 K - 1e20 K (very large cct take a long time!!!) """ xyzw = np2d(xyzw) if len(xyzw.shape) > 2: raise Exception('xyz_to_cct_search(): Input xyzw.shape must be <= 2 !') # get 1960 u,v of test source: Yuvt = xyz_to_Yuv(np.squeeze( xyzw)) # remove possible 1-dim + convert xyzw to CIE 1976 u',v' #axis_of_v3t = len(Yuvt.shape)-1 # axis containing color components ut = Yuvt[:, 1, None] #.take([1],axis = axis_of_v3t) # get CIE 1960 u vt = (2 / 3) * Yuvt[:, 2, None] #.take([2],axis = axis_of_v3t) # get CIE 1960 v # Initialize arrays: ccts = np.ones((xyzw.shape[0], 1)) * np.nan duvs = ccts.copy() #calculate preliminary solution(s): if (approx_cct_temp == True): ccts_est = xyz_to_cct_HA(xyzw) procent_estimates = np.array([[3000.0, 100000.0, 0.05], [100000.0, 200000.0, 0.1], [200000.0, 300000.0, 0.25], [300000.0, 400000.0, 0.4], [400000.0, 600000.0, 0.4], [600000.0, 800000.0, 0.4], [800000.0, np.inf, 0.25]]) else: upper_cct = np.array(upper_cct_max) lower_cct = np.array(10.0**2) cct_scale_fun = lambda x: np.log10(x) cct_scale_ifun = lambda x: np.power(10.0, x) dT = (cct_scale_fun(upper_cct) - cct_scale_fun(lower_cct)) / 2 ccttemp = np.array([cct_scale_ifun(cct_scale_fun(lower_cct) + dT)]) ccts_est = np2d(ccttemp * np.ones((xyzw.shape[0], 1))) dT_approx_cct_False = dT.copy() # Loop through all ccts: for i in range(xyzw.shape[0]): #initialize CCT search parameters: cct = np.nan duv = np.nan ccttemp = ccts_est[i].copy() # Take care of (-1, NaN)'s from xyz_to_cct_HA signifying (CCT < lower, CCT > upper) bounds: approx_cct_temp_temp = approx_cct_temp if (approx_cct_temp == True): cct_scale_fun = lambda x: x cct_scale_ifun = lambda x: x if (ccttemp != -1) & ( np.isnan(ccttemp) == False ): # within validity range of CCT estimator-function for ii in range(procent_estimates.shape[0]): if (ccttemp >= (1.0 - 0.05 * (ii == 0)) * procent_estimates[ii, 0]) & ( ccttemp < (1.0 + 0.05 * (ii == 0)) * procent_estimates[ii, 1]): procent_estimate = procent_estimates[ii, 2] break dT = np.multiply( ccttemp, procent_estimate ) # determines range around CCTtemp (25% around estimate) or 100 K elif (ccttemp == -1) & (np.isnan(ccttemp) == False): ccttemp = np.array([procent_estimates[0, 0] / 2]) procent_estimate = 1 # cover 0 K to min_CCT of estimator dT = np.multiply(ccttemp, procent_estimate) elif (np.isnan(ccttemp) == True): upper_cct = np.array(upper_cct_max) lower_cct = np.array(10.0**2) cct_scale_fun = lambda x: np.log10(x) cct_scale_ifun = lambda x: np.power(10.0, x) dT = (cct_scale_fun(upper_cct) - cct_scale_fun(lower_cct)) / 2 ccttemp = np.array( [cct_scale_ifun(cct_scale_fun(lower_cct) + dT)]) approx_cct_temp = False else: dT = dT_approx_cct_False nsteps = 3 signduv = 1.0 ccttemp = ccttemp[0] delta_cct = dT while ((delta_cct > accuracy)): # keep converging on CCT #generate range of ccts: ccts_i = cct_scale_ifun( np.linspace( cct_scale_fun(ccttemp) - dT, cct_scale_fun(ccttemp) + dT, nsteps + 1)) ccts_i[ccts_i < 100.0] = 100.0 # avoid nan's in calculation # Generate BB: BB = cri_ref(ccts_i, wl3=wl, ref_type=['BB'], cieobs=cieobs) # Calculate xyz: xyz = spd_to_xyz(BB, cieobs=cieobs) # Convert to CIE 1960 u,v: Yuv = xyz_to_Yuv(np.squeeze( xyz)) # remove possible 1-dim + convert xyz to CIE 1976 u',v' #axis_of_v3 = len(Yuv.shape)-1 # axis containing color components u = Yuv[:, 1, None] # get CIE 1960 u v = (2.0 / 3.0) * Yuv[:, 2, None] # get CIE 1960 v # Calculate distance between list of uv's and uv of test source: dc = ((ut[i] - u)**2 + (vt[i] - v)**2)**0.5 if np.isnan(dc.min()) == False: #eps = _EPS q = dc.argmin() if np.size( q ) > 1: #to minimize calculation time: only calculate median when necessary cct = np.median(ccts[q]) duv = np.median(dc[q]) q = np.median(q) q = int(q) #must be able to serve as index else: cct = ccts_i[q] duv = dc[q] if (q == 0): ccttemp = cct_scale_ifun( np.array(cct_scale_fun([cct])) + 2 * dT / nsteps) #dT = 2.0*dT/nsteps continue # look in higher section of planckian locus if (q == np.size(ccts_i)): ccttemp = cct_scale_ifun( np.array(cct_scale_fun([cct])) - 2 * dT / nsteps) #dT = 2.0*dT/nsteps continue # look in lower section of planckian locus if (q > 0) & (q < np.size(ccts_i) - 1): dT = 2 * dT / nsteps # get Duv sign: d_p1m1 = ((u[q + 1] - u[q - 1])**2.0 + (v[q + 1] - v[q - 1])**2.0)**0.5 x = (dc[q - 1]**2.0 - dc[q + 1]**2.0 + d_p1m1**2.0) / 2.0 * d_p1m1 vBB = v[q - 1] + ((v[q + 1] - v[q - 1]) * (x / d_p1m1)) signduv = np.sign(vt[i] - vBB) #calculate difference with previous intermediate solution: delta_cct = abs(cct - ccttemp) ccttemp = np.array(cct) #%set new intermediate CCT approx_cct_temp = approx_cct_temp_temp else: ccttemp = np.nan cct = np.nan duv = np.nan duvs[i] = signduv * abs(duv) ccts[i] = cct # Regulate output: if (out == 'cct') | (out == 1): return np2d(ccts) elif (out == 'duv') | (out == -1): return np2d(duvs) elif (out == 'cct,duv') | (out == 2): return np2d(ccts), np2d(duvs) elif (out == "[cct,duv]") | (out == -2): return np.vstack((ccts, duvs)).T
def Ydlep_to_xyz(Ydlep, cieobs=_CIEOBS, xyzw=_COLORTF_DEFAULT_WHITE_POINT, flip_axes=False, **kwargs): """ Convert Y, dominant (complementary) wavelength and excitation purity to XYZ tristimulus values. Args: :Ydlep: | ndarray with Y, dominant (complementary) wavelength and excitation purity :xyzw: | None or narray with tristimulus values of a single (!) native white point, optional | None defaults to xyz of CIE D65 using the :cieobs: observer. :cieobs: | luxpy._CIEOBS, optional | CMF set to use when calculating spectrum locus coordinates. :flip_axes: | False, optional | If True: flip axis 0 and axis 1 in Ydelep to increase speed of loop in function. | (single xyzw with is not flipped!) Returns: :xyz: | ndarray with tristimulus values """ Ydlep3 = np3d(Ydlep).copy().astype(np.float) # flip axis so that longest dim is on first axis (save time in looping): if (Ydlep3.shape[0] < Ydlep3.shape[1]) & (flip_axes == True): axes12flipped = True Ydlep3 = Ydlep3.transpose((1, 0, 2)) else: axes12flipped = False # convert xyzw to Yxyw: Yxyw = xyz_to_Yxy(xyzw) Yxywo = Yxyw.copy() # get spectrum locus Y,x,y and wavelengths: SL = _CMF[cieobs]['bar'] wlsl = SL[0, None].T Yxysl = xyz_to_Yxy(SL[1:4].T)[:, None] # center on xyzw: Yxysl = Yxysl - Yxyw Yxyw = Yxyw - Yxyw #split: Y, dom, pur = asplit(Ydlep3) Yw, xw, yw = asplit(Yxyw) Ywo, xwo, ywo = asplit(Yxywo) Ysl, xsl, ysl = asplit(Yxysl) # loop over longest dim: x = np.empty(Y.shape) y = np.empty(Y.shape) for i in range(Ydlep3.shape[1]): # find closest wl's to dom: #wlslb,wlib = meshblock(wlsl,np.abs(dom[i,:])) #abs because dom<0--> complemtary wl wlib, wlslb = np.meshgrid(np.abs(dom[:, i]), wlsl) dwl = np.abs(wlslb - wlib) q1 = dwl.argmin(axis=0) # index of closest wl dwl[q1] = 10000.0 q2 = dwl.argmin(axis=0) # index of second closest wl # calculate x,y of dom: x_dom_wl = xsl[q1, 0] + (xsl[q2, 0] - xsl[q1, 0]) * ( np.abs(dom[:, i]) - wlsl[q1, 0]) / (wlsl[q2, 0] - wlsl[q1, 0] ) # calculate x of dom. wl y_dom_wl = ysl[q1, 0] + (ysl[q2, 0] - ysl[q1, 0]) * ( np.abs(dom[:, i]) - wlsl[q1, 0]) / (wlsl[q2, 0] - wlsl[q1, 0] ) # calculate y of dom. wl # calculate x,y of test: d_wl = (x_dom_wl**2.0 + y_dom_wl**2.0)**0.5 # distance from white point to dom d = pur[:, i] * d_wl hdom = math.positive_arctan(x_dom_wl, y_dom_wl, htype='deg') x[:, i] = d * np.cos(hdom * np.pi / 180.0) y[:, i] = d * np.sin(hdom * np.pi / 180.0) # complementary: pc = np.where(dom[:, i] < 0.0) hdom[pc] = hdom[pc] - np.sign(dom[:, i][pc] - 180.0) * 180.0 # get positive hue angle # calculate intersection of line through white point and test point and purple line: xy = np.vstack((x_dom_wl, y_dom_wl)).T xyw = np.vstack((xw, yw)).T xypl1 = np.vstack((xsl[0, None], ysl[0, None])).T xypl2 = np.vstack((xsl[-1, None], ysl[-1, None])).T da = (xy - xyw) db = (xypl2 - xypl1) dp = (xyw - xypl1) T = np.array([[0.0, -1.0], [1.0, 0.0]]) dap = np.dot(da, T) denom = np.sum(dap * db, axis=1, keepdims=True) num = np.sum(dap * dp, axis=1, keepdims=True) xy_linecross = (num / denom) * db + xypl1 d_linecross = np.atleast_2d( (xy_linecross[:, 0]**2.0 + xy_linecross[:, 1]**2.0)**0.5).T[:, 0] x[:, i][pc] = pur[:, i][pc] * d_linecross[pc] * np.cos( hdom[pc] * np.pi / 180) y[:, i][pc] = pur[:, i][pc] * d_linecross[pc] * np.sin( hdom[pc] * np.pi / 180) Yxy = np.dstack((Ydlep3[:, :, 0], x + xwo, y + ywo)) if axes12flipped == True: Yxy = Yxy.transpose((1, 0, 2)) else: Yxy = Yxy.transpose((0, 1, 2)) return Yxy_to_xyz(Yxy).reshape(Ydlep.shape)
'Dtype': 'cat02', 'catmode': '1>2' }, 'rg_pars': { 'nhbins': None, 'start_hue': 0.0, 'normalize_gamut': False, 'normalized_chroma_ref': 100 }, 'cri_specific_pars': { 'similarity_ai': np.array([[-0.09651, 0.41354, 40.64, 16.55, -0.17], [0.16548, 0.38877, 58.27, 20.37, -0.59], [0.32825, 0.49673, 35.97, 18.05, -6.04], [0.02115, -0.13658, 261.62, 110.99, -44.86], [-0.12686, -0.22593, 99.06, 55.90, -39.86], [0.18488, 0.01172, 58.23, 62.55, -22.86], [-0.03440, 0.23480, 94.71, 32.12, 2.90], [0.04258, 0.05040, 205.54, 53.08, -35.20], [0.15829, 0.13624, 90.21, 70.83, -19.01], [-0.01933, -0.02168, 742.97, 297.66, -227.30]]) } } ############################################################################### def spd_to_mcri(SPD, D=0.9, E=None, Yb=20.0, out='Rm', wl=None): """ Calculates the MCRI or Memory Color Rendition Index, Rm Args: :SPD:
'1931_2_juddvos1978', '1951_20_scotopic', 'cie_std_dev_obs_f1' ] _CMF_K_VALUES = [ 683.002, 683.6, 683.002, 683.002, 683.002, 683.002, 1700.06, 0.0 ] #def _dictkv(keys=None,values=None, ordered = True): # # Easy input of of keys and values into dict (both should be iterable lists) # if ordered is True: # return odict(zip(keys,values)) # else: # return dict(zip(keys,values)) _CMF_M_1931_2 = np.array( [ # definition of 3x3 matrices to convert from xyz to lms [0.38971, 0.68898, -0.07868], [-0.22981, 1.1834, 0.04641], [0.0, 0.0, 1.0] ]) _CMF_M_2006_2 = np.array([[0.21057582, 0.85509764, -0.039698265], [-0.41707637, 1.1772611, 0.078628251], [0.0, 0.0, 0.51683501]]) _CMF_M_2006_10 = np.array([[0.21701045, 0.83573367, -0.043510597], [-0.42997951, 1.2038895, 0.086210895], [0.0, 0.0, 0.46579234]]) # Note that for the following, no conversion has been defined, so the 1931 HPE matrix is used: _CMF_M_1964_10 = np.array([[0.38971, 0.68898, -0.07868], [-0.22981, 1.1834, 0.04641], [0.0, 0.0, 1.0]]) _CMF_M_1931_2_JUDD1951 = np.array([[0.38971, 0.68898, -0.07868], [-0.22981, 1.1834, 0.04641], [0.0, 0.0, 1.0]])