Coverage for colorimetry/dominant.py: 63%

57 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-16 22:49 +1300

1""" 

2Dominant Wavelength and Purity 

3============================== 

4 

5Define the objects to compute the *dominant wavelength* and *purity* of a 

6colour and related quantities: 

7 

8- :func:`colour.dominant_wavelength` 

9- :func:`colour.complementary_wavelength` 

10- :func:`colour.excitation_purity` 

11- :func:`colour.colorimetric_purity` 

12 

13References 

14---------- 

15- :cite:`CIETC1-482004o` : CIE TC 1-48. (2004). 9.1 Dominant wavelength and 

16 purity. In CIE 015:2004 Colorimetry, 3rd Edition (pp. 32-33). 

17 ISBN:978-3-901906-33-6 

18- :cite:`Erdogana` : Erdogan, T. (n.d.). How to Calculate Luminosity, 

19 Dominant Wavelength, and Excitation Purity (p. 7). 

20 http://www.semrock.com/Data/Sites/1/semrockpdfs/\ 

21whitepaper_howtocalculateluminositywavelengthandpurity.pdf 

22""" 

23 

24from __future__ import annotations 

25 

26import typing 

27 

28import numpy as np 

29 

30from colour.algebra import euclidean_distance, sdiv, sdiv_mode 

31from colour.colorimetry import MultiSpectralDistributions, handle_spectral_arguments 

32from colour.geometry import extend_line_segment, intersect_line_segments 

33 

34if typing.TYPE_CHECKING: 

35 from colour.hints import ArrayLike, NDArrayFloat, NDArrayInt, Tuple 

36 

37from colour.models import XYZ_to_xy 

38from colour.utilities import as_float_array, required 

39 

40__author__ = "Colour Developers" 

41__copyright__ = "Copyright 2013 Colour Developers" 

42__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" 

43__maintainer__ = "Colour Developers" 

44__email__ = "colour-developers@colour-science.org" 

45__status__ = "Production" 

46 

47__all__ = [ 

48 "closest_spectral_locus_wavelength", 

49 "dominant_wavelength", 

50 "complementary_wavelength", 

51 "excitation_purity", 

52 "colorimetric_purity", 

53] 

54 

55 

56@required("SciPy") 

57def closest_spectral_locus_wavelength( 

58 xy: ArrayLike, xy_n: ArrayLike, xy_s: ArrayLike, inverse: bool = False 

59) -> Tuple[NDArrayInt, NDArrayFloat]: 

60 """ 

61 Compute the coordinates and closest spectral locus wavelength index to the 

62 point where the line defined by the achromatic stimulus :math:`xy_n` to 

63 colour stimulus :math:`xy` *CIE xy* chromaticity coordinates intersects 

64 the spectral locus. 

65 

66 Parameters 

67 ---------- 

68 xy 

69 Colour stimulus *CIE xy* chromaticity coordinates. 

70 xy_n 

71 Achromatic stimulus *CIE xy* chromaticity coordinates. 

72 xy_s 

73 Spectral locus *CIE xy* chromaticity coordinates. 

74 inverse 

75 The intersection will be computed using the colour stimulus :math:`xy` 

76 to achromatic stimulus :math:`xy_n` inverse direction. 

77 

78 Returns 

79 ------- 

80 :class:`tuple` 

81 Closest wavelength index, intersection point *CIE xy* chromaticity 

82 coordinates. 

83 

84 Raises 

85 ------ 

86 ValueError 

87 If no closest spectral locus wavelength index and coordinates found. 

88 

89 Examples 

90 -------- 

91 >>> from colour.colorimetry import MSDS_CMFS 

92 >>> cmfs = MSDS_CMFS["CIE 1931 2 Degree Standard Observer"] 

93 >>> xy = np.array([0.54369557, 0.32107944]) 

94 >>> xy_n = np.array([0.31270000, 0.32900000]) 

95 >>> xy_s = XYZ_to_xy(cmfs.values) 

96 >>> ix, intersect = closest_spectral_locus_wavelength(xy, xy_n, xy_s) 

97 >>> print(ix) # 

98 256 

99 >>> print(intersect) # doctest: +ELLIPSIS 

100 [ 0.6835474... 0.3162840...] 

101 """ 

102 

103 import scipy.spatial.distance # noqa: PLC0415 

104 

105 xy = as_float_array(xy) 

106 xy_n = np.resize(xy_n, xy.shape) 

107 xy_s = as_float_array(xy_s) 

108 

109 xy_e = extend_line_segment(xy, xy_n) if inverse else extend_line_segment(xy_n, xy) 

110 

111 # Closing horse-shoe shape to handle line of purples intersections. 

112 xy_s = np.vstack([xy_s, xy_s[0, :]]) 

113 

114 xy_wl = intersect_line_segments( 

115 np.concatenate((xy_n, xy_e), -1), 

116 np.hstack([xy_s, np.roll(xy_s, 1, axis=0)]), 

117 ).xy 

118 # Extracting the first intersection per-wavelength. 

119 xy_wl = np.sort(xy_wl, 1)[:, 0, :] 

120 

121 i_wl = np.argmin(scipy.spatial.distance.cdist(xy_wl, xy_s), axis=-1) 

122 

123 i_wl = np.reshape(i_wl, xy.shape[0:-1]) 

124 xy_wl = np.reshape(xy_wl, xy.shape) 

125 

126 return i_wl, xy_wl 

127 

128 

129def dominant_wavelength( 

130 xy: ArrayLike, 

131 xy_n: ArrayLike, 

132 cmfs: MultiSpectralDistributions | None = None, 

133 inverse: bool = False, 

134) -> Tuple[NDArrayFloat, NDArrayFloat, NDArrayFloat]: 

135 """ 

136 Compute the *dominant wavelength* :math:`\\lambda_d` for colour stimulus 

137 :math:`xy` and the related :math:`xy_wl` first and :math:`xy_{cw}` second 

138 intersection coordinates with the spectral locus. 

139 

140 In the eventuality where the :math:`xy_wl` first intersection coordinates 

141 are on the line of purples, the *complementary wavelength* will be 

142 computed in lieu. 

143 

144 The *complementary wavelength* is indicated by a negative sign and the 

145 :math:`xy_{cw}` second intersection coordinates which are set by default 

146 to the same value as :math:`xy_wl` first intersection coordinates will be 

147 set to the *complementary dominant wavelength* intersection coordinates 

148 with the spectral locus. 

149 

150 Parameters 

151 ---------- 

152 xy 

153 Colour stimulus *CIE xy* chromaticity coordinates. 

154 xy_n 

155 Achromatic stimulus *CIE xy* chromaticity coordinates. 

156 cmfs 

157 Standard observer colour matching functions, default to the 

158 *CIE 1931 2 Degree Standard Observer*. 

159 inverse 

160 Inverse the computation direction to retrieve the 

161 *complementary wavelength*. 

162 

163 Returns 

164 ------- 

165 :class:`tuple` 

166 *Dominant wavelength*, first intersection point *CIE xy* chromaticity 

167 coordinates, second intersection point *CIE xy* chromaticity 

168 coordinates. 

169 

170 References 

171 ---------- 

172 :cite:`CIETC1-482004o`, :cite:`Erdogana` 

173 

174 Examples 

175 -------- 

176 *Dominant wavelength* computation: 

177 

178 >>> from colour.colorimetry import MSDS_CMFS 

179 >>> from pprint import pprint 

180 >>> cmfs = MSDS_CMFS["CIE 1931 2 Degree Standard Observer"] 

181 >>> xy = np.array([0.54369557, 0.32107944]) 

182 >>> xy_n = np.array([0.31270000, 0.32900000]) 

183 >>> pprint(dominant_wavelength(xy, xy_n, cmfs)) # doctest: +ELLIPSIS 

184 (array(616...), 

185 array([ 0.6835474..., 0.3162840...]), 

186 array([ 0.6835474..., 0.3162840...])) 

187 

188 *Complementary dominant wavelength* is returned if the first intersection 

189 is located on the line of purples: 

190 

191 >>> xy = np.array([0.37605506, 0.24452225]) 

192 >>> pprint(dominant_wavelength(xy, xy_n)) # doctest: +ELLIPSIS 

193 (array(-509.0), 

194 array([ 0.4572314..., 0.1362814...]), 

195 array([ 0.0104096..., 0.7320745...])) 

196 """ 

197 

198 cmfs, _illuminant = handle_spectral_arguments(cmfs) 

199 

200 xy = as_float_array(xy) 

201 xy_n = np.resize(xy_n, xy.shape) 

202 

203 xy_s = XYZ_to_xy(cmfs.values) 

204 

205 i_wl, xy_wl = closest_spectral_locus_wavelength(xy, xy_n, xy_s, inverse) 

206 xy_cwl = xy_wl 

207 wl = cmfs.wavelengths[i_wl] 

208 

209 xy_e = extend_line_segment(xy, xy_n) if inverse else extend_line_segment(xy_n, xy) 

210 intersect = intersect_line_segments( 

211 np.concatenate((xy_n, xy_e), -1), np.hstack([xy_s[0], xy_s[-1]]) 

212 ).intersect 

213 intersect = np.reshape(intersect, wl.shape) 

214 

215 i_wl_r, xy_cwl_r = closest_spectral_locus_wavelength(xy, xy_n, xy_s, not inverse) 

216 wl_r = -cmfs.wavelengths[i_wl_r] 

217 

218 wl = np.where(intersect, wl_r, wl) 

219 xy_cwl = np.where(intersect[..., None], xy_cwl_r, xy_cwl) 

220 

221 return wl, np.squeeze(xy_wl), np.squeeze(xy_cwl) 

222 

223 

224def complementary_wavelength( 

225 xy: ArrayLike, 

226 xy_n: ArrayLike, 

227 cmfs: MultiSpectralDistributions | None = None, 

228) -> Tuple[NDArrayFloat, NDArrayFloat, NDArrayFloat]: 

229 """ 

230 Compute the *complementary wavelength* :math:`\\lambda_c` for the 

231 specified colour stimulus :math:`xy` and the related :math:`xy_wl` first 

232 and :math:`xy_{cw}` second intersection coordinates with the spectral 

233 locus. 

234 

235 In the eventuality where the :math:`xy_wl` first intersection coordinates 

236 are on the line of purples, the *dominant wavelength* will be computed in 

237 lieu. 

238 

239 The *dominant wavelength* is indicated by a negative sign and the 

240 :math:`xy_{cw}` second intersection coordinates which are set by default 

241 to the same value as :math:`xy_wl` first intersection coordinates will be 

242 set to the *dominant wavelength* intersection coordinates with the 

243 spectral locus. 

244 

245 Parameters 

246 ---------- 

247 xy 

248 Colour stimulus *CIE xy* chromaticity coordinates. 

249 xy_n 

250 Achromatic stimulus *CIE xy* chromaticity coordinates. 

251 cmfs 

252 Standard observer colour matching functions, default to the 

253 *CIE 1931 2 Degree Standard Observer*. 

254 

255 Returns 

256 ------- 

257 :class:`tuple` 

258 *Complementary wavelength*, first intersection point *CIE xy* 

259 chromaticity coordinates, second intersection point *CIE xy* 

260 chromaticity coordinates. 

261 

262 References 

263 ---------- 

264 :cite:`CIETC1-482004o`, :cite:`Erdogana` 

265 

266 Examples 

267 -------- 

268 *Complementary wavelength* computation: 

269 

270 >>> from colour.colorimetry import MSDS_CMFS 

271 >>> from pprint import pprint 

272 >>> cmfs = MSDS_CMFS["CIE 1931 2 Degree Standard Observer"] 

273 >>> xy = np.array([0.37605506, 0.24452225]) 

274 >>> xy_n = np.array([0.31270000, 0.32900000]) 

275 >>> pprint(complementary_wavelength(xy, xy_n, cmfs)) # doctest: +ELLIPSIS 

276 (array(509.0), 

277 array([ 0.0104096..., 0.7320745...]), 

278 array([ 0.0104096..., 0.7320745...])) 

279 

280 *Dominant wavelength* is returned if the first intersection is located on 

281 the line of purples: 

282 

283 >>> xy = np.array([0.54369557, 0.32107944]) 

284 >>> pprint(complementary_wavelength(xy, xy_n)) # doctest: +ELLIPSIS 

285 (array(492.0), 

286 array([ 0.0364795 , 0.3384712...]), 

287 array([ 0.0364795 , 0.3384712...])) 

288 """ 

289 

290 return dominant_wavelength(xy, xy_n, cmfs, True) 

291 

292 

293def excitation_purity( 

294 xy: ArrayLike, 

295 xy_n: ArrayLike, 

296 cmfs: MultiSpectralDistributions | None = None, 

297) -> NDArrayFloat: 

298 """ 

299 Compute the *excitation purity* :math:`P_e` for the specified colour 

300 stimulus :math:`xy`. 

301 

302 Parameters 

303 ---------- 

304 xy 

305 Colour stimulus *CIE xy* chromaticity coordinates. 

306 xy_n 

307 Achromatic stimulus *CIE xy* chromaticity coordinates. 

308 cmfs 

309 Standard observer colour matching functions, default to the 

310 *CIE 1931 2 Degree Standard Observer*. 

311 

312 Returns 

313 ------- 

314 :class:`np.float` or :class:`numpy.ndarray` 

315 *Excitation purity* :math:`P_e`. 

316 

317 References 

318 ---------- 

319 :cite:`CIETC1-482004o`, :cite:`Erdogana` 

320 

321 Examples 

322 -------- 

323 >>> from colour.colorimetry import MSDS_CMFS 

324 >>> cmfs = MSDS_CMFS["CIE 1931 2 Degree Standard Observer"] 

325 >>> xy = np.array([0.54369557, 0.32107944]) 

326 >>> xy_n = np.array([0.31270000, 0.32900000]) 

327 >>> excitation_purity(xy, xy_n, cmfs) # doctest: +ELLIPSIS 

328 0.6228856... 

329 """ 

330 

331 _wl, xy_wl, _xy_cwl = dominant_wavelength(xy, xy_n, cmfs) 

332 

333 with sdiv_mode(): 

334 return sdiv( 

335 euclidean_distance(xy_n, xy), 

336 euclidean_distance(xy_n, xy_wl), 

337 ) 

338 

339 

340def colorimetric_purity( 

341 xy: ArrayLike, 

342 xy_n: ArrayLike, 

343 cmfs: MultiSpectralDistributions | None = None, 

344) -> NDArrayFloat: 

345 """ 

346 Compute the *colorimetric purity* :math:`P_c` for the specified 

347 colour stimulus :math:`xy`. 

348 

349 Parameters 

350 ---------- 

351 xy 

352 Colour stimulus *CIE xy* chromaticity coordinates. 

353 xy_n 

354 Achromatic stimulus *CIE xy* chromaticity coordinates. 

355 cmfs 

356 Standard observer colour matching functions, default to the 

357 *CIE 1931 2 Degree Standard Observer*. 

358 

359 Returns 

360 ------- 

361 :class:`np.float` or :class:`numpy.ndarray` 

362 *Colorimetric purity* :math:`P_c`. 

363 

364 References 

365 ---------- 

366 :cite:`CIETC1-482004o`, :cite:`Erdogana` 

367 

368 Examples 

369 -------- 

370 >>> from colour.colorimetry import MSDS_CMFS 

371 >>> cmfs = MSDS_CMFS["CIE 1931 2 Degree Standard Observer"] 

372 >>> xy = np.array([0.54369557, 0.32107944]) 

373 >>> xy_n = np.array([0.31270000, 0.32900000]) 

374 >>> colorimetric_purity(xy, xy_n, cmfs) # doctest: +ELLIPSIS 

375 0.6135828... 

376 """ 

377 

378 xy = as_float_array(xy) 

379 

380 _wl, xy_wl, _xy_cwl = dominant_wavelength(xy, xy_n, cmfs) 

381 P_e = excitation_purity(xy, xy_n, cmfs) 

382 

383 with sdiv_mode(): 

384 return P_e * sdiv(xy_wl[..., 1], xy[..., 1])