def positive_arctan(x,y, htype = 'deg'): """ Calculate positive angle (0°-360° or 0 - 2*pi rad.) from x and y. Args: :x: | ndarray of x-coordinates :y: | ndarray of y-coordinates :htype: | 'deg' or 'rad', optional | - 'deg': hue angle between 0° and 360° | - 'rad': hue angle between 0 and 2pi radians Returns: :returns: | ndarray of positive angles. """ if htype == 'deg': r2d = 180.0/np.pi h360 = 360.0 else: r2d = 1.0 h360 = 2.0*np.pi h = np.atleast_1d((np.arctan2(y,x)*r2d)) h[np.where(h<0)] = h[np.where(h<0)] + h360 return h
def _compute_s_W_S(sample_size, num_groups, tri_idxs, distances, group_sizes, grouping, subjects, paired): """Compute PERMANOVA Within & Subjects Sum-of-Squares.""" # Create a matrix where objects in the same group are marked with the group # index (e.g. 0, 1, 2, etc.). objects that are not in the same group are # marked with -1. If paired == True: Do similar for test subjects: grouping_matrix = -1 * np.ones((sample_size, sample_size), dtype=int) for group_idx in range(num_groups): within_indices = _index_combinations( np.where(grouping == group_idx)[0]) grouping_matrix[within_indices] = group_idx # Extract upper triangle (in same order as distances were extracted # from full distance matrix). grouping_tri = grouping_matrix[tri_idxs] # Calculate s_WG for each group, accounting for different group sizes. s_WG = 0 for i in range(num_groups): s_WG += (distances[grouping_tri == i]**2).sum() / group_sizes[i] # for pseudo-F2: Calculate s_WG_V for each group, accounting for different group sizes. s_WG_V = 0 for i in range(num_groups): s_WG_V += (1 - group_sizes[i] / sample_size) * ( (1 / (group_sizes[i] * (group_sizes[i] - 1))) * distances[grouping_tri == i]**2).sum() if paired == True: num_subjects = sample_size // num_groups subjects_matrix = -1 * np.ones((sample_size, sample_size), dtype=int) for subject_idx in range(num_subjects): subject_indices = _index_combinations( np.where(subjects == subject_idx)[0]) subjects_matrix[subject_indices] = subject_idx # Extract upper triangle (in same order as distances were extracted # from full distance matrix). subjects_tri = subjects_matrix[tri_idxs] # Calculate s_WS for each subject, accounting for number of groups. s_WS = 0 for i in range(num_subjects): s_WS += (distances[subjects_tri == i]**2).sum() / num_groups else: s_WS = 0 return s_WG, s_WS, s_WG_V
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 getMonteCarloParam(n_obs=1, stdDevAllParam=_INDVCMF_STD_DEV_ALL_PARAM.copy()): """ Get dict with normally-distributed physiological factors for a population of observers. Args: :n_obs: | 1, optional | Number of individual observers in population. :stdDevAllParam: | _INDVCMF_STD_DEV_ALL_PARAM, optional | Dict with parameters for: | ['od_lens', 'od_macula', | 'od_L', 'od_M', 'od_S', | 'shft_L', 'shft_M', 'shft_S'] Returns: :returns: | dict with n_obs randomly drawn parameters. """ varParam = {} for k in list(stdDevAllParam.keys()): varParam[k] = stdDevAllParam[k] * np.random.randn(n_obs) # limit varAllParam so that it doesn't create negative val for # lens, macula, pkod_LMS: if (k == 'od_lens') | (k == 'od_macula') | (k == 'od_L') | ( k == 'od_M') | (k == 'od_S'): varParam[k][np.where(varParam[k] < -100)] = -100 return varParam
def naka_rushton(data, sig=2.0, n=0.73, scaling=1.0, noise=0.0, forward=True): """ Apply a Naka-Rushton response compression (n) and an adaptive shift (sig). | NK(x) = sign(x) * scaling * ((abs(x)**n) / ((abs(x)**n) + (sig**n))) + noise Args: :data: | float or ndarray :sig: | 2.0, optional | Semi-saturation constant. Value for which NK(:data:) is 1/2 :n: | 0.73, optional | Compression power. :scaling: | 1.0, optional | Maximum value of NK-function. :noise: | 0.0, optional | Cone excitation noise. :forward: | True, optional | True: do NK(x) | False: do NK(x)**(-1). Returns: :returns: | float or ndarray with NK-(de)compressed input :x: """ if forward: return np.sign(data) * scaling * ((np.abs(data)**n) / ((np.abs(data)**n) + (sig**n))) + noise elif forward == False: Ip = sig * (((np.abs(np.abs(data) - noise)) / (scaling - np.abs(np.abs(data) - noise))))**(1 / n) if not np.isscalar(Ip): p = np.where(np.abs(data) < noise) Ip[p] = -Ip[p] else: if np.abs(data) < noise: Ip = -Ip return Ip
def recombination(Xp, Xm, options): """ Performs recombination in the individuals. | The recombination combines the information of the parents and the | mutated individuals (also called "trial vectors") to create the | offspring. Assuming x represents the i-th parent, and u the i-th trial | vector (obtained from the mutation), the offspring xo will have the | following j-th coordinate: xo_j = u_j if rand_j <= CR, x_j otherwise | wherein rand_j is a number drawn from a uniform distribution from 0 to | 1, and CR is called the crossover factor. To prevent mere copies, at | least one coordinate is guaranteed to belong to the trial vector. Args: :Xp: | a n x mu ndarray with the mu parents :Xm: | a n x mu ndarray with the mu mutated points :options: | the dict with the internal parameters Returns: Xo: | a n x mu ndarray with the recombinated points (offspring) """ # Draws random numbers and checks whether they are smaller or # greater than CR: n = Xp.shape[0] #dimension of the problem aux = np.random.rand(n, options['mu']) <= options['CR'] # Now assures at least one coordinate will be changed, that is, # there is at least one 'true' in each column auxs = aux.sum(axis=0) == 0 #gets the columns with no trues indc = np.where(auxs)[0] #get the number of the columns indr = np.random.randint(0, n, auxs.sum()) #define random indexes of rows #ind = np.ravel_multi_index((indr,indc),(n, options['mu'])) #converts to lin. indexes aux[indr,indc] = True # Finally, creates the offspring Xo = Xp Xo[aux] = Xm[aux] return Xo
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 xyz_to_luv(xyz, xyzw=None, cieobs=_CIEOBS, **kwargs): """ Convert XYZ tristimulus values to CIE 1976 L*u*v* (CIELUV) coordinates. Args: :xyz: | ndarray with tristimulus values :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. Returns: :luv: | ndarray with CIE 1976 L*u*v* (CIELUV) color coordinates """ xyz = np2d(xyz) if xyzw is None: xyzw = spd_to_xyz(_CIE_ILLUMINANTS['D65'], cieobs=cieobs) # Calculate u',v' of test and white: Yuv = xyz_to_Yuv(xyz) Yuvw = xyz_to_Yuv(todim(xyzw, xyz.shape)) # todim: make xyzw same shape as xyz #uv1976 to CIELUV luv = np.empty(xyz.shape) YdivYw = Yuv[..., 0] / Yuvw[..., 0] luv[..., 0] = 116.0 * YdivYw**(1.0 / 3.0) - 16.0 p = np.where(YdivYw <= (6.0 / 29.0)**3.0) luv[..., 0][p] = ((29.0 / 3.0)**3.0) * YdivYw[p] luv[..., 1] = 13.0 * luv[..., 0] * (Yuv[..., 1] - Yuvw[..., 1]) luv[..., 2] = 13.0 * luv[..., 0] * (Yuv[..., 2] - Yuvw[..., 2]) return luv
def luv_to_xyz(luv, xyzw=None, cieobs=_CIEOBS, **kwargs): """ Convert CIE 1976 L*u*v* (CIELUVB) coordinates to XYZ tristimulus values. Args: :luv: | ndarray with CIE 1976 L*u*v* (CIELUV) 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. Returns: :xyz: | ndarray with tristimulus values """ luv = np2d(luv) if xyzw is None: xyzw = spd_to_xyz(_CIE_ILLUMINANTS['D65'], cieobs=cieobs) # Make xyzw same shape as luv and convert to Yuv: Yuvw = todim(xyz_to_Yuv(xyzw), luv.shape, equal_shape=True) # calculate u'v' from u*,v*: Yuv = np.empty(luv.shape) Yuv[..., 1:3] = (luv[..., 1:3] / (13 * luv[..., :1])) + Yuvw[..., 1:3] Yuv[Yuv[..., 0] == 0, 1:3] = 0 Yuv[..., 0] = Yuvw[..., 0] * (((luv[..., 0] + 16.0) / 116.0)**3.0) p = np.where((Yuv[..., 0] / Yuvw[..., 0]) < ((6.0 / 29.0)**3.0)) Yuv[..., 0][p] = Yuvw[..., 0][p] * (luv[..., 0][p] / ((29.0 / 3.0)**3.0)) return Yuv_to_xyz(Yuv)
def lmsb_to_xyzb(lms, fieldsize=10, out='XYZ', allow_negative_values=False): """ Convert from LMS cone fundamentals to XYZ color matching functions. Args: :lms: | ndarray with lms cone fundamentals, optional :fieldsize: | fieldsize in degrees, optional | Defaults to 10°. :out: | 'xyz' or str, optional | Determines output. :allow_negative_values: | False, optional | XYZ color matching functions should not have negative values. | If False: xyz[xyz<0] = 0. Returns: :returns: | LMS | - LMS: ndarray with population XYZ color matching functions. Note: For intermediate field sizes (2° < fieldsize < 10°) a conversion matrix is calculated by linear interpolation between the _INDVCMF_M_2d and _INDVCMF_M_10d matrices. """ wl = lms[None, 0] #store wavelengths M = get_lms_to_xyz_matrix(fieldsize=fieldsize) if lms.ndim > 2: xyz = np.vstack((wl, math.dot23(M, lms[1:, ...], keepdims=False))) else: xyz = np.vstack((wl, np.dot(M, lms[1:, ...]))) if allow_negative_values == False: xyz[np.where(xyz < 0)] = 0 return xyz
def _xyz_to_jab_cam02ucs(xyz, xyzw, ucs=True, conditions=None): """ Calculate CAM02-UCS J'a'b' coordinates from xyz tristimulus values of sample and white point. Args: :xyz: | ndarray with sample tristimulus values :xyzw: | ndarray with white point tristimulus values :conditions: | None, optional | Dictionary with viewing conditions. | None results in: | {'La':100, 'Yb':20, 'D':1, 'surround':'avg'} | For more info see luxpy.cam.ciecam02()? Returns: :jab: | ndarray with J'a'b' coordinates. """ #-------------------------------------------- # Get/ set conditions parameters: if conditions is not None: surround_parameters = { 'surrounds': ['avg', 'dim', 'dark'], 'avg': { 'c': 0.69, 'Nc': 1.0, 'F': 1.0, 'FLL': 1.0 }, 'dim': { 'c': 0.59, 'Nc': 0.9, 'F': 0.9, 'FLL': 1.0 }, 'dark': { 'c': 0.525, 'Nc': 0.8, 'F': 0.8, 'FLL': 1.0 } } La = conditions['La'] Yb = conditions['Yb'] D = conditions['D'] surround = conditions['surround'] if isinstance(surround, str): surround = surround_parameters[conditions['surround']] F, FLL, Nc, c = [surround[x] for x in sorted(surround.keys())] else: # set defaults: La, Yb, D, F, FLL, Nc, c = 100, 20, 1, 1, 1, 1, 0.69 #-------------------------------------------- # Define sensor space and cat matrices: mhpe = np.array([[0.38971, 0.68898, -0.07868], [-0.22981, 1.1834, 0.04641], [0.0, 0.0, 1.0] ]) # Hunt-Pointer-Estevez sensors (cone fundamentals) mcat = np.array([[0.7328, 0.4296, -0.1624], [-0.7036, 1.6975, 0.0061], [0.0030, 0.0136, 0.9834]]) # CAT02 sensor space #-------------------------------------------- # pre-calculate some matrices: invmcat = np.linalg.inv(mcat) mhpe_x_invmcat = np.dot(mhpe, invmcat) #-------------------------------------------- # calculate condition dependent parameters: Yw = xyzw[..., 1:2].T k = 1.0 / (5.0 * La + 1.0) FL = 0.2 * (k**4.0) * (5.0 * La) + 0.1 * ((1.0 - k**4.0)**2.0) * ( (5.0 * La)**(1.0 / 3.0)) # luminance adaptation factor n = Yb / Yw Nbb = 0.725 * (1 / n)**0.2 Ncb = Nbb z = 1.48 + FLL * n**0.5 if D is None: D = F * (1.0 - (1.0 / 3.6) * np.exp((-La - 42.0) / 92.0)) #-------------------------------------------- # transform from xyz, xyzw to cat sensor space: rgb = math.dot23(mcat, xyz.T) rgbw = mcat @ xyzw.T #-------------------------------------------- # apply von Kries cat: rgbc = ( (D * Yw / rgbw)[..., None] + (1 - D) ) * rgb # factor 100 from ciecam02 is replaced with Yw[i] in ciecam16, but see 'note' in Fairchild's "Color Appearance Models" (p291 ni 3ed.) rgbwc = ( (D * Yw / rgbw) + (1 - D) ) * rgbw # factor 100 from ciecam02 is replaced with Yw[i] in ciecam16, but see 'note' in Fairchild's "Color Appearance Models" (p291 ni 3ed.) #-------------------------------------------- # convert from cat02 sensor space to cone sensors (hpe): rgbp = math.dot23(mhpe_x_invmcat, rgbc).T rgbwp = (mhpe_x_invmcat @ rgbwc).T #-------------------------------------------- # apply Naka_rushton repsonse compression: naka_rushton = lambda x: 400 * x**0.42 / (x**0.42 + 27.13) + 0.1 rgbpa = naka_rushton(FL * rgbp / 100.0) p = np.where(rgbp < 0) rgbpa[p] = 0.1 - (naka_rushton(FL * np.abs(rgbp[p]) / 100.0) - 0.1) rgbwpa = naka_rushton(FL * rgbwp / 100.0) pw = np.where(rgbwp < 0) rgbwpa[pw] = 0.1 - (naka_rushton(FL * np.abs(rgbwp[pw]) / 100.0) - 0.1) #-------------------------------------------- # Calculate achromatic signal: A = (2.0 * rgbpa[..., 0] + rgbpa[..., 1] + (1.0 / 20.0) * rgbpa[..., 2] - 0.305) * Nbb Aw = (2.0 * rgbwpa[..., 0] + rgbwpa[..., 1] + (1.0 / 20.0) * rgbwpa[..., 2] - 0.305) * Nbb #-------------------------------------------- # calculate initial opponent channels: a = rgbpa[..., 0] - 12.0 * rgbpa[..., 1] / 11.0 + rgbpa[..., 2] / 11.0 b = (1.0 / 9.0) * (rgbpa[..., 0] + rgbpa[..., 1] - 2.0 * rgbpa[..., 2]) #-------------------------------------------- # calculate hue h and eccentricity factor, et: h = np.arctan2(b, a) et = (1.0 / 4.0) * (np.cos(h + 2.0) + 3.8) #-------------------------------------------- # calculate lightness, J: J = 100.0 * (A / Aw)**(c * z) #-------------------------------------------- # calculate chroma, C: t = ((50000.0 / 13.0) * Nc * Ncb * et * ((a**2.0 + b**2.0)**0.5)) / (rgbpa[..., 0] + rgbpa[..., 1] + (21.0 / 20.0 * rgbpa[..., 2])) C = (t**0.9) * ((J / 100.0)**0.5) * (1.64 - 0.29**n)**0.73 #-------------------------------------------- # Calculate colorfulness, M: M = C * FL**0.25 #-------------------------------------------- # convert to cam02ucs J', aM', bM': if ucs == True: KL, c1, c2 = 1.0, 0.007, 0.0228 Jp = (1.0 + 100.0 * c1) * J / (1.0 + c1 * J) Mp = (1.0 / c2) * np.log(1.0 + c2 * M) else: Jp = J Mp = M aMp = Mp * np.cos(h) bMp = Mp * np.sin(h) return np.dstack((Jp, aMp, bMp))
import copy from luxpy import (math, spd_to_xyz, xyz_to_cct, getwld, getwlr, _CMF, blackbody, daylightphase, _CRI_RFL, _CRI_REF_TYPES, _CRI_REF_TYPE, _CIEOBS, xyzbar, cie_interp) from luxpy.utils import np, plt from luxpy.color.cri.utils.DE_scalers import log_scale from luxpy.color.cri.utils.helpers import _get_hue_bin_data __all__ = ['_cri_ref', '_xyz_to_jab_cam02ucs', 'spd_to_tm30'] # new or redefined _DL = 1 _WL3 = [360, 830, _DL] _WL = getwlr(_WL3) _POS_WL560 = np.where(np.abs(_WL - 560.0) == np.min(np.abs(_WL - 560.0)))[0] _TM30_SAMPLE_SET = _CRI_RFL['ies-tm30-18']['99']['{:1.0f}nm'.format(_DL)] def _cri_ref_i(cct, wl3=_WL, ref_type='iestm30', mix_range=[4000, 5000], cieobs='1931_2', force_daylight_below4000K=False, n=None, daylight_locus=None): """ Calculates a reference illuminant spectrum based on cct for color rendering index calculations. """
def getCatObs(n_cat=10, fieldsize=2, out='LMS', wl=None, allow_negative_values=False): """ Generate cone fundamentals for categorical observers. Args: :n_cat: | 10, optional | Number of observer CMFs to generate. :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()) Notes: 1. Categorical observers are observer functions that would represent color-normal populations. They are finite and discrete as opposed to observer functions generated from the individual colorimetric observer model. Thus, they would offer more convenient and practical approaches for the personalized color imaging workflow and color matching analyses. Categorical observers were derived in two steps. At the first step, 10000 observer functions were generated from the individual colorimetric observer model using Monte Carlo simulation. At the second step, the cluster analysis, a modified k-medoids algorithm, was applied to the 10000 observers minimizing the squared Euclidean distance in cone fundamentals space, and categorical observers were derived iteratively. Since the proposed categorical observers are defined by their physiological parameters and ages, their CMFs can be derived for any target field size. 2. Categorical observers were ordered by the importance; the first categorical observer vas the average observer equivalent to CIEPO06 with 38 year-old for a given field size, followed by the second most important categorical observer, the third, and so on. 3. see: https://www.rit.edu/cos/colorscience/re_AsanoObserverFunctions.php """ # Use Iteratively Derived Cat.Obs.: var_age = _INDVCMF_CATOBSPFCTR['age'].copy() vAll = _INDVCMF_CATOBSPFCTR.copy() vAll.pop('age') # Set requested wavelength range: if wl is not None: wl = getwlr(wl3=wl) else: wl = _WL LMS_All = np.zeros((3 + 1, _WL.shape[0], n_cat)) LMS_All.fill(np.nan) for k in range(n_cat): t_LMS = 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') LMS_All[:, :, k] = t_LMS LMS_All[np.where(LMS_All < 0)] = 0 if n_cat == 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)
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): """ Generate Individual Observer CMFs (cone fundamentals) based on CIE2006 cone fundamentals and published literature on observer variability in color matching and in physiological parameters. Args: :age: | 32 or float or int, optional | Observer age :fieldsize: | 10, optional | Field size of stimulus in degrees (between 2° and 10°). :wl: | None, optional | Interpolation/extraplation of :LMS: output to specified wavelengths. | None: output original _WL = np.array([390,780,5]) :var_od_lens: | 0, optional | Std Dev. in peak optical density [%] of lens. :var_od_macula: | 0, optional | Std Dev. in peak optical density [%] of macula. :var_od_L: | 0, optional | Std Dev. in peak optical density [%] of L-cone. :var_od_M: | 0, optional | Std Dev. in peak optical density [%] of M-cone. :var_od_S: | 0, optional | Std Dev. in peak optical density [%] of S-cone. :var_shft_L: | 0, optional | Std Dev. in peak wavelength shift [nm] of L-cone. :var_shft_L: | 0, optional | Std Dev. in peak wavelength shift [nm] of M-cone. :var_shft_S: | 0, optional | Std Dev. in peak wavelength shift [nm] of S-cone. :out: | 'LMS' or , optional | Determines output. :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' : ndarray with individual observer area-normalized | cone fundamentals. Wavelength have been added. | [- 'trans_lens': ndarray with lens transmission | (no wavelengths added, no interpolation) | - 'trans_macula': ndarray with macula transmission | (no wavelengths added, no interpolation) | - 'sens_photopig' : ndarray with photopigment sens. | (no wavelengths added, no interpolation)] 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>`_ """ fs = fieldsize rmd = _INDVCMF_DATA['rmd'].copy() LMSa = _INDVCMF_DATA['LMSa'].copy() docul = _INDVCMF_DATA['docul'].copy() # field size corrected macular density: pkOd_Macula = 0.485 * np.exp(-fs / 6.132) * ( 1 + var_od_macula / 100) # varied peak optical density of macula corrected_rmd = rmd * pkOd_Macula # age corrected lens/ocular media density: if (age <= 60): correct_lomd = docul[:1] * (1 + 0.02 * (age - 32)) + docul[1:2] else: correct_lomd = docul[:1] * (1.56 + 0.0667 * (age - 60)) + docul[1:2] correct_lomd = correct_lomd * (1 + var_od_lens / 100 ) # varied overall optical density of lens # Peak Wavelength Shift: wl_shifted = np.empty(LMSa.shape) wl_shifted[0] = _WL + var_shft_L wl_shifted[1] = _WL + var_shft_M wl_shifted[2] = _WL + var_shft_S LMSa_shft = np.empty(LMSa.shape) kind = 'cubic' LMSa_shft[0] = sp.interpolate.interp1d(wl_shifted[0], LMSa[0], kind=kind, bounds_error=False, fill_value="extrapolate")(_WL) LMSa_shft[1] = sp.interpolate.interp1d(wl_shifted[1], LMSa[1], kind=kind, bounds_error=False, fill_value="extrapolate")(_WL) LMSa_shft[2] = sp.interpolate.interp1d(wl_shifted[2], LMSa[2], kind=kind, bounds_error=False, fill_value="extrapolate")(_WL) # LMSa[2,np.where(_WL >= _WL_CRIT)] = 0 #np.nan # Not defined above 620nm # LMSa_shft[2,np.where(_WL >= _WL_CRIT)] = 0 ssw = np.hstack( (0, np.sign(np.diff(LMSa_shft[2, :])) )) #detect poor interpolation (sign switch due to instability) LMSa_shft[2, np.where((ssw >= 0) & (_WL > 560))] = np.nan # corrected LMS (no age correction): pkOd_L = (0.38 + 0.54 * np.exp(-fs / 1.333)) * ( 1 + var_od_L / 100) # varied peak optical density of L-cone pkOd_M = (0.38 + 0.54 * np.exp(-fs / 1.333)) * ( 1 + var_od_M / 100) # varied peak optical density of M-cone pkOd_S = (0.30 + 0.45 * np.exp(-fs / 1.333)) * ( 1 + var_od_S / 100) # varied peak optical density of S-cone alpha_lms = 0. * LMSa_shft alpha_lms[0] = 1 - 10**(-pkOd_L * (10**LMSa_shft[0])) alpha_lms[1] = 1 - 10**(-pkOd_M * (10**LMSa_shft[1])) alpha_lms[2] = 1 - 10**(-pkOd_S * (10**LMSa_shft[2])) # this fix is required because the above math fails for alpha_lms[2,:]==0 alpha_lms[2, np.where(_WL >= _WL_CRIT)] = 0 # Corrected to Corneal Incidence: lms_barq = alpha_lms * (10**(-corrected_rmd - correct_lomd)) * np.ones( alpha_lms.shape) # Corrected to Energy Terms: lms_bar = lms_barq * _WL # Set NaN values to zero: lms_bar[np.isnan(lms_bar)] = 0 # normalized: LMS = 100 * lms_bar / np.nansum(lms_bar, axis=1, keepdims=True) # Output extra: trans_lens = 10**(-correct_lomd) trans_macula = 10**(-corrected_rmd) sens_photopig = alpha_lms * _WL # Add wavelengths: LMS = np.vstack((_WL, LMS)) if ('xyz' in out.lower().split(',')): LMS = lmsb_to_xyzb(LMS, 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') # Interpolate/extrapolate: if wl is None: interpolation = None else: interpolation = 'cubic' LMS = spd(LMS, wl=wl, interpolation=interpolation, norm_type='area') if (out == 'LMS'): return LMS elif (out == 'LMS,trans_lens,trans_macula,sens_photopig'): return LMS, trans_lens, trans_macula, sens_photopig elif (out == 'LMS,trans_lens,trans_macula,sens_photopig,LMSa'): return LMS, trans_lens, trans_macula, sens_photopig, LMSa else: return eval(out)
def spd_to_cqs(SPD, version='v9.0', out='Qa', wl=None): """ Calculates CQS Qa (Qai) or Qf (Qfi) or Qp (Qpi) for versions v9.0 or v7.5. Args: :SPD: | ndarray with spectral data (can be multiple SPDs, | first axis are the wavelengths) :version: | 'v9.0' or 'v7.5', optional :out: | 'Qa' or str, optional | Specifies requested output (e.g. 'Qa,Qai,Qf,cct,duv') :wl: | None, optional | Wavelengths (or [start, end, spacing]) to interpolate the SPDs to. | None: default to no interpolation Returns: :returns: | float or ndarray with CQS Qa for :out: 'Qa' | Other output is also possible by changing the :out: str value. References: 1. `W. Davis and Y. Ohno, “Color quality scale,” (2010), Opt. Eng., vol. 49, no. 3, pp. 33602–33616. <http://spie.org/Publications/Journal/10.1117/1.3360335>`_ """ outlist = out.split() if isinstance(version, str): cri_type = 'cqs-' + version elif isinstance(version, dict): cri_type = version # calculate DEI, labti, labri and get cspace_pars and rg_pars: DEi, labti, labri, cct, duv, cri_type = spd_to_DEi( SPD, cri_type=cri_type, out='DEi,jabt,jabr,cct,duv,cri_type', wl=wl) # further unpack cri_type: scale_fcn = cri_type['scale']['fcn'] scale_factor = cri_type['scale']['cfactor'] avg = cri_type['avg'] cri_specific_pars = cri_type['cri_specific_pars'] rg_pars = cri_type['rg_pars'] # get maxC: to limit chroma-enhancement: maxC = cri_specific_pars['maxC'] # make 3d: test_original_shape = labti.shape if len(test_original_shape) < 3: labti = labti[:, None] labri = labri[:, None] DEi = DEi[:, None] cct = cct[:, None] # calculate Rg for each spd: Qf = np.zeros((1, labti.shape[1])) Qfi = np.zeros((labti.shape[0], labti.shape[1])) if version == 'v7.5': GA = (9.2672 * (1.0e-11)) * cct**3.0 - ( 8.3959 * (1.0e-7)) * cct**2.0 + 0.00255 * cct - 1.612 elif version == 'v9.0': GA = np.ones(cct.shape) else: raise Exception('.cri.spd_to_cqs(): Unrecognized CQS version.') if ('Qf' in outlist) | ('Qfi' in outlist): # loop of light source spds for ii in range(labti.shape[1]): Qfi[:, ii] = GA[ii] * scale_fcn(DEi[:, ii], [scale_factor[0]]) Qf[:, ii] = GA[ii] * scale_fcn(avg(DEi[:, ii, None], axis=0), [scale_factor[0]]) if ('Qa' in outlist) | ('Qai' in outlist) | ('Qp' in outlist) | ( 'Qpi' in outlist): Qa = Qf.copy() Qai = Qfi.copy() Qp = Qf.copy() Qpi = Qfi.copy() # loop of light source spds for ii in range(labti.shape[1]): # calculate deltaC: deltaC = np.sqrt( np.power(labti[:, ii, 1:3], 2).sum( axis=1, keepdims=True)) - np.sqrt( np.power(labri[:, ii, 1:3], 2).sum(axis=1, keepdims=True)) # limit chroma increase: DEi_Climited = DEi[:, ii, None].copy() deltaC_Climited = deltaC.copy() if maxC is None: maxC = 10000.0 limitC = np.where(deltaC >= maxC)[0] deltaC_Climited[limitC] = maxC p_deltaC_pos = np.where(deltaC > 0.0)[0] DEi_Climited[p_deltaC_pos] = np.sqrt( DEi_Climited[p_deltaC_pos]**2.0 - deltaC_Climited[p_deltaC_pos] **2.0) # increase in chroma is not penalized! if ('Qa' in outlist) | ('Qai' in outlist): Qai[:, ii, None] = GA[ii] * scale_fcn(DEi_Climited, [scale_factor[1]]) Qa[:, ii] = GA[ii] * scale_fcn(avg(DEi_Climited, axis=0), [scale_factor[1]]) if ('Qp' in outlist) | ('Qpi' in outlist): deltaC_pos = deltaC_Climited * (deltaC_Climited >= 0.0) deltaCmu = np.mean(deltaC_Climited * (deltaC_Climited >= 0.0)) Qpi[:, ii, None] = GA[ii] * scale_fcn( (DEi_Climited - deltaC_pos), [scale_factor[2] ]) # or ?? np.sqrt(DEi_Climited**2 - deltaC_pos**2) ?? Qp[:, ii] = GA[ii] * scale_fcn( (avg(DEi_Climited, axis=0) - deltaCmu), [scale_factor[2]]) if ('Qg' in outlist): Qg = Qf.copy() for ii in range(labti.shape[1]): Qg[:, ii] = 100.0 * math.polyarea( labti[:, ii, 1], labti[:, ii, 2]) / math.polyarea( labri[:, ii, 1], labri[:, ii, 2] ) # calculate Rg = gamut area ratio of test and ref if out == 'Qa': return Qa else: return eval(out)
def run(data, xyzw=_DEFAULT_WHITE_POINT, Yw=None, outin='J,aM,bM', conditions=None, forward=True, yellowbluepurplecorrect=False, mcat='cat02'): """ Run CIECAM02 color appearance model in forward or backward modes. Args: :data: | ndarray with relative sample xyz values (forward mode) or J'a'b' coordinates (inverse mode) :xyzw: | ndarray with relative white point tristimulus values :Yw: | None, optional | Luminance factor of white point. | If None: xyz (in data) and xyzw are entered as relative tristimulus values | (normalized to Yw = 100). | If not None: input tristimulus are absolute and Yw is used to | rescale the absolute values to relative ones | (relative to a reference perfect white diffuser | with Ywr = 100). | Yw can be < 100 for e.g. paper as white point. If Yw is None, it | is assumed that the relative Y-tristimulus value in xyzw | represents the luminance factor Yw. :conditions: | None, optional | Dictionary with viewing condition parameters for: | La, Yb, D and surround. | surround can contain: | - str (options: 'avg','dim','dark') or | - dict with keys c, Nc, F. | None results in: | {'La':100, 'Yb':20, 'D':1, 'surround':'avg'} :forward: | True, optional | If True: run in CAM in forward mode, else: inverse mode. :outin: | 'J,aM,bM', optional | String with requested output (e.g. "J,aM,bM,M,h") [Forward mode] | - attributes: 'J': lightness,'Q': brightness, | 'M': colorfulness,'C': chroma, 's': saturation, | 'h': hue angle, 'H': hue quadrature/composition, | String with inputs in data [inverse mode]. | Input must have data.shape[-1]==3 and last dim of data must have | the following structure for inverse mode: | * data[...,0] = J or Q, | * data[...,1:] = (aM,bM) or (aC,bC) or (aS,bS) or (M,h) or (C, h), ... :yellowbluepurplecorrect: | False, optional | If False: don't correct for yellow-blue and purple problems in ciecam02. | If 'brill-suss': | for yellow-blue problem, see: | - Brill [Color Res Appl, 2006; 31, 142-145] and | - Brill and Süsstrunk [Color Res Appl, 2008; 33, 424-426] | If 'jiang-luo': | for yellow-blue problem + purple line problem, see: | - Jiang, Jun et al. [Color Res Appl 2015: 40(5), 491-503] :mcat: | 'cat02', optional | Specifies CAT sensor space. | - options: | - None defaults to 'cat02' | (others e.g. 'cat02-bs', 'cat02-jiang', | all trying to correct gamut problems of original cat02 matrix) | - str: see see luxpy.cat._MCATS.keys() for options | (details on type, ?luxpy.cat) | - ndarray: matrix with sensor primaries Returns: :camout: | ndarray with color appearance correlates (forward mode) | or | XYZ tristimulus values (inverse mode) References: 1. `N. Moroney, M. D. Fairchild, R. W. G. Hunt, C. Li, M. R. Luo, and T. Newman, (2002), "The CIECAM02 color appearance model,” IS&T/SID Tenth Color Imaging Conference. p. 23, 2002. <http://rit-mcsl.org/fairchild/PDFs/PRO19.pdf>`_ """ outin = outin.split(',') if isinstance(outin, str) else outin #-------------------------------------------- # Get condition parameters: if conditions is None: conditions = _DEFAULT_CONDITIONS D, Dtype, La, Yb, surround = (conditions[x] for x in sorted(conditions.keys())) surround_parameters = _SURROUND_PARAMETERS if isinstance(surround, str): surround = surround_parameters[conditions['surround']] F, FLL, Nc, c = [surround[x] for x in sorted(surround.keys())] #-------------------------------------------- # Define sensor space and cat matrices: # Hunt-Pointer-Estevez sensors (cone fundamentals) mhpe = cat._MCATS['hpe'] # chromatic adaptation sensors: if (mcat is None) | (mcat == 'cat02'): mcat = cat._MCATS['cat02'] if yellowbluepurplecorrect == 'brill-suss': mcat = cat._MCATS[ 'cat02-bs'] # for yellow-blue problem, Brill [Color Res Appl 2006;31:142-145] and Brill and Süsstrunk [Color Res Appl 2008;33:424-426] elif yellowbluepurplecorrect == 'jiang-luo': mcat = cat._MCATS[ 'cat02-jiang-luo'] # for yellow-blue problem + purple line problem elif isinstance(mcat, str): mcat = cat._MCATS[mcat] #-------------------------------------------- # pre-calculate some matrices: invmcat = np.linalg.inv(mcat) mhpe_x_invmcat = np.dot(mhpe, invmcat) if not forward: mcat_x_invmhpe = np.dot(mcat, np.linalg.inv(mhpe)) #-------------------------------------------- # Set Yw: if Yw is not None: Yw = (Yw * np.ones_like(xyzw2[..., 1:2]).T) else: Yw = xyzw[..., 1:2].T #-------------------------------------------- # calculate condition dependent parameters: k = 1.0 / (5.0 * La + 1.0) FL = 0.2 * (k**4.0) * (5.0 * La) + 0.1 * ((1.0 - k**4.0)**2.0) * ( (5.0 * La)**(1.0 / 3.0)) # luminance adaptation factor n = Yb / Yw Nbb = 0.725 * (1 / n)**0.2 Ncb = Nbb z = 1.48 + FLL * n**0.5 yw = xyzw[..., 1:2].T # original Y in xyzw (pre-transposed) #-------------------------------------------- # Calculate degree of chromatic adaptation: if D is None: D = F * (1.0 - (1.0 / 3.6) * np.exp((-La - 42.0) / 92.0)) #=================================================================== # WHITE POINT transformations (common to forward and inverse modes): #-------------------------------------------- # Normalize white point (keep transpose for next step): xyzw = Yw * xyzw.T / yw #-------------------------------------------- # transform from xyzw to cat sensor space: rgbw = math.dot23(mcat, xyzw) #-------------------------------------------- # apply von Kries cat: rgbwc = ( (D * Yw / rgbw) + (1 - D) ) * rgbw # factor 100 from ciecam02 is replaced with Yw[i] in ciecam16, but see 'note' in Fairchild's "Color Appearance Models" (p291 ni 3ed.) #-------------------------------------------- # convert from cat02 sensor space to cone sensors (hpe): rgbwp = math.dot23(mhpe_x_invmcat, rgbwc).T #-------------------------------------------- # apply Naka_rushton repsonse compression to white: NK = lambda x, forward: naka_rushton(x, scaling=400, n=0.42, sig=27.13**(1 / 0.42), noise=0.1, forward=forward) pw = np.where(rgbwp < 0) # if requested apply yellow-blue correction: if (yellowbluepurplecorrect == 'brill-suss' ): # Brill & Susstrunck approach, for purple line problem rgbwp[pw] = 0.0 rgbwpa = NK(FL * rgbwp / 100.0, True) rgbwpa[pw] = 0.1 - (NK(FL * np.abs(rgbwp[pw]) / 100.0, True) - 0.1) #-------------------------------------------- # Calculate achromatic signal of white: Aw = (2.0 * rgbwpa[..., 0] + rgbwpa[..., 1] + (1.0 / 20.0) * rgbwpa[..., 2] - 0.305) * Nbb # massage shape of data for broadcasting: original_ndim = data.ndim if data.ndim == 2: data = data[:, None] #=================================================================== # STIMULUS transformations if forward: #-------------------------------------------- # Normalize xyz (keep transpose for matrix multiplication in next step): xyz = (Yw / yw)[..., None] * data.T #-------------------------------------------- # transform from xyz to cat sensor space: rgb = math.dot23(mcat, xyz) #-------------------------------------------- # apply von Kries cat: rgbc = ( (D * Yw / rgbw)[..., None] + (1 - D) ) * rgb # factor 100 from ciecam02 is replaced with Yw[i] in ciecam16, but see 'note' in Fairchild's "Color Appearance Models" (p291 ni 3ed.) #-------------------------------------------- # convert from cat02 sensor space to cone sensors (hpe): rgbp = math.dot23(mhpe_x_invmcat, rgbc).T #-------------------------------------------- # apply Naka_rushton repsonse compression: p = np.where(rgbp < 0) if (yellowbluepurplecorrect == 'brill-suss' ): # Brill & Susstrunck approach, for purple line problem rgbp[p] = 0.0 rgbpa = NK(FL * rgbp / 100.0, forward) rgbpa[p] = 0.1 - (NK(FL * np.abs(rgbp[p]) / 100.0, forward) - 0.1) #-------------------------------------------- # Calculate achromatic signal: A = (2.0 * rgbpa[..., 0] + rgbpa[..., 1] + (1.0 / 20.0) * rgbpa[..., 2] - 0.305) * Nbb #-------------------------------------------- # calculate initial opponent channels: a = rgbpa[..., 0] - 12.0 * rgbpa[..., 1] / 11.0 + rgbpa[..., 2] / 11.0 b = (1.0 / 9.0) * (rgbpa[..., 0] + rgbpa[..., 1] - 2.0 * rgbpa[..., 2]) #-------------------------------------------- # calculate hue h and eccentricity factor, et: h = hue_angle(a, b, htype='deg') et = (1.0 / 4.0) * (np.cos(h * np.pi / 180 + 2.0) + 3.8) #-------------------------------------------- # calculate Hue quadrature (if requested in 'out'): if 'H' in outin: H = hue_quadrature(h, unique_hue_data=_UNIQUE_HUE_DATA) else: H = None #-------------------------------------------- # calculate lightness, J: J = 100.0 * (A / Aw)**(c * z) #-------------------------------------------- # calculate brightness, Q: Q = (4.0 / c) * ((J / 100.0)**0.5) * (Aw + 4.0) * (FL**0.25) #-------------------------------------------- # calculate chroma, C: t = ((50000.0 / 13.0) * Nc * Ncb * et * ((a**2.0 + b**2.0)**0.5)) / (rgbpa[..., 0] + rgbpa[..., 1] + (21.0 / 20.0 * rgbpa[..., 2])) C = (t**0.9) * ((J / 100.0)**0.5) * (1.64 - 0.29**n)**0.73 #-------------------------------------------- # calculate colorfulness, M: M = C * FL**0.25 #-------------------------------------------- # calculate saturation, s: s = 100.0 * (M / Q)**0.5 S = s # make extra variable, jsut in case 'S' is called #-------------------------------------------- # calculate cartesian coordinates: if ('aS' in outin): aS = s * np.cos(h * np.pi / 180.0) bS = s * np.sin(h * np.pi / 180.0) if ('aC' in outin): aC = C * np.cos(h * np.pi / 180.0) bC = C * np.sin(h * np.pi / 180.0) if ('aM' in outin): aM = M * np.cos(h * np.pi / 180.0) bM = M * np.sin(h * np.pi / 180.0) #-------------------------------------------- if outin != ['J', 'aM', 'bM']: camout = eval('ajoin((' + ','.join(outin) + '))') else: camout = ajoin((J, aM, bM)) if (camout.shape[1] == 1) & (original_ndim < 3): camout = camout[:, 0, :] return camout elif forward == False: #-------------------------------------------- # Get Lightness J from data: if ('J' in outin[0]): J = data[..., 0].copy() elif ('Q' in outin[0]): Q = data[..., 0].copy() J = 100.0 * (Q / ((Aw + 4.0) * (FL**0.25) * (4.0 / c)))**2.0 else: raise Exception( 'No lightness or brightness values in data. Inverse CAM-transform not possible!' ) #-------------------------------------------- if 'a' in outin[1]: # calculate hue h: h = hue_angle(data[..., 1], data[..., 2], htype='deg') #-------------------------------------------- # calculate Colorfulness M or Chroma C or Saturation s from a,b: MCs = (data[..., 1]**2.0 + data[..., 2]**2.0)**0.5 else: h = data[..., 2] MCs = data[..., 1] if ('S' in outin[1]): Q = (4.0 / c) * ((J / 100.0)**0.5) * (Aw + 4.0) * (FL**0.25) M = Q * (MCs / 100.0)**2.0 C = M / (FL**0.25) if ('M' in outin[1]): # convert M to C: C = MCs / (FL**0.25) if ('C' in outin[1]): C = MCs #-------------------------------------------- # calculate t from J, C: t = (C / ((J / 100.0)**(1.0 / 2.0) * (1.64 - 0.29**n)**0.73))**(1.0 / 0.9) #-------------------------------------------- # calculate eccentricity factor, et: et = (np.cos(h * np.pi / 180.0 + 2.0) + 3.8) / 4.0 #-------------------------------------------- # calculate achromatic signal, A: A = Aw * (J / 100.0)**(1.0 / (c * z)) #-------------------------------------------- # calculate temporary cart. co. at, bt and p1,p2,p3,p4,p5: at = np.cos(h * np.pi / 180.0) bt = np.sin(h * np.pi / 180.0) p1 = (50000.0 / 13.0) * Nc * Ncb * et / t p2 = A / Nbb + 0.305 p3 = 21.0 / 20.0 p4 = p1 / bt p5 = p1 / at #-------------------------------------------- #q = np.where(np.abs(bt) < np.abs(at))[0] q = (np.abs(bt) < np.abs(at)) b = p2 * (2.0 + p3) * (460.0 / 1403.0) / (p4 + (2.0 + p3) * (220.0 / 1403.0) * (at / bt) - (27.0 / 1403.0) + p3 * (6300.0 / 1403.0)) a = b * (at / bt) a[q] = p2[q] * (2.0 + p3) * (460.0 / 1403.0) / (p5[q] + (2.0 + p3) * (220.0 / 1403.0) - ((27.0 / 1403.0) - p3 * (6300.0 / 1403.0)) * (bt[q] / at[q])) b[q] = a[q] * (bt[q] / at[q]) #-------------------------------------------- # calculate post-adaptation values rpa = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0 gpa = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0 bpa = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0 #-------------------------------------------- # join values: rgbpa = ajoin((rpa, gpa, bpa)) #-------------------------------------------- # decompress signals: rgbp = (100.0 / FL) * NK(rgbpa, forward) # apply yellow-blue correction: if (yellowbluepurplecorrect == 'brill-suss' ): # Brill & Susstrunck approach, for purple line problem p = np.where(rgbp < 0.0) rgbp[p] = 0.0 #-------------------------------------------- # convert from to cone sensors (hpe) cat02 sensor space: rgbc = math.dot23(mcat_x_invmhpe, rgbp.T) #-------------------------------------------- # apply inverse von Kries cat: rgb = rgbc / ((D * Yw / rgbw)[..., None] + (1.0 - D)) #-------------------------------------------- # transform from cat sensor space to xyz: xyz = math.dot23(invmcat, rgb) #-------------------------------------------- # unnormalize xyz: xyz = ((yw / Yw)[..., None] * xyz).T return xyz
def initialize_VF_hue_angles(hx = None, Cxr = _VF_MAXR, \ cri_type = _VF_CRI_DEFAULT, \ modeltype = _VF_MODEL_TYPE,\ determine_hue_angles = _DETERMINE_HUE_ANGLES): """ Initialize the hue angles that will be used to 'summarize' the VF model fitting parameters. Args: :hx: | None or ndarray, optional | None defaults to Munsell H5 hues. :Cxr: | _VF_MAXR, optional :cri_type: | _VF_CRI_DEFAULT or str or dict, optional, | Cri_type parameters for cri and VF model. :modeltype: | _VF_MODEL_TYPE or 'M5' or 'M6', optional | Determines the type of polynomial model. :determine_hue_angles: | _DETERMINE_HUE_ANGLES or True or False, optional | True: determines the 10 primary / secondary Munsell hues ('5..'). | Note that for 'M6', an additional Returns: :pcolorshift: | {'href': href, | 'Cref' : _VF_MAXR, | 'sig' : _VF_SIG, | 'labels' : list[str]} """ ########################################### # Get Munsell H5 hues: ########################################### rflM = _MUNSELL['R'] hn = _MUNSELL['H'] # all Munsell hues rH5 = np.where([ _MUNSELL['H'][:, 0][x][0] == '5' for x in range(_MUNSELL['H'][:, 0].shape[0]) ])[0] #all Munsell H5 hues hns5 = np.unique(_MUNSELL['H'][rH5]) #------------------------------------------------------------------------------ # Determine Munsell hue angles in cam02ucs: pool = False IllC = _CIE_ILLUMINANTS[ 'C'] # for determining Munsell hue angles in cam02ucs outM = VF_colorshift_model(IllC, cri_type=cri_type, sampleset=rflM, vfcolor='g', pool=pool) #------------------------------------------------------------------------------ if (determine_hue_angles == True) | (hx is None): # find samples at major Munsell hue angles: all_h5_Munsell_cam02ucs = np.ones(hns5.shape) Jabt_IllC = outM[0]['Jab']['Jabt'] for i, v in enumerate(hns5): hm = np.where(hn == v)[0] all_h5_Munsell_cam02ucs[i] = math.positive_arctan( [Jabt_IllC[hm, 0, 1].mean()], [Jabt_IllC[hm, 0, 2].mean()], htype='rad')[0] hx = all_h5_Munsell_cam02ucs #------------------------------------------------------------------------------ # Setp color shift parameters: pcolorshift = {'href': hx, 'Cref': Cxr, 'sig': _VF_SIG, 'labels': hns5} return pcolorshift
def DE2000(xyzt, xyzr, dtype='xyz', DEtype='jab', avg=None, avg_axis=0, out='DEi', xyzwt=None, xyzwr=None, KLCH=None): """ Calculate DE2000 color difference. Args: :xyzt: | ndarray with tristimulus values of test data. :xyzr: | ndarray with tristimulus values of reference data. :dtype: | 'xyz' or 'lab', optional | Specifies data type in :xyzt: and :xyzr:. :xyzwt: | None or ndarray, optional | White point tristimulus values of test data | None defaults to the one set in lx.xyz_to_lab() :xyzwr: | None or ndarray, optional | Whitepoint tristimulus values of reference data | None defaults to the one set in lx.xyz_to_lab() :DEtype: | 'jab' or str, optional | Options: | - 'jab' : calculates full color difference over all 3 dimensions. | - 'ab' : calculates chromaticity difference. | - 'j' : calculates lightness or brightness difference | (depending on :outin:). | - 'j,ab': calculates both 'j' and 'ab' options | and returns them as a tuple. :KLCH: | None, optional | Weigths for L, C, H | None: default to [1,1,1] :avg: | None, optional | None: don't calculate average DE, | otherwise use function handle in :avg:. :avg_axis: | axis to calculate average over, optional :out: | 'DEi' or str, optional | Requested output. Note: For the other input arguments, see specific color space used. Returns: :returns: | ndarray with DEi [, DEa] or other as specified by :out: References: 1. `Sharma, G., Wu, W., & Dalal, E. N. (2005). The CIEDE2000 color‐difference formula: Implementation notes, supplementary test data, and mathematical observations. Color Research & Application, 30(1), 21–30. <https://doi.org/10.1002/col.20070>`_ """ if KLCH is None: KLCH = [1, 1, 1] if dtype == 'xyz': labt = xyz_to_lab(xyzt, xyzw=xyzwt) labr = xyz_to_lab(xyzr, xyzw=xyzwr) else: labt = xyzt labr = xyzr Lt = labt[..., 0:1] at = labt[..., 1:2] bt = labt[..., 2:3] Ct = np.sqrt(at**2 + bt**2) #ht = cam.hue_angle(at,bt,htype = 'rad') Lr = labr[..., 0:1] ar = labr[..., 1:2] br = labr[..., 2:3] Cr = np.sqrt(ar**2 + br**2) #hr = cam.hue_angle(at,bt,htype = 'rad') # Step 1: Cavg = (Ct + Cr) / 2 G = 0.5 * (1 - np.sqrt((Cavg**7.0) / ((Cavg**7.0) + (25.0**7)))) apt = (1 + G) * at apr = (1 + G) * ar Cpt = np.sqrt(apt**2 + bt**2) Cpr = np.sqrt(apr**2 + br**2) Cpprod = Cpt * Cpr hpt = cam.hue_angle(apt, bt, htype='deg') hpr = cam.hue_angle(apr, br, htype='deg') hpt[(apt == 0) * (bt == 0)] = 0 hpr[(apr == 0) * (br == 0)] = 0 # Step 2: dL = np.abs(Lr - Lt) dCp = np.abs(Cpr - Cpt) dhp_ = hpr - hpt dhp = dhp_.copy() dhp[np.where(np.abs(dhp_) > 180)] = dhp[np.where(np.abs(dhp_) > 180)] - 360 dhp[np.where( np.abs(dhp_) < -180)] = dhp[np.where(np.abs(dhp_) < -180)] + 360 dhp[np.where(Cpprod == 0)] = 0 #dH = 2*np.sqrt(Cpprod)*np.sin(dhp/2*np.pi/180) dH = deltaH(dhp, Cpprod, htype='deg') # Step 3: Lp = (Lr + Lt) / 2 Cp = (Cpr + Cpt) / 2 hps = hpt + hpr hp = (hpt + hpr) / 2 hp[np.where((np.abs(dhp_) > 180) & (hps < 360))] = hp[np.where((np.abs(dhp_) > 180) & (hps < 360))] + 180 hp[np.where((np.abs(dhp_) > 180) & (hps >= 360))] = hp[np.where((np.abs(dhp_) > 180) & (hps >= 360))] - 180 hp[np.where(Cpprod == 0)] = 0 T = 1 - 0.17*np.cos((hp - 30)*np.pi/180) + 0.24*np.cos(2*hp*np.pi/180) +\ 0.32*np.cos((3*hp + 6)*np.pi/180) - 0.20*np.cos((4*hp - 63)*np.pi/180) dtheta = 30 * np.exp(-((hp - 275) / 25)**2) RC = 2 * np.sqrt((Cp**7) / ((Cp**7) + (25**7))) SL = 1 + ((0.015 * (Lp - 50)**2) / np.sqrt(20 + (Lp - 50)**2)) SC = 1 + 0.045 * Cp SH = 1 + 0.015 * Cp * T RT = -np.sin(2 * dtheta * np.pi / 180) * RC kL, kC, kH = KLCH DEi = ((dL / (kL * SL))**2, (dCp / (kC * SC))**2 + (dH / (kH * SH))**2 + RT * (dCp / (kC * SC)) * (dH / (kH * SH))) return _process_DEi(DEi, DEtype=DEtype, avg=avg, avg_axis=avg_axis, out=out)
def spd_to_COI_ASNZS1680(S=None, tf=_COI_CSPACE, cieobs=_COI_CIEOBS, out='COI,cct', extrapolate_rfl=False): """ Calculate the Cyanosis Observation Index (COI) [ASNZS 1680.2.5-1995]. Args: :S: | ndarray with light source spectrum (first column are wavelengths). :tf: | _COI_CSPACE, optional | Color space in which to calculate the COI. | Default is CIELAB. :cieobs: | _COI_CIEOBS, optional | CMF set to use. | Default is '1931_2'. :out: | 'COI,cct' or str, optional | Determines output. :extrapolate_rfl: | False, optional | If False: | limit the wavelength range of the source to that of the standard | reflectance spectra for the 50% and 100% oxygenated blood. Returns: :COI: | ndarray with cyanosis indices for input sources. :cct: | ndarray with correlated color temperatures. Note: Clause 7.2 of the ASNZS 1680.2.5-1995. standard mentions the properties demanded of the light source used in region where visual conditions suitable to the detection of cyanosis should be provided: 1. The correlated color temperature (CCT) of the source should be from 3300 to 5300 K. 2. The cyanosis observation index should not exceed 3.3 """ if S is None: #use default S = _CIE_ILLUMINANTS['F4'] if extrapolate_rfl == False: # _COI_RFL do not cover the full 360-830nm range. wl_min = _COI_RFL_BLOOD[0].min() wl_max = _COI_RFL_BLOOD[0].max() S = S[:, np.where((S[0] >= wl_min) & (S[0] <= wl_max))[0]] # Calculate reference spd: Sr = blackbody(4000, wl3=S[0]) # same wavelength range # Calculate xyz of blood under test source and ref. source: xyzt, xyzwt = spd_to_xyz(S, rfl=_COI_RFL_BLOOD, relative=True, cieobs=cieobs, out=2) xyzr, xyzwr = spd_to_xyz(Sr, rfl=_COI_RFL_BLOOD, relative=True, cieobs=cieobs, out=2) # Calculate color difference between blood under test and ref. DEi = deltaE.DE_cspace(xyzt, xyzr, xyzwt=xyzwt, xyzwr=xyzwr, tf=tf) # Calculate Cyanosis Observation Index: COI = np.nanmean(DEi, axis=0)[:, None] # Calculate cct, if requested: if 'cct' in out.split(','): cct, duv = xyz_to_cct(xyzwt, cieobs=cieobs, out=2) # manage output: if out == 'COI': return COI elif out == 'COI,cct': return COI, cct else: return eval(out)
def cie_interp(data, wl_new, kind=None, negative_values_allowed=False, extrap_values=None): """ Interpolate / extrapolate spectral data following standard CIE15-2018. | The kind of interpolation depends on the spectrum type defined in :kind:. | Extrapolation is always done by replicate the closest known values. Args: :data: | ndarray with spectral data | (.shape = (number of spectra + 1, number of original wavelengths)) :wl_new: | ndarray with new wavelengths :kind: | None, optional | - If :kind: is None, return original data. | - If :kind: is a spectrum type (see _INTERP_TYPES), the correct | interpolation type if automatically chosen. | - Or :kind: can be any interpolation type supported by | scipy.interpolate.interp1d (math.interp1d if nan's are present!!) :negative_values_allowed: | False, optional | If False: negative values are clipped to zero. :extrap_values: | None, optional | If None: use CIE recommended 'closest value' approach when extrapolating. | If float or list or ndarray, use those values to fill extrapolated value(s). | If 'ext': use normal extrapolated values by scipy.interpolate.interp1d Returns: :returns: | ndarray of interpolated spectral data. | (.shape = (number of spectra + 1, number of wavelength in wl_new)) """ if (kind is not None): # Wavelength definition: wl_new = getwlr(wl_new) if (not np.array_equal(data[0], wl_new)) | np.isnan(data).any(): extrap_values = np.atleast_1d(extrap_values) # Set interpolation type based on data type: if kind in _INTERP_TYPES['linear']: kind = 'linear' elif kind in _INTERP_TYPES['cubic']: kind = 'cubic' # define wl, S, wl_new: wl = np.array(data[0]) S = data[1:] wl_new = np.array(wl_new) # Interpolate each spectrum in S: N = S.shape[0] nan_indices = np.isnan(S) # Interpolate all (if not all rows have nan): rows_with_nans = np.where(nan_indices.sum(axis=1))[0] if not (rows_with_nans.size == N): #allrows_nans = False if extrap_values[0] is None: fill_value = (0, 0) elif (((type(extrap_values[0]) == np.str_) | (type(extrap_values[0]) == str)) and (extrap_values[0][:3] == 'ext')): fill_value = 'extrapolate' else: fill_value = (extrap_values[0], extrap_values[-1]) Si = sp.interpolate.interp1d(wl, S, kind=kind, bounds_error=False, fill_value=fill_value)(wl_new) #extrapolate by replicating closest known (in source data!) value (conform CIE15-2004 recommendation) if extrap_values[0] is None: Si[:, wl_new < wl[0]] = S[:, :1] Si[:, wl_new > wl[-1]] = S[:, -1:] else: #allrows_nans = True Si = np.zeros([N, wl_new.shape[0]]) Si.fill(np.nan) # Re-interpolate those which have none: if nan_indices.any(): #looping required as some values are NaN's for i in rows_with_nans: nonan_indices = np.logical_not(nan_indices[i]) wl_nonan = wl[nonan_indices] S_i_nonan = S[i][nonan_indices] Si_nonan = math.interp1(wl_nonan, S_i_nonan, wl_new, kind=kind, ext='extrapolate') # Si_nonan = sp.interpolate.interp1d(wl_nonan, S_i_nonan, kind = kind, bounds_error = False, fill_value = 'extrapolate')(wl_new) #extrapolate by replicating closest known (in source data!) value (conform CIE15-2004 recommendation) if extrap_values[0] is None: Si_nonan[wl_new < wl_nonan[0]] = S_i_nonan[0] Si_nonan[wl_new > wl_nonan[-1]] = S_i_nonan[-1] elif (((type(extrap_values[0]) == np.str_) | (type(extrap_values[0]) == str)) and (extrap_values[0][:3] == 'ext')): pass else: Si_nonan[wl_new < wl_nonan[0]] = extrap_values[0] Si_nonan[wl_new > wl_nonan[-1]] = extrap_values[-1] Si[i] = Si_nonan # No negative values allowed for spectra: if negative_values_allowed == False: if np.any(Si): Si[Si < 0.0] = 0.0 # Add wavelengths to data array: return np.vstack((wl_new, Si)) return data
def selection(P, O, options): """ Selects the next population. | Each parent is compared to its offspring. If the parent dominates its | child, then it goes to the next population. If the offspring dominates | the parent, that new member is added. However, if they are incomparable | (there is no mutual domination), them both are sent to the next | population. After that, the new set of individuals must be truncated to | mu, wherein mu is the original number of points. | This is accomplished by the use of "non-dominated sorting", that is, | ranks the individual in fronts of non-domination, and within each | front, measures them by using crowding distance. With regard to these | two metrics, the best individuals are kept in the new population. Args: :P: | a dict with the parents (x and f) :O: | a dict with the offspring :options: | the dict with the algorithm's parameters Returns: :Pnew: | the new population (a dict with x and f) """ # ------ First part: checks dominance between parents and offspring # Verifies whether parent dominates offspring: aux1 = (P['f'] <= O['f']).all(axis = 0) aux2 = (P['f'] < O['f']).any(axis = 0) auxp = np.logical_and(aux1, aux2) #P dominates O # Now, where offspring dominates parent: aux1 = (P['f'] >= O['f']).all(axis = 0) aux2 = (P['f'] > O['f']).any(axis = 0) auxo = np.logical_and(aux1, aux2) #O dominates P auxpo = np.logical_and(~auxp, ~auxo); #P and O are incomparable # New population (where P dominates O, O dominates P and where they are # incomparable) R = {'f' : np.hstack((P['f'][:,auxp].copy(), O['f'][:,auxo].copy(), P['f'][:,auxpo].copy(), O['f'][:,auxpo].copy()))} R['x'] = np.hstack((P['x'][:,auxp].copy(), O['x'][:,auxo].copy(), P['x'][:,auxpo].copy(), O['x'][:,auxpo].copy())) # ------- Second part: non-dominated sorting Pnew = {'x' : np.atleast_2d([])} Pnew['f'] = np.atleast_2d([]) #prepares the new population while True: ispar = ndset(R['f']) #gets the non-dominated front # If the number of points in this front plus the current size of the new # population is smaller than mu, then include everything and keep going. # If it is greater, then stops and go to the truncation step: if ((Pnew['f'].shape[1] + ispar.sum()) < options['mu']): Pnew['f'] = np.hstack((Pnew['f'], R['f'][:,ispar].copy())) if (Pnew['f'].size) else R['f'][:,ispar].copy() Pnew['x'] = np.hstack((Pnew['x'], R['x'][:,ispar].copy())) if (Pnew['x'].size) else R['x'][:,ispar].copy() R['f'] = np.delete(R['f'],np.where(ispar)[0], axis = 1) #R['f'][:,ispar] = []; #removes this front R['x'] = np.delete(R['x'],np.where(ispar)[0], axis = 1) #R['x'][:,ispar] = []; #removes this front else: # Gets the points of this front and goes to the truncation part Frem = R['f'][:,ispar].copy() Xrem = R['x'][:,ispar].copy() break #don't forget this to stop this infinite loop # ------- Third part: truncates using crowding distance # If the remaining front has the exact number of points to fill the original # size, then just include them. If it has too many, remove some according to # the crowding distance (notice it cannot have too few!) aux = (Pnew['f'].shape[1] + Frem.shape[1]) - options['mu'] #remaining points to fill if aux == 0: Pnew['x'] = np.hstack((Pnew['x'], Xrem.copy())) #Xrem.copy() Pnew['f'] = np.hstack((Pnew['f'], Frem.copy())) #Frem.copy() elif aux > 0: for ii in range(aux): cdist = crowdingdistance(Frem) imin = cdist.argmin()#gets the point with smaller crowding distance Frem = np.delete(Frem, imin, axis = 1) # Frem(:,imin) = []; #and remove it Xrem = np.delete(Xrem, imin, axis = 1) # Xrem(:,imin) = []; Pnew['x'] = np.hstack((Pnew['x'], Xrem.copy())) if Pnew['x'].size else Xrem.copy() Pnew['f'] = np.hstack((Pnew['f'], Frem.copy())) if Pnew['f'].size else Frem.copy() else: #if there are too few points... well, we're doomed! raise Exception('Run to the hills! This is not supposed to happen!') return Pnew
def get_degree_of_adaptation(Dtype=None, **kwargs): """ Calculates the degree of adaptation according to some function published in literature. Args: :Dtype: | None, optional | If None: kwargs should contain 'D' with value. | If 'manual: kwargs should contain 'D' with value. | If 'cat02' or 'cat16': kwargs should contain keys 'F' and 'La'. | Calculate D according to CAT02 or CAT16 model: | D = F*(1-(1/3.6)*numpy.exp((-La-42)/92)) | If 'cmc': kwargs should contain 'La', 'La0'(or 'La2') and 'order' | for 'order' = '1>0': 'La' is set La1 and 'La0' to La0. | for 'order' = '0>2': 'La' is set La0 and 'La0' to La1. | for 'order' = '1>2': 'La' is set La1 and 'La2' to La0. | D is calculated as follows: | D = 0.08*numpy.log10(La1+La0)+0.76-0.45*(La1-La0)/(La1+La0) | If 'smet2017': kwargs should contain 'xyzw' and 'Dmax' | (see Smet2017_D for more details). | If "? user defined", then D is calculated by: | D = ndarray(eval(:Dtype:)) Returns: :D: | ndarray with degree of adaptation values. Notes: 1. D passes either right through or D is calculated following some D-function (Dtype) published in literature. 2. D is limited to values between zero and one 3. If kwargs do not contain the required parameters, an exception is raised. """ try: if Dtype is None: PAR = ["D"] D = np.array([kwargs['D']]) elif Dtype == 'manual': PAR = ["D"] D = np.array([kwargs['D']]) elif (Dtype == 'cat02') | (Dtype == 'cat16'): PAR = ["F, La"] F = kwargs['F'] if isinstance(F, str): #CIECAM02 / CAT02 surround based F values if (F == 'avg') | (F == 'average'): F = 1 elif (F == 'dim'): F = 0.9 elif (F == 'dark'): F = 0.8 elif (F == 'disp') | (F == 'display'): F = 0.0 else: F = eval(F) F = np.array([F]) La = np.array([kwargs['La']]) D = F * (1 - (1 / 3.6) * np.exp((-La - 42) / 92)) elif Dtype == 'cmc': PAR = ["La, La0, order"] order = np.array([kwargs['order']]) if order == '1>0': La1 = np.array([kwargs['La']]) La0 = np.array([kwargs['La0']]) elif order == '0>2': La0 = np.array([kwargs['La']]) La1 = np.array([kwargs['La0']]) elif order == '1>2': La1 = np.array([kwargs['La']]) La0 = np.array([kwargs['La2']]) D = 0.08 * np.log10(La1 + La0) + 0.76 - 0.45 * (La1 - La0) / (La1 + La0) elif 'smet2017': PAR = ['xyzw', 'Dmax'] xyzw = np.array([kwargs['xyzw']]) Dmax = np.array([kwargs['Dmax']]) D = smet2017_D(xyzw, Dmax=Dmax) else: PAR = ["? user defined"] D = np.array(eval(Dtype)) D[np.where(D < 0)] = 0 D[np.where(D > 1)] = 1 except: raise Exception( 'degree_of_adaptation_D(): **kwargs does not contain the necessary parameters ({}) for Dtype = {}' .format(PAR, Dtype)) return D
def apply(data, n_step = 2, catmode = None, cattype = 'vonkries', xyzw1 = None, xyzw2 = None, xyzw0 = None,\ D = None, mcat = [_MCAT_DEFAULT], normxyz0 = None, outtype = 'xyz', La = None, F = None, Dtype = None): """ Calculate corresponding colors by applying a von Kries chromatic adaptation transform (CAT), i.e. independent rescaling of 'sensor sensitivity' to data to adapt from current adaptation conditions (1) to the new conditions (2). Args: :data: | ndarray of tristimulus values (can be NxMx3) :n_step: | 2, optional | Number of step in CAT (1: 1-step, 2: 2-step) :catmode: | None, optional | - None: use :n_step: to set mode: 1 = '1>2', 2:'1>0>2' | -'1>0>2': Two-step CAT | from illuminant 1 to baseline illuminant 0 to illuminant 2. | -'1>2': One-step CAT | from illuminant 1 to illuminant 2. | -'1>0': One-step CAT | from illuminant 1 to baseline illuminant 0. | -'0>2': One-step CAT | from baseline illuminant 0 to illuminant 2. :cattype: | 'vonkries' (others: 'rlab', see Farchild 1990), optional :xyzw1: | None, depending on :catmode: optional (can be Mx3) :xyzw2: | None, depending on :catmode: optional (can be Mx3) :xyzw0: | None, depending on :catmode: optional (can be Mx3) :D: | None, optional | Degrees of adaptation. Defaults to [1.0, 1.0]. :La: | None, optional | Adapting luminances. | If None: xyz values are absolute or relative. | If not None: xyz are relative. :F: | None, optional | Surround parameter(s) for CAT02/CAT16 calculations | (:Dtype: == 'cat02' or 'cat16') | Defaults to [1.0, 1.0]. :Dtype: | None, optional | Type of degree of adaptation function from literature | See luxpy.cat.get_degree_of_adaptation() :mcat: | [_MCAT_DEFAULT], optional | List[str] or List[ndarray] of sensor space matrices for each | condition pair. If len(:mcat:) == 1, the same matrix is used. :normxyz0: | None, optional | Set of xyz tristimulus values to normalize the sensor space matrix to. :outtype: | 'xyz' or 'lms', optional | - 'xyz': return corresponding tristimulus values | - 'lms': return corresponding sensor space excitation values | (e.g. for further calculations) Returns: :returns: | ndarray with corresponding colors Reference: 1. `Smet, K. A. G., & Ma, S. (2020). Some concerns regarding the CAT16 chromatic adaptation transform. Color Research & Application, 45(1), 172–177. <https://doi.org/10.1002/col.22457>`_ """ if (xyzw1 is None) & (xyzw2 is None): return data # do nothing else: # Set catmode: if catmode is None: if n_step == 2: catmode = '1>0>2' elif n_step == 1: catmode = '1>2' else: raise Exception( 'cat.apply(n_step = {:1.0f}, catmode = None): Unknown requested n-step CAT mode !' .format(n_step)) # Make data 2d: data = np2d(data) data_original_shape = data.shape if data.ndim < 3: target_shape = np.hstack((1, data.shape)) data = data * np.ones(target_shape) else: target_shape = data.shape target_shape = data.shape # initialize xyzw0: if (xyzw0 is None): # set to iLL.E xyzw0 = np2d([100.0, 100.0, 100.0]) xyzw0 = np.ones(target_shape) * xyzw0 La0 = xyzw0[..., 1, None] # Determine cat-type (1-step or 2-step) + make input same shape as data for block calculations: expansion_axis = np.abs(1 * (len(data_original_shape) == 2) - 1) if ((xyzw1 is not None) & (xyzw2 is not None)): xyzw1 = xyzw1 * np.ones(target_shape) xyzw2 = xyzw2 * np.ones(target_shape) default_La12 = [xyzw1[..., 1, None], xyzw2[..., 1, None]] elif (xyzw2 is None) & (xyzw1 is not None): # apply one-step CAT: 1-->0 catmode = '1>0' #override catmode input xyzw1 = xyzw1 * np.ones(target_shape) default_La12 = [xyzw1[..., 1, None], La0] elif (xyzw1 is None) & (xyzw2 is not None): raise Exception( "von_kries(): cat transformation '0>2' not supported, use '1>0' !" ) # Get or set La (La == None: xyz are absolute or relative, La != None: xyz are relative): target_shape_1 = tuple(np.hstack((target_shape[:-1], 1))) La1, La2 = parse_x1x2_parameters(La, target_shape=target_shape_1, catmode=catmode, expand_2d_to_3d=expansion_axis, default=default_La12) # Set degrees of adaptation, D10, D20: (note D20 is degree of adaptation for 2-->0!!) D10, D20 = parse_x1x2_parameters(D, target_shape=target_shape_1, catmode=catmode, expand_2d_to_3d=expansion_axis) # Set F surround in case of Dtype == 'cat02': F1, F2 = parse_x1x2_parameters(F, target_shape=target_shape_1, catmode=catmode, expand_2d_to_3d=expansion_axis) # Make xyz relative to go to relative xyz0: if La is None: data = 100 * data / La1 xyzw1 = 100 * xyzw1 / La1 xyzw0 = 100 * xyzw0 / La0 if (catmode == '1>0>2') | (catmode == '1>2'): xyzw2 = 100 * xyzw2 / La2 # transform data (xyz) to sensor space (lms) and perform cat: xyzc = np.zeros(data.shape) xyzc.fill(np.nan) mcat = np.array(mcat) if (mcat.shape[0] != data.shape[1]) & (mcat.shape[0] == 1): mcat = np.repeat(mcat, data.shape[1], axis=0) elif (mcat.shape[0] != data.shape[1]) & (mcat.shape[0] > 1): raise Exception( 'von_kries(): mcat.shape[0] > 1 and does not match data.shape[0]!' ) for i in range(xyzc.shape[1]): # get cat sensor matrix: if mcat[i].dtype == np.float64: mcati = mcat[i] else: mcati = _MCATS[mcat[i]] # normalize sensor matrix: if normxyz0 is not None: mcati = math.normalize_3x3_matrix(mcati, xyz0=normxyz0) # convert from xyz to lms: lms = np.dot(mcati, data[:, i].T).T lmsw0 = np.dot(mcati, xyzw0[:, i].T).T if (catmode == '1>0>2') | (catmode == '1>0'): lmsw1 = np.dot(mcati, xyzw1[:, i].T).T Dpar1 = dict(D=D10[:, i], F=F1[:, i], La=La1[:, i], La0=La0[:, i], order='1>0') D10[:, i] = get_degree_of_adaptation( Dtype=Dtype, **Dpar1) #get degree of adaptation depending on Dtype lmsw2 = None # in case of '1>0' if (catmode == '1>0>2'): lmsw2 = np.dot(mcati, xyzw2[:, i].T).T Dpar2 = dict(D=D20[:, i], F=F2[:, i], La=La2[:, i], La0=La0[:, i], order='0>2') D20[:, i] = get_degree_of_adaptation( Dtype=Dtype, **Dpar2) #get degree of adaptation depending on Dtype if (catmode == '1>2'): lmsw1 = np.dot(mcati, xyzw1[:, i].T).T lmsw2 = np.dot(mcati, xyzw2[:, i].T).T Dpar12 = dict(D=D10[:, i], F=F1[:, i], La=La1[:, i], La2=La2[:, i], order='1>2') D10[:, i] = get_degree_of_adaptation( Dtype=Dtype, **Dpar12) #get degree of adaptation depending on Dtype # Determine transfer function Dt: Dt = get_transfer_function(cattype=cattype, catmode=catmode, lmsw1=lmsw1, lmsw2=lmsw2, lmsw0=lmsw0, D10=D10[:, i], D20=D20[:, i], La1=La1[:, i], La2=La2[:, i]) # Perform cat: lms = np.dot(np.diagflat(Dt[0]), lms.T).T # Make xyz, lms 'absolute' again: if (catmode == '1>0>2'): lms = (La2[:, i] / La1[:, i]) * lms elif (catmode == '1>0'): lms = (La0[:, i] / La1[:, i]) * lms elif (catmode == '1>2'): lms = (La2[:, i] / La1[:, i]) * lms # transform back from sensor space to xyz (or not): if outtype == 'xyz': xyzci = np.dot(np.linalg.inv(mcati), lms.T).T xyzci[np.where(xyzci < 0)] = _EPS xyzc[:, i] = xyzci else: xyzc[:, i] = lms # return data to original shape: if len(data_original_shape) == 2: xyzc = xyzc[0] return xyzc
def Ydlep_to_xyz(Ydlep, cieobs=_CIEOBS, xyzw=_COLORTF_DEFAULT_WHITE_POINT, flip_axes=False, SL_max_lambda=None, **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!) :SL_max_lambda: | None or float, optional | Maximum wavelength of spectrum locus before it turns back on itelf in the high wavelength range (~700 nm) 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'] SL = SL[:, SL[1:].sum(axis=0) > 0] # avoid div by zero in xyz-to-Yxy conversion wlsl = SL[0, None].T Yxysl = xyz_to_Yxy(SL[1:4].T)[:, None] # Get maximum wavelength of spectrum locus (before it turns back on itself) if SL_max_lambda is None: pmaxlambda = Yxysl[..., 1].argmax() # lambda with largest x value dwl = np.diff( Yxysl[:, 0, 1]) # spectrumlocus in that range should have increasing x dwl[wlsl[:-1, 0] < 600] = 10000 pmaxlambda = np.where( dwl <= 0)[0][0] # Take first element with zero or <zero slope else: pmaxlambda = np.abs(wlsl - SL_max_lambda).argmin() Yxysl = Yxysl[:(pmaxlambda + 1), :] wlsl = wlsl[:(pmaxlambda + 1), :1] # 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 = wlslb - wlib q1 = np.abs(dwl).argmin(axis=0) # index of closest wl sign_q1 = np.sign(dwl[q1]) dwl[np.sign(dwl) == sign_q1] = 1000000 # set all dwl on the same side as q1 to a very large value q2 = np.abs(dwl).argmin( axis=0) # index of second closest (enclosing) 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)
def symmM_to_posdefM(A = None, atol = 1.0e-9, rtol = 1.0e-9, method = 'make', forcesymm = True): """ Convert a symmetric matrix to a positive definite one. Args: :A: | ndarray :atol: | float, optional | The absolute tolerance parameter (see Notes of numpy.allclose()) :rtol: | float, optional | The relative tolerance parameter (see Notes of numpy.allclose()) :method: | 'make' or 'nearest', optional (see notes for more info) :forcesymm: | True or False, optional | If A is not symmetric, force symmetry using: | A = numpy.triu(A) + numpy.triu(A).T - numpy.diag(numpy.diag(A)) Returns: :returns: | ndarray with positive-definite matrix. Notes on supported methods: 1. `'make': A Python/Numpy port of Muhammad Asim Mubeen's matlab function Spd_Mat.m <https://nl.mathworks.com/matlabcentral/fileexchange/45873-positive-definite-matrix>`_ 2. `'nearest': A Python/Numpy port of John D'Errico's `nearestSPD` MATLAB code. <https://stackoverflow.com/questions/43238173/python-convert-matrix-to-positive-semi-definite>`_ """ if A is not None: A = np2d(A) # Make sure matrix A is symmetric up to a certain tolerance: sn = check_symmetric(A, atol = atol, rtol = rtol) if ((A.shape[0] != A.shape[1]) | (sn != True)): if (forcesymm == True) & (A.shape[0] == A.shape[1]): A = np.triu(A) + np.triu(A).T - np.diag(np.diag(A)) else: raise Exception('symmM_to_posdefM(): matrix A not symmetric.') if check_posdef(A, atol = atol, rtol = rtol) == True: return A else: if method == 'make': # A Python/Numpy port of Muhammad Asim Mubeen's matlab function Spd_Mat.m # # See: https://nl.mathworks.com/matlabcentral/fileexchange/45873-positive-definite-matrix Val, Vec = np.linalg.eig(A) Val = np.real(Val) Vec = np.real(Vec) Val[np.where(Val==0)] = _EPS #making zero eigenvalues non-zero p = np.where(Val<0) Val[p] = -Val[p] #making negative eigenvalues positive return np.dot(Vec,np.dot(np.diag(Val) , Vec.T)) elif method == 'nearest': # A Python/Numpy port of John D'Errico's `nearestSPD` MATLAB code [1], which # credits [2]. # # [1] https://www.mathworks.com/matlabcentral/fileexchange/42885-nearestspd # # [2] N.J. Higham, "Computing a nearest symmetric positive semidefinite # matrix" (1988): https://doi.org/10.1016/0024-3795(88)90223-6 # # See: https://stackoverflow.com/questions/43238173/python-convert-matrix-to-positive-semi-definite B = (A + A.T) / 2.0 _, s, V = np.linalg.svd(B) H = np.dot(V.T, np.dot(np.diag(s), V)) A2 = (B + H) / 2.0 A3 = (A2 + A2.T) / 2.0 if check_posdef(A3, atol = atol, rtol = rtol) == True: return A3 spacing = np.spacing(np.linalg.norm(A)) I = np.eye(A.shape[0]) k = 1 while not check_posdef(A3, atol = atol, rtol = rtol): mineig = np.min(np.real(np.linalg.eigvals(A3))) A3 += I * (-mineig * k**2.0+ spacing) k += 1 return A3
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 calibrate(rgbcal, xyzcal, L_type = 'lms', tr_type = 'lut', cieobs = '1931_2', nbit = 8, cspace = 'lab', avg = lambda x: ((x**2).mean()**0.5), ensure_increasing_lut_at_low_rgb = 0.2, verbosity = 1, sep=',',header=None): """ Calculate TR parameters/lut and conversion matrices. Args: :rgbcal: | ndarray [Nx3] or string with filename of RGB values | rgcal must contain at least the following type of settings: | - pure R,G,B: e.g. for pure R: (R != 0) & (G==0) & (B == 0) | - white(s): R = G = B = 2**nbit-1 | - gray(s): R = G = B | - black(s): R = G = B = 0 | - binary colors: cyan (G = B, R = 0), yellow (G = R, B = 0), magenta (R = B, G = 0) :xyzcal: | ndarray [Nx3] or string with filename of measured XYZ values for | the RGB settings in rgbcal. :L_type: | 'lms', optional | Type of response to use in the derivation of the Tone-Response curves. | options: | - 'lms': use cone fundamental responses: L vs R, M vs G and S vs B | (reduces noise and generally leads to more accurate characterization) | - 'Y': use the luminance signal: Y vs R, Y vs G, Y vs B :tr_type: | 'lut', optional | options: | - 'lut': Derive/specify Tone-Response as a look-up-table | - 'gog': Derive/specify Tone-Response as a gain-offset-gamma function :cieobs: | '1931_2', optional | CIE CMF set used to determine the XYZ tristimulus values | (needed when L_type == 'lms': determines the conversion matrix to | convert xyz to lms values) :nbit: | 8, optional | RGB values in nbit format (e.g. 8, 16, ...) :cspace: | color space or chromaticity diagram to calculate color differences in | when optimizing the xyz_to_rgb and rgb_to_xyz conversion matrices. :avg: | lambda x: ((x**2).mean()**0.5), optional | Function used to average the color differences of the individual RGB settings | in the optimization of the xyz_to_rgb and rgb_to_xyz conversion matrices. :ensure_increasing_lut_at_low_rgb: | 0.2 or float (max = 1.0) or None, optional | Ensure an increasing lut by setting all values below the RGB with the maximum | zero-crossing of np.diff(lut) and RGB/RGB.max() values of :ensure_increasing_lut_at_low_rgb: | (values of 0.2 are a good rule of thumb value) | Non-strictly increasing lut values can be caused at low RGB values due | to noise and low measurement signal. | If None: don't force lut, but keep as is. :verbosity: | 1, optional | > 0: print and plot optimization results :sep: | ',', optional | separator in files with rgbcal and xyzcal data :header: | None, optional | header specifier for files with rgbcal and xyzcal data | (see pandas.read_csv) Returns: :M: | linear rgb to xyz conversion matrix :N: | xyz to linear rgb conversion matrix :tr: | Tone Response function parameters or lut :xyz_black: | ndarray with XYZ tristimulus values of black :xyz_white: | ndarray with tristimlus values of white """ # process rgb, xyzcal inputs: rgbcal, xyzcal = _parse_rgbxyz_input(rgbcal, xyz = xyzcal, sep = sep, header=header) # get black-positions and average black xyz (flare): p_blacks = (rgbcal[:,0]==0) & (rgbcal[:,1]==0) & (rgbcal[:,2]==0) xyz_black = xyzcal[p_blacks,:].mean(axis=0,keepdims=True) # Calculate flare corrected xyz: xyz_fc = xyzcal - xyz_black # get positions of pure r, g, b values: p_pure = [(rgbcal[:,1]==0) & (rgbcal[:,2]==0), (rgbcal[:,0]==0) & (rgbcal[:,2]==0), (rgbcal[:,0]==0) & (rgbcal[:,1]==0)] # set type of L-response to use: Y for R,G,B or L,M,S for R,G,B: if L_type == 'Y': L = np.array([xyz_fc[:,1] for i in range(3)]).T elif L_type == 'lms': lms = (math.normalize_3x3_matrix(_CMF[cieobs]['M'].copy()) @ xyz_fc.T).T L = np.array([lms[:,i] for i in range(3)]).T # Get rgb linearizer parameters or lut and apply to all rgb's: if tr_type == 'gog': par = np.array([sp.optimize.curve_fit(TR, rgbcal[p_pure[i],i], L[p_pure[i],i]/L[p_pure[i],i].max(), p0=[1,0,1])[0] for i in range(3)]) # calculate parameters of each TR tr = par elif tr_type == 'lut': dac = np.arange(2**nbit) # lut = np.array([cie_interp(np.vstack((rgbcal[p_pure[i],i],L[p_pure[i],i]/L[p_pure[i],i].max())), dac, kind ='cubic')[1,:] for i in range(3)]).T lut = np.array([sp.interpolate.PchipInterpolator(rgbcal[p_pure[i],i],L[p_pure[i],i]/L[p_pure[i],i].max())(dac) for i in range(3)]).T # use this one to avoid potential overshoot with cubic spline interpolation (but slightly worse performance) lut[lut<0] = 0 # ensure monotonically increasing lut values for low signal: if ensure_increasing_lut_at_low_rgb is not None: #ensure_increasing_lut_at_low_rgb = 0.2 # anything below that has a zero-crossing for diff(lut) will be set to zero for i in range(3): p0 = np.where((np.diff(lut[dac/dac.max() < ensure_increasing_lut_at_low_rgb,i])<=0))[0] if p0.any(): p0 = range(0,p0[-1]) lut[p0,i] = 0 tr = lut # plot: if verbosity > 0: colors = 'rgb' linestyles = ['-','--',':'] rgball = np.repeat(np.arange(2**8)[:,None],3,axis=1) Lall = _rgb_linearizer(rgball, tr, tr_type = tr_type) plt.figure() for i in range(3): plt.plot(rgbcal[p_pure[i],i],L[p_pure[i],i]/L[p_pure[i],i].max(),colors[i]+'o') plt.plot(rgball[:,i],Lall[:,i],colors[i]+linestyles[i],label=colors[i]) plt.xlabel('Display RGB') plt.ylabel('Linear RGB') plt.legend() plt.title('Tone response curves') # linearize all rgb values and clamp to 0 rgblin = _rgb_linearizer(rgbcal, tr, tr_type = tr_type) # get rgblin to xyz_fc matrix: M = np.linalg.lstsq(rgblin, xyz_fc, rcond=None)[0].T # get xyz_fc to rgblin matrix: N = np.linalg.inv(M) # get better approximation for conversion matrices: p_grays = (rgbcal[:,0] == rgbcal[:,1]) & (rgbcal[:,0] == rgbcal[:,2]) p_whites = (rgbcal[:,0] == (2**nbit-1)) & (rgbcal[:,1] == (2**nbit-1)) & (rgbcal[:,2] == (2**nbit-1)) xyz_white = xyzcal[p_whites,:].mean(axis=0,keepdims=True) # get xyzw for input into xyz_to_lab() or colortf() def optfcn(x, rgbcal, xyzcal, tr, xyz_black, cspace, p_grays, p_whites,out,verbosity): M = x.reshape((3,3)) xyzest = rgb_to_xyz(rgbcal, M, tr, xyz_black, tr_type) xyzw = xyzcal[p_whites,:].mean(axis=0) # get xyzw for input into xyz_to_lab() or colortf() labcal, labest = colortf(xyzcal,tf=cspace,xyzw=xyzw), colortf(xyzest,tf=cspace,xyzw=xyzw) # calculate lab coord. of cal. and est. DEs = ((labcal-labest)**2).sum(axis=1)**0.5 DEg = DEs[p_grays] DEw = DEs[p_whites] F = (avg(DEs)**2 + avg(DEg)**2 + avg(DEw**2))**0.5 if verbosity > 1: print('\nPerformance of TR + rgb-to-xyz conversion matrix M:') print('all: DE(jab): avg = {:1.4f}, std = {:1.4f}'.format(avg(DEs),np.std(DEs))) print('grays: DE(jab): avg = {:1.4f}, std = {:1.4f}'.format(avg(DEg),np.std(DEg))) print('whites(s) DE(jab): avg = {:1.4f}, std = {:1.4f}'.format(avg(DEw),np.std(DEw))) if out == 'F': return F else: return eval(out) x0 = M.ravel() res = math.minimizebnd(optfcn, x0, args =(rgbcal, xyzcal, tr, xyz_black, cspace, p_grays, p_whites,'F',0), use_bnd=False) xf = res['x_final'] M = optfcn(xf, rgbcal, xyzcal, tr, xyz_black, cspace, p_grays, p_whites,'M',verbosity) N = np.linalg.inv(M) return M, N, tr, xyz_black, xyz_white
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'] SL = SL[:, SL[1:].sum(axis=0) > 0] # avoid div by zero in xyz-to-Yxy conversion wlsl = SL[0] Yxysl = xyz_to_Yxy(SL[1:4].T)[:, None] pmaxlambda = Yxysl[..., 1].argmax() maxlambda = wlsl[pmaxlambda] maxlambda = 700 print(np.where(wlsl == maxlambda)) pmaxlambda = np.where(wlsl == maxlambda)[0][0] Yxysl = Yxysl[:(pmaxlambda + 1), :] wlsl = wlsl[:(pmaxlambda + 1)] # 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') print(h) print('rh', h[0, 0] - h[0, 1]) print(wlsl[0], wlsl[-1]) 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 if hsl_min < hsl_max: hsl_min += 360 dominantwavelength = np.empty(Y.shape) purity = np.empty(Y.shape) print('xyz:', xyz) for i in range(xyz3.shape[1]): print('\ni:', i, h[:, i], hsl_max, hsl_min) print(h) # find index of complementary wavelengths/hues: pc = np.where( (h[:, i] > hsl_max) & (h[:, i] < hsl_min) ) # hue's requiring complementary wavelength (purple line) print('pc', (h[:, i] > hsl_max) & (h[:, i] < hsl_min)) 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] = 1000000.0 q2 = dh.argmin(axis=0) # index of second closest hue print('q1q2', q2, q1) print('wls:', h[:, i], wlsl[q1], wlsl[q2]) print('hsls:', hsl[q2, 0], hsl[q1, 0]) print('d', (wlsl[q2] - wlsl[q1]), (hsl[q2, 0] - hsl[q1, 0]), (wlsl[q2] - wlsl[q1]) / (hsl[q2, 0] - hsl[q1, 0])) print('(h[:,i] - hsl[q1,0])', (h[:, i] - hsl[q1, 0])) print('div', np.divide((wlsl[q2] - wlsl[q1]), (hsl[q2, 0] - hsl[q1, 0]))) print( 'mult(...)', np.multiply((h[:, i] - hsl[q1, 0]), np.divide((wlsl[q2] - wlsl[q1]), (hsl[q2, 0] - hsl[q1, 0])))) dominantwavelength[:, i] = wlsl[q1] + np.multiply( (h[:, i] - hsl[q1, 0]), np.divide((wlsl[q2] - wlsl[q1]), (hsl[q2, 0] - hsl[q1, 0])) ) # calculate wl corresponding to h: y = y1 + (x-x1)*(y2-y1)/(x2-x1) print('dom', dominantwavelength[:, i]) dominantwavelength[(dominantwavelength[:, i] > max(wlsl[q1], wlsl[q2])), i] = max(wlsl[q1], wlsl[q2]) dominantwavelength[(dominantwavelength[:, i] < min(wlsl[q1], wlsl[q2])), i] = min(wlsl[q1], wlsl[q2]) 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 get_pixel_coordinates(jab, jab_ranges=None, jab_deltas=None, limit_grid_radius=0): """ Get pixel coordinates corresponding to array of jab color coordinates. Args: :jab: | ndarray of color coordinates :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) :jab_deltas: | float or ndarray, optional | Specifies the sampling range. | A float uses jab_deltas as the maximum Euclidean distance to select | samples around each pixel center. A ndarray of 3 deltas, uses | a city block sampling around each pixel center. :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: | gridp, idxp, jabp, samplenrs, samplesIDs | - :gridp: ndarray with coordinates of all pixel centers. | - :idxp: list[int] with pixel index for each non-empty pixel | - :jabp: ndarray with center color coordinates of non-empty pixels | - :samplenrs: list[list[int]] with sample numbers belong to each | non-empty pixel | - :sampleIDs: summarizing list, | with column order: 'idxp, jabp, samplenrs' """ if jab_deltas is None: jab_deltas = np.array([_VF_DELTAR, _VF_DELTAR, _VF_DELTAR]) if jab_ranges is None: jab_ranges = np.vstack( ([0, 100, jab_deltas[0] ], [-_VF_MAXR, _VF_MAXR + jab_deltas[1], jab_deltas[1]], [-_VF_MAXR, _VF_MAXR + jab_deltas[2], jab_deltas[2]])) # Get pixel grid: gridp = generate_grid(jab_ranges=jab_ranges, limit_grid_radius=limit_grid_radius) # determine pixel coordinates of each sample in jab: samplesIDs = [] for idx in range(gridp.shape[0]): # get pixel coordinates: jp = gridp[idx, 0] ap = gridp[idx, 1] bp = gridp[idx, 2] #Cp = np.sqrt(ap**2+bp**2) if type(jab_deltas) == np.ndarray: sampleID = np.where( ((np.abs(jab[..., 0] - jp) <= jab_deltas[0] / 2) & (np.abs(jab[..., 1] - ap) <= jab_deltas[1] / 2) & (np.abs(jab[..., 2] - bp) <= jab_deltas[2] / 2))) else: sampleID = np.where( (np.sqrt((jab[..., 0] - jp)**2 + (jab[..., 1] - ap)**2 + (jab[..., 2] - bp)**2) <= jab_deltas / 2)) if (sampleID[0].shape[0] > 0): samplesIDs.append( np.hstack((idx, np.array([jp, ap, bp]), sampleID[0]))) idxp = [np.int(samplesIDs[i][0]) for i in range(len(samplesIDs))] jabp = np.vstack([samplesIDs[i][1:4] for i in range(len(samplesIDs))]) samplenrs = [ np.array(samplesIDs[i][4:], dtype=int).tolist() for i in range(len(samplesIDs)) ] return gridp, idxp, jabp, samplenrs, samplesIDs
def xyz_to_Ydlep(xyz, cieobs=_CIEOBS, xyzw=_COLORTF_DEFAULT_WHITE_POINT, flip_axes=False, SL_max_lambda=None, **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!) :SL_max_lambda: | None or float, optional | Maximum wavelength of spectrum locus before it turns back on itelf in the high wavelength range (~700 nm) 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'] SL = SL[:, SL[1:].sum(axis=0) > 0] # avoid div by zero in xyz-to-Yxy conversion wlsl = SL[0] Yxysl = xyz_to_Yxy(SL[1:4].T)[:, None] # Get maximum wavelength of spectrum locus (before it turns back on itself) if SL_max_lambda is None: pmaxlambda = Yxysl[..., 1].argmax() # lambda with largest x value dwl = np.diff( Yxysl[:, 0, 1]) # spectrumlocus in that range should have increasing x dwl[wlsl[:-1] < 600] = 10000 pmaxlambda = np.where( dwl <= 0)[0][0] # Take first element with zero or <zero slope else: pmaxlambda = np.abs(wlsl - SL_max_lambda).argmin() Yxysl = Yxysl[:(pmaxlambda + 1), :] wlsl = wlsl[:(pmaxlambda + 1)] # 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 if hsl_min < hsl_max: hsl_min += 360 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) ) # 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 enclosing hues in sl: #hslb,hib = meshblock(hsl,h[:,i:i+1]) hib, hslb = np.meshgrid(h[:, i:i + 1], hsl) dh = (hslb - hib) q1 = np.abs(dh).argmin(axis=0) # index of closest hue sign_q1 = np.sign(dh[q1])[0] dh[np.sign(dh) == sign_q1] = 1000000 # set all dh on the same side as q1 to a very large value q2 = np.abs(dh).argmin( axis=0) # index of second closest (enclosing) hue # # Test changes to code: # print('wls',i, wlsl[q1],wlsl[q2]) # import matplotlib.pyplot as plt # plt.figure() # plt.plot(wlsl[:-1],np.diff(xsl[:,0]),'k.-') # plt.figure() # plt.plot(x[0,i],y[0,i],'k.'); plt.plot(xsl,ysl,'r.-');plt.plot(xsl[q1],ysl[q1],'b.');plt.plot(xsl[q2],ysl[q2],'g.');plt.plot(xsl[-1],ysl[-1],'c+') dominantwavelength[:, i] = wlsl[q1] + np.multiply( (h[:, i] - hsl[q1, 0]), np.divide((wlsl[q2] - wlsl[q1]), (hsl[q2, 0] - hsl[q1, 0])) ) # calculate wl corresponding to h: y = y1 + (x-x1)*(y2-y1)/(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)