def run(data, xyzw=None, outin='J,aM,bM', cieobs=_CIEOBS, conditions=None, forward=True, mcat='cat16', **kwargs): """ Run the Jz,az,bz based 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 | None defaults to D65 :cieobs: | _CIEOBS, optional | CMF set to use when calculating :xyzw: if this is None. :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] | String with inputs in data. | Input must have data.shape[-1]==3 and last dim of data must have | the following structure: | * data[...,0] = J or Q, | * data[...,1:] = (aM,bM) or (aC,bC) or (aS,bS) :mcat: | 'cat16', optional | Specifies CAT sensor space. | - options: | - None defaults to 'cat16' | - 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. `Safdar, M., Cui, G., Kim,Y. J., and Luo, M. R.(2017). Perceptually uniform color space for image signals including high dynamic range and wide gamut. Opt. Express, vol. 25, no. 13, pp. 15131–15151, Jun. 2017. <https://www.opticsexpress.org/abstract.cfm?URI=oe-25-13-15131>`_ 2. `Safdar, M., Hardeberg, J., Cui, G., Kim, Y. J., and Luo, M. R.(2018). A Colour Appearance Model based on Jzazbz Colour Space, 26th Color and Imaging Conference (2018), Vancouver, Canada, November 12-16, 2018, pp96-101. <https://doi.org/10.2352/ISSN.2169-2629.2018.26.96>`_ """ 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 cone/chromatic adaptation sensor space: if (mcat is None) | (mcat == 'cat16'): mcat = cat._MCATS['cat16'] elif isinstance(mcat, str): mcat = cat._MCATS[mcat] invmcat = np.linalg.inv(mcat) #-------------------------------------------- # Get white point of D65 fro chromatic adaptation transform (CAT) xyzw_d65 = np.array([[ 9.5047e+01, 1.0000e+02, 1.0888e+02 ]]) if cieobs == '1931_2' else spd_to_xyz(_CIE_D65, cieobs=cieobs) #-------------------------------------------- # Get default white point: if xyzw is None: xyzw = xyzw_d65.copy() #-------------------------------------------- # calculate condition dependent parameters: Yw = xyzw[..., 1].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 z = 1.48 + FLL * n**0.5 #-------------------------------------------- # 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): #-------------------------------------------- # Apply CAT to white point: xyzwc = cat.apply_vonkries1(xyzw, xyzw, xyzw_d65, D=D, mcat=mcat, invmcat=invmcat) #-------------------------------------------- # Get Iz,az,bz coordinates: iabzw = xyz_to_jabz(xyzwc, ztype='iabz') #=================================================================== # STIMULUS transformations: #-------------------------------------------- # massage shape of data for broadcasting: original_ndim = data.ndim if data.ndim == 2: data = data[:, None] if forward: # Apply CAT to D65: xyzc = cat.apply_vonkries1(data, xyzw, xyzw_d65, D=D, mcat=mcat, invmcat=invmcat) # Get Iz,az,bz coordinates: iabz = xyz_to_jabz(xyzc, ztype='iabz') #-------------------------------------------- # calculate hue h and eccentricity factor, et: h = hue_angle(iabz[..., 1], iabz[..., 2], htype='deg') et = 1.01 + np.cos(h * np.pi / 180 + 1.55) #-------------------------------------------- # 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: if ('J' in outin) | ('Q' in outin) | ('C' in outin) | ( 'M' in outin) | ('s' in outin) | ('aS' in outin) | ( 'aC' in outin) | ('aM' in outin): J = 100.0 * (iabz[..., 0] / iabzw[..., 0])**(c * z) #-------------------------------------------- # calculate brightness, Q: if ('Q' in outin) | ('s' in outin) | ('aS' in outin): Q = 192.5 * (J / c) * (FL**0.64) #-------------------------------------------- # calculate chroma, C: if ('C' in outin) | ('M' in outin) | ('s' in outin) | ( 'aS' in outin) | ('aC' in outin) | ('aM' in outin): C = ((1 / n)**0.074) * ( (iabz[..., 1]**2.0 + iabz[..., 2]**2.0)**0.37) * (et**0.067) #-------------------------------------------- # calculate colorfulness, M: if ('M' in outin) | ('s' in outin) | ('aM' in outin) | ('aS' in outin): M = 1.42 * C * FL**0.25 #-------------------------------------------- # calculate saturation, s: if ('s' in outin) | ('aS' in outin): s = 100.0 * (M / Q)**0.5 #-------------------------------------------- # 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 = c * (Q / (192.25 * FL**0.64)) else: raise Exception( 'No lightness or brightness values in data[...,0]. 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 ('aS' in outin): Q = 192.5 * (J / c) * (FL**0.64) M = Q * (MCs / 100.0)**2.0 C = M / (1.42 * FL**0.25) if ('aM' in outin): # convert M to C: C = MCs / (1.42 * FL**0.25) if ('aC' in outin): C = MCs #-------------------------------------------- # calculate achromatic signal, Iz: Iz = iabzw[..., 0] * (J / 100.0)**(1.0 / (c * z)) #-------------------------------------------- # calculate eccentricity factor, et: et = 1.01 + np.cos(h * np.pi / 180 + 1.55) #-------------------------------------------- # calculate t (=a**2+b**2) from C: t = (n**0.074 * C * (1 / et)**0.067)**(1 / 0.37) #-------------------------------------------- # Calculate az, bz: az = (t / (1 + np.tan(h * np.pi / 180)**2))**0.5 bz = az * np.tan(h * np.pi / 180) #-------------------------------------------- # join values and convert to xyz: xyzc = jabz_to_xyz(ajoin((Iz, az, bz)), ztype='iabz') #------------------------------------------- # Apply CAT from D65: xyz = cat.apply_vonkries1(xyzc, xyzw_d65, xyzw, D=D, mcat=mcat, invmcat=invmcat) return xyz
def run(data, xyzw, out = 'J,aM,bM', conditions = None, forward = True): """ 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 :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()? :forward: | True, optional | If True: run in CAM in forward mode, else: inverse mode. :out: | 'J,aM,bM', optional | String with requested output (e.g. "J,aM,bM,M,h") [Forward mode] | String with inputs in data. | Input must have data.shape[-1]==3 and last dim of data must have | the following structure: | * data[...,0] = J or Q, | * data[...,1:] = (aM,bM) or (aC,bC) or (aS,bS) Returns: :camout: | ndarray with Jab coordinates or whatever correlates requested in out. Note: * This is a simplified, less flexible, but faster version than the main ciecam02(). 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 = out.split(',') if isinstance(out,str) else out #-------------------------------------------- # 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) if not forward: mcat_x_invmhpe = np.dot(mcat,np.linalg.inv(mhpe)) #-------------------------------------------- # 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)) #=================================================================== # WHITE POINT transformations (common to forward and inverse modes): #-------------------------------------------- # transform from xyzw to cat sensor space: rgbw = mcat @ xyzw.T #-------------------------------------------- # apply von Kries cat: rgbwc = ((D*Yw/rgbw) + (1 - D))*rgbw # factor 100 from ciecam02 is replaced with Yw[i] in cam16, but see 'note' in Fairchild's "Color Appearance Models" (p291 ni 3ed.) #-------------------------------------------- # convert from cat02 sensor space to cone sensors (hpe): rgbwp = (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) rgbwpa = NK(FL*rgbwp/100.0, True) pw = np.where(rgbwp<0) 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: if data.ndim == 2: data = data[:,None] #=================================================================== # STIMULUS transformations if forward: #-------------------------------------------- # transform from xyz to cat sensor space: rgb = math.dot23(mcat, data.T) #-------------------------------------------- # apply von Kries cat: rgbc = ((D*Yw/rgbw)[...,None] + (1 - D))*rgb # factor 100 from ciecam02 is replaced with Yw[i] in cam16, 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: rgbpa = NK(FL*rgbp/100.0, forward) p = np.where(rgbp<0) 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 = 'ciecam02') else: H = None #-------------------------------------------- # calculate lightness, J: if ('J' in outin) | ('Q' in outin) | ('C' in outin) | ('M' in outin) | ('s' in outin) | ('aS' in outin) | ('aC' in outin) | ('aM' in outin): J = 100.0* (A / Aw)**(c*z) #-------------------------------------------- # calculate brightness, Q: if ('Q' in outin) | ('s' in outin) | ('aS' in outin): Q = (4.0/c)* ((J/100.0)**0.5) * (Aw + 4.0)*(FL**0.25) #-------------------------------------------- # calculate chroma, C: if ('C' in outin) | ('M' in outin) | ('s' in outin) | ('aS' in outin) | ('aC' in outin) | ('aM' in outin): 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: if ('M' in outin) | ('s' in outin) | ('aM' in outin) | ('aS' in outin): M = C*FL**0.25 #-------------------------------------------- # calculate saturation, s: if ('s' in outin) | ('aS' in outin): s = 100.0* (M/Q)**0.5 #-------------------------------------------- # 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: camout = camout[:,0,:] return camout elif forward == False: #-------------------------------------------- # Get Lightness J from data: if ('J' in outin): J = data[...,0].copy() elif ('Q' in outin): 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!') #-------------------------------------------- # 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 if ('aS' in outin): 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 ('aM' in outin): # convert M to C: C = MCs/(FL**0.25) if ('aC' in outin): 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) #-------------------------------------------- # 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).T return xyz
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 run(data, xyzw=None, outin='J,aM,bM', cieobs=_CIEOBS, conditions=None, forward=True, mcat='cat02', **kwargs): """ Run the Jz,az,bz based 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 | None defaults to D65 :cieobs: | _CIEOBS, optional | CMF set to use when calculating :xyzw: if this is None. :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, | 'Wz': whiteness, 'Kz':blackness, 'Sz': saturation, 'V': vividness | 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), ... :mcat: | 'cat02', optional | Specifies CAT sensor space. | - options: | - None defaults to 'cat02' | - 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. `Safdar, M., Cui, G., Kim,Y. J., and Luo, M. R.(2017). Perceptually uniform color space for image signals including high dynamic range and wide gamut. Opt. Express, vol. 25, no. 13, pp. 15131–15151, Jun. 2017. <https://www.opticsexpress.org/abstract.cfm?URI=oe-25-13-15131>`_ 2. `Safdar, M., Hardeberg, J., Cui, G., Kim, Y. J., and Luo, M. R.(2018). A Colour Appearance Model based on Jzazbz Colour Space, 26th Color and Imaging Conference (2018), Vancouver, Canada, November 12-16, 2018, pp96-101. <https://doi.org/10.2352/ISSN.2169-2629.2018.26.96>`_ 3. Safdar, M., Hardeberg, J.Y., Luo, M.R. (2021) "ZCAM, a psychophysical model for colour appearance prediction", Optics Express. """ print( "WARNING: Z-CAM is as yet unpublished and under development, so parameter values might change! (07 Oct, 2020" ) 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 cone/chromatic adaptation sensor space: if (mcat is None): mcat = cat._MCATS['cat02'] elif isinstance(mcat, str): mcat = cat._MCATS[mcat] invmcat = np.linalg.inv(mcat) #-------------------------------------------- # Get white point of D65 fro chromatic adaptation transform (CAT) xyzw_d65 = np.array([[ 9.5047e+01, 1.0000e+02, 1.08883e+02 ]]) if cieobs == '1931_2' else spd_to_xyz(_CIE_D65, cieobs=cieobs) #-------------------------------------------- # Get default white point: if xyzw is None: xyzw = xyzw_d65.copy() #-------------------------------------------- # calculate condition dependent parameters: Yw = xyzw[..., 1].T FL = 0.171 * La**(1 / 3) * (1 - np.exp(-48 / 9 * La) ) # luminance adaptation factor n = Yb / Yw Fb = 1.045 + 1.46 * n**0.5 # background factor Fs = c # surround factor #-------------------------------------------- # 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): #-------------------------------------------- # Apply CAT to white point: xyzwc = cat.apply_vonkries2(xyzw, xyzw1=xyzw, xyzw2=xyzw_d65, D=D, mcat=mcat, invmcat=invmcat, use_Yw=True) #-------------------------------------------- # Get Iz,az,bz coordinates: iabzw = xyz_to_jabz(xyzwc, ztype='iabz', use_zcam_parameters=True) # Get brightness of white point: Qw = 925 * iabzw[..., 0]**1.17 * (FL / Fs)**0.5 #=================================================================== # STIMULUS transformations: #-------------------------------------------- # massage shape of data for broadcasting: original_ndim = data.ndim if data.ndim == 2: data = data[:, None] if forward: # Apply CAT to D65: xyzc = cat.apply_vonkries2(data, xyzw1=xyzw, xyzw2=xyzw_d65, D=D, mcat=mcat, invmcat=invmcat, use_Yw=True) # Get Iz,az,bz coordinates: iabz = xyz_to_jabz(xyzc, ztype='iabz', use_zcam_parameters=True) #-------------------------------------------- # calculate hue h and eccentricity factor, et: h = hue_angle(iabz[..., 1], iabz[..., 2], htype='deg') ez = 1.014 + np.cos((h + 89.038) * np.pi / 180) # ez = 1.014 + np.cos((h*np.pi/180) + 89.038) #-------------------------------------------- # 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 brightness, Q: Q = 925 * iabz[..., 0]**1.17 * (FL / Fs)**0.5 #-------------------------------------------- # calculate lightness, J: J = 100.0 * (Q / Qw)**(Fs * Fb) #-------------------------------------------- # calculate colorfulness, M: M = 50 * ((iabz[..., 1]**2.0 + iabz[..., 2]**2.0)** 0.368) * (ez**0.068) * (FL**0.2) / ((iabzw[..., 0]**2.52) * (Fb**0.3)) #-------------------------------------------- # calculate chroma, C: C = 100 * M / Qw #-------------------------------------------- # calculate saturation, s: s = 100.0 * (M / Q) S = s # make extra variable, jsut in case 'S' is called #-------------------------------------------- # calculate whiteness, W: if ('Wz' in outin) | ('aWz' in outin): Wz = 100 - 0.68 * ((100 - J)**2 + C**2)**0.5 #-------------------------------------------- # calculate blackness, K: if ('Kz' in outin) | ('aKz' in outin): Kz = 100 - 0.82 * (J**2 + C**2)**0.5 #-------------------------------------------- # calculate saturation, S: if ('Sz' in outin) | ('aSz' in outin): Sz = 8 + 0.5 * ((J - 55)**2 + C**2)**0.5 #-------------------------------------------- # calculate vividness, V: if ('Vz' in outin) | ('aVz' in outin): Sz = 8 + 0.4 * ((J - 70)**2 + C**2)**0.5 #-------------------------------------------- # 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 and brightness Q from data: if ('J' in outin[0]): J = data[..., 0].copy() Q = Qw * (J / 100)**(1 / (Fs * Fb)) elif ('Q' in outin[0]): Q = data[..., 0].copy() J = 100.0 * (Q / Qw)**(Fs * Fb) else: raise Exception( 'No lightness or brightness values in data[...,0]. Inverse CAM-transform not possible!' ) #-------------------------------------------- # calculate achromatic signal, Iz: Iz = (Qw / 925 * ((J / 100)**(1 / (Fs * Fb))) * (Fs / FL)**0.5)**(1 / 1.17) #-------------------------------------------- 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 ('aS' in outin) | ('S' in outin): Q = Qw * (J / 100)**(1 / (Fs * Fb)) M = Q * (MCs / 100.0) C = 100 * M / Qw if ('aM' in outin) | ('M' in outin): C = 100 * MCs / Qw if ('aC' in outin) | ('C' in outin): # convert C to M: C = MCs if ('Wz' in outin) | ('aWz' in outin): #whiteness C = ((100 / 68 * (100 - MCs))**2 - (J - 100)**2)**0.5 if ('Kz' in outin) | ('aKz' in outin): # blackness C = ((100 / 82 * (100 - MCs))**2 - (J)**2)**0.5 if ('Sz' in outin) | ('aSz' in outin): # saturation C = ((10 / 5 * (MCs - 8))**2 - (J - 55)**2)**0.5 if ('Vz' in outin) | ('aVz' in outin): # vividness C = ((10 / 4 * (MCs - 8))**2 - (J - 70)**2)**0.5 #-------------------------------------------- # Calculate colorfulness, M: M = Qw * C / 100 #-------------------------------------------- # calculate eccentricity factor, et: # ez = 1.014 + np.cos(h*np.pi/180 + 89.038) ez = 1.014 + np.cos((h + 89.038) * np.pi / 180) #-------------------------------------------- # calculate t (=sqrt(a**2+b**2)) from M: t = (((M / 50) * (iabzw[..., 0]**2.52) * (Fb**0.3)) / ((ez**0.068) * (FL**0.2)))**(1 / 0.368 / 2) #-------------------------------------------- # Calculate az, bz: az = t * np.cos(h * np.pi / 180) bz = t * np.sin(h * np.pi / 180) #-------------------------------------------- # join values and convert to xyz: xyzc = jabz_to_xyz(ajoin((Iz, az, bz)), ztype='iabz', use_zcam_parameters=True) #------------------------------------------- # Apply CAT from D65: xyz = cat.apply_vonkries2(xyzc, xyzw_d65, xyzw, D=D, mcat=mcat, invmcat=invmcat, use_Yw=True) return xyz