Coverage for colour/recovery/mallett2019.py: 100%

39 statements  

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

1""" 

2Mallett and Yuksel (2019) - Reflectance Recovery 

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

4 

5Define the objects for reflectance recovery, i.e., spectral upsampling, using 

6*Mallett and Yuksel (2019)* method. 

7 

8- :func:`colour.recovery.spectral_primary_decomposition_Mallett2019` 

9- :func:`colour.recovery.RGB_to_sd_Mallett2019` 

10 

11References 

12---------- 

13- :cite:`Mallett2019` : Mallett, I., & Yuksel, C. (2019). Spectral Primary 

14 Decomposition for Rendering with sRGB Reflectance. Eurographics Symposium 

15 on Rendering - DL-Only and Industry Track, 7 pages. doi:10.2312/SR.20191216 

16""" 

17 

18from __future__ import annotations 

19 

20import typing 

21 

22import numpy as np 

23 

24from colour.colorimetry import ( 

25 MultiSpectralDistributions, 

26 SpectralDistribution, 

27 handle_spectral_arguments, 

28) 

29 

30if typing.TYPE_CHECKING: 

31 from colour.models import RGB_Colourspace 

32 from colour.hints import Callable, Domain1 

33 

34from colour.hints import Domain1 # noqa: TC001 

35from colour.recovery import MSDS_BASIS_FUNCTIONS_sRGB_MALLETT2019 

36from colour.utilities import required, to_domain_1 

37 

38__author__ = "Colour Developers" 

39__copyright__ = "Copyright 2013 Colour Developers" 

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

41__maintainer__ = "Colour Developers" 

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

43__status__ = "Production" 

44 

45__all__ = [ 

46 "spectral_primary_decomposition_Mallett2019", 

47 "RGB_to_sd_Mallett2019", 

48] 

49 

50 

51@required("SciPy") 

52def spectral_primary_decomposition_Mallett2019( 

53 colourspace: RGB_Colourspace, 

54 cmfs: MultiSpectralDistributions | None = None, 

55 illuminant: SpectralDistribution | None = None, 

56 metric: Callable = np.linalg.norm, 

57 metric_args: tuple = (), 

58 optimisation_kwargs: dict | None = None, 

59) -> MultiSpectralDistributions: 

60 """ 

61 Perform spectral primary decomposition as described in *Mallett and 

62 Yuksel (2019)* for the specified *RGB* colourspace. 

63 

64 Parameters 

65 ---------- 

66 colourspace 

67 *RGB* colourspace. 

68 cmfs 

69 Standard observer colour matching functions, default to the 

70 *CIE 1931 2 Degree Standard Observer*. 

71 illuminant 

72 Illuminant spectral distribution, default to 

73 *CIE Standard Illuminant D65*. 

74 metric 

75 Function to be minimised, i.e., the objective function. 

76 

77 ``metric(basis, *metric_args) -> float`` 

78 

79 where ``basis`` is three reflectances concatenated together, each 

80 with a shape matching ``shape``. 

81 metric_args 

82 Additional arguments passed to ``metric``. 

83 optimisation_kwargs 

84 Parameters for :func:`scipy.optimize.minimize` definition. 

85 

86 Returns 

87 ------- 

88 :class:`colour.MultiSpectralDistributions` 

89 Basis functions for the specified *RGB* colourspace. 

90 

91 References 

92 ---------- 

93 :cite:`Mallett2019` 

94 

95 Notes 

96 ----- 

97 - In addition to the *BT.709* primaries used by the *sRGB* 

98 colourspace, :cite:`Mallett2019` tested *BT.2020*, *P3 D65*, 

99 *Adobe RGB 1998*, *NTSC (1987)*, *Pal/Secam*, *ProPhoto RGB*, and 

100 *Adobe Wide Gamut RGB* primaries, every one of which encompasses a 

101 larger (albeit not always enveloping) set of *CIE L\\*a\\*b\\** 

102 colours than BT.709. Of these, only *Pal/Secam* produces a 

103 feasible basis, which is relatively unsurprising since it is very 

104 similar to *BT.709*, whereas the others are significantly larger. 

105 

106 Examples 

107 -------- 

108 >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS, SpectralShape 

109 >>> from colour.models import RGB_COLOURSPACE_PAL_SECAM 

110 >>> from colour.utilities import numpy_print_options 

111 >>> cmfs = ( 

112 ... MSDS_CMFS["CIE 1931 2 Degree Standard Observer"] 

113 ... .copy() 

114 ... .align(SpectralShape(360, 780, 10)) 

115 ... ) 

116 >>> illuminant = SDS_ILLUMINANTS["D65"].copy().align(cmfs.shape) 

117 >>> msds = spectral_primary_decomposition_Mallett2019( 

118 ... RGB_COLOURSPACE_PAL_SECAM, 

119 ... cmfs, 

120 ... illuminant, 

121 ... optimisation_kwargs={"options": {"ftol": 1e-5}}, 

122 ... ) 

123 >>> with numpy_print_options(suppress=True): 

124 ... print(msds) # doctest: +SKIP 

125 [[ 360. 0.3395134... 0.3400214... 0.3204650...] 

126 [ 370. 0.3355246... 0.3338028... 0.3306724...] 

127 [ 380. 0.3376707... 0.3185578... 0.3437715...] 

128 [ 390. 0.3178866... 0.3351754... 0.3469378...] 

129 [ 400. 0.3045154... 0.3248376... 0.3706469...] 

130 [ 410. 0.2935652... 0.2919463... 0.4144884...] 

131 [ 420. 0.1875740... 0.1853729... 0.6270530...] 

132 [ 430. 0.0167983... 0.054483 ... 0.9287186...] 

133 [ 440. 0. ... 0. ... 1. ...] 

134 [ 450. 0. ... 0. ... 1. ...] 

135 [ 460. 0. ... 0. ... 1. ...] 

136 [ 470. 0. ... 0.0458044... 0.9541955...] 

137 [ 480. 0. ... 0.2960917... 0.7039082...] 

138 [ 490. 0. ... 0.5042592... 0.4957407...] 

139 [ 500. 0. ... 0.6655795... 0.3344204...] 

140 [ 510. 0. ... 0.8607541... 0.1392458...] 

141 [ 520. 0. ... 0.9999998... 0.0000001...] 

142 [ 530. 0. ... 1. ... 0. ...] 

143 [ 540. 0. ... 1. ... 0. ...] 

144 [ 550. 0. ... 1. ... 0. ...] 

145 [ 560. 0. ... 0.9924229... 0. ...] 

146 [ 570. 0. ... 0.9970703... 0.0025673...] 

147 [ 580. 0.0396002... 0.9028231... 0.0575766...] 

148 [ 590. 0.7058973... 0.2941026... 0. ...] 

149 [ 600. 1. ... 0. ... 0. ...] 

150 [ 610. 1. ... 0. ... 0. ...] 

151 [ 620. 1. ... 0. ... 0. ...] 

152 [ 630. 1. ... 0. ... 0. ...] 

153 [ 640. 0.9835925... 0.0100166... 0.0063908...] 

154 [ 650. 0.7878949... 0.1265097... 0.0855953...] 

155 [ 660. 0.5987994... 0.2051062... 0.1960942...] 

156 [ 670. 0.4724493... 0.2649623... 0.2625883...] 

157 [ 680. 0.3989806... 0.3007488... 0.3002704...] 

158 [ 690. 0.3666586... 0.3164003... 0.3169410...] 

159 [ 700. 0.3497806... 0.3242863... 0.3259329...] 

160 [ 710. 0.3563736... 0.3232441... 0.3203822...] 

161 [ 720. 0.3362624... 0.3326209... 0.3311165...] 

162 [ 730. 0.3245015... 0.3365982... 0.3389002...] 

163 [ 740. 0.3335520... 0.3320670... 0.3343808...] 

164 [ 750. 0.3441287... 0.3291168... 0.3267544...] 

165 [ 760. 0.3343705... 0.3330132... 0.3326162...] 

166 [ 770. 0.3274633... 0.3305704... 0.3419662...] 

167 [ 780. 0.3475263... 0.3262331... 0.3262404...]] 

168 """ 

169 

170 from scipy.linalg import block_diag # noqa: PLC0415 

171 from scipy.optimize import Bounds, LinearConstraint, minimize # noqa: PLC0415 

172 

173 cmfs, illuminant = handle_spectral_arguments(cmfs, illuminant) 

174 

175 N = len(cmfs.shape) 

176 

177 R_to_XYZ = np.transpose( 

178 illuminant.values[..., None] 

179 * cmfs.values 

180 / (np.sum(cmfs.values[:, 1] * illuminant.values)) 

181 ) 

182 R_to_RGB = np.dot(colourspace.matrix_XYZ_to_RGB, R_to_XYZ) 

183 basis_to_RGB = block_diag(R_to_RGB, R_to_RGB, R_to_RGB) 

184 

185 primaries = np.reshape(np.identity(3), 9) 

186 

187 # Ensure that the reflectances correspond to the correct RGB colours. 

188 colour_match = LinearConstraint(basis_to_RGB, primaries, primaries) 

189 

190 # Ensure that the reflectances are bounded by [0, 1]. 

191 energy_conservation = Bounds(np.zeros(3 * N), np.ones(3 * N)) 

192 

193 # Ensure that the sum of the three bases is bounded by [0, 1]. 

194 sum_matrix = np.transpose(np.tile(np.identity(N), (3, 1))) 

195 sum_constraint = LinearConstraint(sum_matrix, np.zeros(N), np.ones(N)) 

196 

197 optimisation_settings = { 

198 "method": "SLSQP", 

199 "constraints": [colour_match, sum_constraint], 

200 "bounds": energy_conservation, 

201 "options": { 

202 "ftol": 1e-10, 

203 }, 

204 } 

205 

206 if optimisation_kwargs is not None: 

207 optimisation_settings.update(optimisation_kwargs) 

208 

209 result = minimize( 

210 metric, args=metric_args, x0=np.zeros(3 * N), **optimisation_settings 

211 ) 

212 

213 basis_functions = np.transpose(np.reshape(result.x, (3, N))) 

214 

215 return MultiSpectralDistributions( 

216 basis_functions, 

217 cmfs.shape.wavelengths, 

218 name=f"Basis Functions - {colourspace.name} - Mallett (2019)", 

219 labels=("red", "green", "blue"), 

220 ) 

221 

222 

223def RGB_to_sd_Mallett2019( 

224 RGB: Domain1, 

225 basis_functions: MultiSpectralDistributions = MSDS_BASIS_FUNCTIONS_sRGB_MALLETT2019, 

226) -> SpectralDistribution: 

227 """ 

228 Recover the spectral distribution of the specified *RGB* colourspace 

229 array using *Mallett and Yuksel (2019)* method. 

230 

231 Parameters 

232 ---------- 

233 RGB 

234 *RGB* colourspace array. 

235 basis_functions 

236 Basis functions for the method. The default is to use the built-in 

237 *sRGB* basis functions, i.e., 

238 :attr:`colour.recovery.MSDS_BASIS_FUNCTIONS_sRGB_MALLETT2019`. 

239 

240 Returns 

241 ------- 

242 :class:`colour.SpectralDistribution` 

243 Recovered reflectance. 

244 

245 References 

246 ---------- 

247 :cite:`Mallett2019` 

248 

249 Notes 

250 ----- 

251 +------------+-----------------------+---------------+ 

252 | **Domain** | **Scale - Reference** | **Scale - 1** | 

253 +============+=======================+===============+ 

254 | ``RGB`` | 1 | 1 | 

255 +------------+-----------------------+---------------+ 

256 

257 - In addition to the *BT.709* primaries used by the *sRGB* 

258 colourspace, :cite:`Mallett2019` tested *BT.2020*, *P3 D65*, 

259 *Adobe RGB 1998*, *NTSC (1987)*, *Pal/Secam*, *ProPhoto RGB*, and 

260 *Adobe Wide Gamut RGB* primaries, every one of which encompasses a 

261 larger (albeit not always enveloping) set of *CIE L\\*a\\*b\\** 

262 colours than BT.709. Of these, only *Pal/Secam* produces a 

263 feasible basis, which is relatively unsurprising since it is very 

264 similar to *BT.709*, whereas the others are significantly larger. 

265 

266 Examples 

267 -------- 

268 >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS, XYZ_to_sRGB 

269 >>> from colour.colorimetry import sd_to_XYZ_integration 

270 >>> from colour.recovery import SPECTRAL_SHAPE_sRGB_MALLETT2019 

271 >>> from colour.utilities import numpy_print_options 

272 >>> XYZ = np.array([0.20654008, 0.12197225, 0.05136952]) 

273 >>> RGB = XYZ_to_sRGB(XYZ, apply_cctf_encoding=False) 

274 >>> cmfs = ( 

275 ... MSDS_CMFS["CIE 1931 2 Degree Standard Observer"] 

276 ... .copy() 

277 ... .align(SPECTRAL_SHAPE_sRGB_MALLETT2019) 

278 ... ) 

279 >>> illuminant = SDS_ILLUMINANTS["D65"].copy().align(cmfs.shape) 

280 >>> sd = RGB_to_sd_Mallett2019(RGB) 

281 >>> with numpy_print_options(suppress=True): 

282 ... sd # doctest: +ELLIPSIS 

283 SpectralDistribution([[ 380. , 0.1735531...], 

284 [ 385. , 0.1720357...], 

285 [ 390. , 0.1677721...], 

286 [ 395. , 0.1576605...], 

287 [ 400. , 0.1372829...], 

288 [ 405. , 0.1170849...], 

289 [ 410. , 0.0895694...], 

290 [ 415. , 0.0706232...], 

291 [ 420. , 0.0585765...], 

292 [ 425. , 0.0523959...], 

293 [ 430. , 0.0497598...], 

294 [ 435. , 0.0476057...], 

295 [ 440. , 0.0465079...], 

296 [ 445. , 0.0460337...], 

297 [ 450. , 0.0455839...], 

298 [ 455. , 0.0452872...], 

299 [ 460. , 0.0450981...], 

300 [ 465. , 0.0448895...], 

301 [ 470. , 0.0449257...], 

302 [ 475. , 0.0448987...], 

303 [ 480. , 0.0446834...], 

304 [ 485. , 0.0441372...], 

305 [ 490. , 0.0417137...], 

306 [ 495. , 0.0373832...], 

307 [ 500. , 0.0357657...], 

308 [ 505. , 0.0348263...], 

309 [ 510. , 0.0341953...], 

310 [ 515. , 0.0337683...], 

311 [ 520. , 0.0334979...], 

312 [ 525. , 0.0332991...], 

313 [ 530. , 0.0331909...], 

314 [ 535. , 0.0332181...], 

315 [ 540. , 0.0333387...], 

316 [ 545. , 0.0334970...], 

317 [ 550. , 0.0337381...], 

318 [ 555. , 0.0341847...], 

319 [ 560. , 0.0346447...], 

320 [ 565. , 0.0353993...], 

321 [ 570. , 0.0367367...], 

322 [ 575. , 0.0392007...], 

323 [ 580. , 0.0445902...], 

324 [ 585. , 0.0625633...], 

325 [ 590. , 0.2965381...], 

326 [ 595. , 0.4215576...], 

327 [ 600. , 0.4347139...], 

328 [ 605. , 0.4385134...], 

329 [ 610. , 0.4385184...], 

330 [ 615. , 0.4385249...], 

331 [ 620. , 0.4374694...], 

332 [ 625. , 0.4384672...], 

333 [ 630. , 0.4368251...], 

334 [ 635. , 0.4340867...], 

335 [ 640. , 0.4303219...], 

336 [ 645. , 0.4243257...], 

337 [ 650. , 0.4159482...], 

338 [ 655. , 0.4057443...], 

339 [ 660. , 0.3919874...], 

340 [ 665. , 0.3742784...], 

341 [ 670. , 0.3518421...], 

342 [ 675. , 0.3240127...], 

343 [ 680. , 0.2955145...], 

344 [ 685. , 0.2625658...], 

345 [ 690. , 0.2343423...], 

346 [ 695. , 0.2174830...], 

347 [ 700. , 0.2060461...], 

348 [ 705. , 0.1977437...], 

349 [ 710. , 0.1916846...], 

350 [ 715. , 0.1861020...], 

351 [ 720. , 0.1823908...], 

352 [ 725. , 0.1807923...], 

353 [ 730. , 0.1795571...], 

354 [ 735. , 0.1785623...], 

355 [ 740. , 0.1775758...], 

356 [ 745. , 0.1771614...], 

357 [ 750. , 0.1767431...], 

358 [ 755. , 0.1764319...], 

359 [ 760. , 0.1762597...], 

360 [ 765. , 0.1762209...], 

361 [ 770. , 0.1761803...], 

362 [ 775. , 0.1761195...], 

363 [ 780. , 0.1760763...]], 

364 SpragueInterpolator, 

365 {}, 

366 Extrapolator, 

367 {'method': 'Constant', 'left': None, 'right': None}) 

368 >>> sd_to_XYZ_integration(sd, cmfs, illuminant) / 100 

369 ... # doctest: +ELLIPSIS 

370 array([ 0.2065436..., 0.1219996..., 0.0513764...]) 

371 """ 

372 

373 RGB = to_domain_1(RGB) 

374 

375 sd = SpectralDistribution( 

376 np.dot(RGB, np.transpose(basis_functions.values)), 

377 basis_functions.wavelengths, 

378 ) 

379 sd.name = f"{RGB} (RGB) - Mallett (2019)" 

380 

381 return sd