Coverage for colour/recovery/tests/test_jakob2019.py: 100%

123 statements  

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

1"""Define the unit tests for the :mod:`colour.recovery.jakob2019` module.""" 

2 

3from __future__ import annotations 

4 

5import os 

6import shutil 

7import tempfile 

8import typing 

9 

10import numpy as np 

11import pytest 

12 

13from colour.characterisation import SDS_COLOURCHECKERS 

14from colour.colorimetry import handle_spectral_arguments, sd_to_XYZ 

15from colour.constants import TOLERANCE_ABSOLUTE_TESTS 

16from colour.difference import JND_CIE1976, delta_E_CIE1976 

17 

18if typing.TYPE_CHECKING: 

19 from colour.hints import Type 

20 

21from colour.models import RGB_COLOURSPACE_sRGB, RGB_to_XYZ, XYZ_to_Lab, XYZ_to_xy 

22from colour.recovery.jakob2019 import ( 

23 SPECTRAL_SHAPE_JAKOB2019, 

24 LUT3D_Jakob2019, 

25 XYZ_to_sd_Jakob2019, 

26 dimensionalise_coefficients, 

27 error_function, 

28 sd_Jakob2019, 

29) 

30from colour.utilities import domain_range_scale, full, is_scipy_installed, ones, zeros 

31 

32__author__ = "Colour Developers" 

33__copyright__ = "Copyright 2013 Colour Developers" 

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

35__maintainer__ = "Colour Developers" 

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

37__status__ = "Production" 

38 

39__all__ = [ 

40 "TestErrorFunction", 

41 "TestXYZ_to_sd_Jakob2019", 

42 "TestLUT3D_Jakob2019", 

43] 

44 

45 

46class TestErrorFunction: 

47 """ 

48 Define :func:`colour.recovery.jakob2019.error_function` definition unit 

49 tests methods. 

50 """ 

51 

52 def setup_method(self) -> None: 

53 """Initialise the common tests attributes.""" 

54 

55 self._shape = SPECTRAL_SHAPE_JAKOB2019 

56 self._cmfs, self._sd_D65 = handle_spectral_arguments(shape_default=self._shape) 

57 self._XYZ_D65 = sd_to_XYZ(self._sd_D65) 

58 self._xy_D65 = XYZ_to_xy(self._XYZ_D65) 

59 

60 self._Lab_e = np.array([72, -20, 61]) 

61 

62 def test_intermediates(self) -> None: 

63 """ 

64 Test intermediate results of 

65 :func:`colour.recovery.jakob2019.error_function` with 

66 :func:`colour.sd_to_XYZ`, :func:`colour.XYZ_to_Lab` and checks if the 

67 error is computed correctly by comparing it with 

68 :func:`colour.difference.delta_E_CIE1976`. 

69 """ 

70 

71 # Quoted names refer to colours from ColorChecker N Ohta (using D65). 

72 coefficient_list = [ 

73 np.array([0, 0, 0]), # 50% gray 

74 np.array([0, 0, -1e9]), # Pure black 

75 np.array([0, 0, +1e9]), # Pure white 

76 np.array([1e9, -1e9, 2.1e8]), # A pathological example 

77 np.array([2.2667394, -7.6313081, 1.03185]), # 'blue' 

78 np.array([-31.377077, 26.810094, -6.1139927]), # 'green' 

79 np.array([25.064246, -16.072039, 0.10431365]), # 'red' 

80 np.array([-19.325667, 22.242319, -5.8144924]), # 'yellow' 

81 np.array([21.909902, -17.227963, 2.142351]), # 'magenta' 

82 np.array([-15.864009, 8.6735071, -1.4012552]), # 'cyan' 

83 ] 

84 

85 for coefficients in coefficient_list: 

86 error, _derror, R, XYZ, Lab = error_function( 

87 coefficients, 

88 self._Lab_e, 

89 self._cmfs, 

90 self._sd_D65, 

91 additional_data=True, 

92 ) 

93 

94 sd = sd_Jakob2019( 

95 dimensionalise_coefficients(coefficients, self._shape), 

96 self._shape, 

97 ) 

98 

99 sd_XYZ = sd_to_XYZ(sd, self._cmfs, self._sd_D65) / 100 

100 sd_Lab = XYZ_to_Lab(XYZ, self._xy_D65) 

101 error_reference = delta_E_CIE1976(self._Lab_e, Lab) 

102 

103 np.testing.assert_allclose(sd.values, R, atol=TOLERANCE_ABSOLUTE_TESTS) 

104 np.testing.assert_allclose(XYZ, sd_XYZ, atol=TOLERANCE_ABSOLUTE_TESTS) 

105 

106 assert abs(error_reference - error) < JND_CIE1976 / 100 

107 assert delta_E_CIE1976(Lab, sd_Lab) < JND_CIE1976 / 100 

108 

109 def test_derivatives(self) -> None: 

110 """ 

111 Test the gradients computed using closed-form expressions of the 

112 derivatives with finite difference approximations. 

113 """ 

114 

115 samples = np.linspace(-10, 10, 1000) 

116 ds = samples[1] - samples[0] 

117 

118 # Vary one coefficient at a time, keeping the others fixed to 1. 

119 for coefficient_i in range(3): 

120 errors = np.empty_like(samples) 

121 derrors = np.empty_like(samples) 

122 

123 for i, sample in enumerate(samples): 

124 coefficients = ones(3) 

125 coefficients[coefficient_i] = sample 

126 

127 error, derror = error_function( # pyright: ignore 

128 coefficients, self._Lab_e, self._cmfs, self._sd_D65 

129 ) 

130 

131 errors[i] = error 

132 derrors[i] = derror[coefficient_i] 

133 

134 staggered_derrors = (derrors[:-1] + derrors[1:]) / 2 

135 approximate_derrors = np.diff(errors) / ds 

136 

137 # The approximated derivatives aren't too accurate, so tolerances 

138 # have to be rather loose. 

139 np.testing.assert_allclose( 

140 staggered_derrors, approximate_derrors, atol=5e-3 

141 ) 

142 

143 

144class TestXYZ_to_sd_Jakob2019: 

145 """ 

146 Define :func:`colour.recovery.jakob2019.XYZ_to_sd_Jakob2019` definition 

147 unit tests methods. 

148 """ 

149 

150 def setup_method(self) -> None: 

151 """Initialise the common tests attributes.""" 

152 

153 self._shape = SPECTRAL_SHAPE_JAKOB2019 

154 self._cmfs, self._sd_D65 = handle_spectral_arguments(shape_default=self._shape) 

155 

156 def test_XYZ_to_sd_Jakob2019(self) -> None: 

157 """Test :func:`colour.recovery.jakob2019.XYZ_to_sd_Jakob2019` definition.""" 

158 

159 if not is_scipy_installed(): # pragma: no cover 

160 return 

161 

162 # Tests the round-trip with values of a colour checker. 

163 for name, sd in SDS_COLOURCHECKERS["ColorChecker N Ohta"].items(): 

164 XYZ = sd_to_XYZ(sd, self._cmfs, self._sd_D65) / 100 

165 

166 _recovered_sd, error = XYZ_to_sd_Jakob2019( 

167 XYZ, self._cmfs, self._sd_D65, additional_data=True 

168 ) 

169 

170 if error > JND_CIE1976 / 100: # pragma: no cover 

171 pytest.fail(f"Delta E for '{name}' is {error}!") 

172 

173 def test_domain_range_scale_XYZ_to_sd_Jakob2019(self) -> None: 

174 """ 

175 Test :func:`colour.recovery.jakob2019.XYZ_to_sd_Jakob2019` definition 

176 domain and range scale support. 

177 """ 

178 

179 if not is_scipy_installed(): # pragma: no cover 

180 return 

181 

182 XYZ_i = np.array([0.20654008, 0.12197225, 0.05136952]) 

183 XYZ_o = sd_to_XYZ( 

184 XYZ_to_sd_Jakob2019(XYZ_i, self._cmfs, self._sd_D65, additional_data=False), 

185 self._cmfs, 

186 self._sd_D65, 

187 ) 

188 

189 d_r = (("reference", 1, 1), ("1", 1, 0.01), ("100", 100, 1)) 

190 for scale, factor_a, factor_b in d_r: 

191 with domain_range_scale(scale): 

192 np.testing.assert_allclose( 

193 sd_to_XYZ( 

194 XYZ_to_sd_Jakob2019( 

195 XYZ_i * factor_a, 

196 self._cmfs, 

197 self._sd_D65, 

198 additional_data=False, 

199 ), 

200 self._cmfs, 

201 self._sd_D65, 

202 ), 

203 XYZ_o * factor_b, 

204 atol=TOLERANCE_ABSOLUTE_TESTS, 

205 ) 

206 

207 

208class TestLUT3D_Jakob2019: 

209 """ 

210 Define :class:`colour.recovery.jakob2019.LUT3D_Jakob2019` definition unit 

211 tests methods. 

212 """ 

213 

214 @classmethod 

215 def generate_LUT(cls: Type[TestLUT3D_Jakob2019]) -> LUT3D_Jakob2019: 

216 """ 

217 Generate the *LUT* used for the unit tests. 

218 

219 Notes 

220 ----- 

221 - This method is used to define the slow common tests attributes once 

222 rather than using the typical :meth:`unittest.TestCase.setUp` that 

223 is invoked once per-test. 

224 """ 

225 

226 if not hasattr(cls, "_LUT"): 

227 cls._shape = SPECTRAL_SHAPE_JAKOB2019 

228 cls._cmfs, cls._sd_D65 = handle_spectral_arguments(shape_default=cls._shape) 

229 cls._XYZ_D65 = sd_to_XYZ(cls._sd_D65) 

230 cls._xy_D65 = XYZ_to_xy(cls._XYZ_D65) 

231 

232 cls._RGB_colourspace = RGB_COLOURSPACE_sRGB 

233 

234 cls._LUT = LUT3D_Jakob2019() 

235 cls._LUT.generate(cls._RGB_colourspace, cls._cmfs, cls._sd_D65, 5) 

236 

237 return cls._LUT 

238 

239 def test_required_attributes(self) -> None: 

240 """Test the presence of required attributes.""" 

241 

242 required_attributes = ( 

243 "size", 

244 "lightness_scale", 

245 "coefficients", 

246 "interpolator", 

247 ) 

248 

249 for attribute in required_attributes: 

250 assert attribute in dir(LUT3D_Jakob2019) 

251 

252 def test_required_methods(self) -> None: 

253 """Test the presence of required methods.""" 

254 

255 required_methods = ( 

256 "__init__", 

257 "generate", 

258 "RGB_to_coefficients", 

259 "RGB_to_sd", 

260 "read", 

261 "write", 

262 ) 

263 

264 for method in required_methods: 

265 assert method in dir(LUT3D_Jakob2019) 

266 

267 def test_size(self) -> None: 

268 """Test :attr:`colour.recovery.jakob2019.LUT3D_Jakob2019.size` property.""" 

269 

270 if not is_scipy_installed(): # pragma: no cover 

271 return 

272 

273 assert TestLUT3D_Jakob2019.generate_LUT().size == 5 

274 

275 def test_lightness_scale(self) -> None: 

276 """ 

277 Test :attr:`colour.recovery.jakob2019.LUT3D_Jakob2019.lightness_scale` 

278 property. 

279 """ 

280 

281 np.testing.assert_allclose( 

282 TestLUT3D_Jakob2019.generate_LUT().lightness_scale, 

283 np.array([0.00000000, 0.06561279, 0.50000000, 0.93438721, 1.00000000]), 

284 atol=TOLERANCE_ABSOLUTE_TESTS, 

285 ) 

286 

287 def test_coefficients(self) -> None: 

288 """ 

289 Test :attr:`colour.recovery.jakob2019.LUT3D_Jakob2019.coefficients` 

290 property. 

291 """ 

292 

293 assert TestLUT3D_Jakob2019.generate_LUT().coefficients.shape == (3, 5, 5, 5, 3) 

294 

295 def test_interpolator(self) -> None: 

296 """ 

297 Test :attr:`colour.recovery.jakob2019.LUT3D_Jakob2019.interpolator` 

298 property. 

299 """ 

300 

301 if not is_scipy_installed(): # pragma: no cover 

302 return 

303 

304 from scipy.interpolate import RegularGridInterpolator # noqa: PLC0415 

305 

306 interpolator = TestLUT3D_Jakob2019.generate_LUT().interpolator 

307 assert isinstance(interpolator, RegularGridInterpolator) 

308 

309 def test_LUT3D_Jakob2019(self) -> None: 

310 """ 

311 Test the entirety of the 

312 :class:`colour.recovery.jakob2019.LUT3D_Jakob2019`class. 

313 """ 

314 

315 if not is_scipy_installed(): # pragma: no cover 

316 return 

317 

318 LUT = TestLUT3D_Jakob2019.generate_LUT() 

319 

320 temporary_directory = tempfile.mkdtemp() 

321 path = os.path.join(temporary_directory, "Test_Jakob2019.coeff") 

322 

323 try: 

324 LUT.write(path) 

325 LUT_t = LUT3D_Jakob2019().read(path) 

326 

327 np.testing.assert_allclose( 

328 LUT.lightness_scale, 

329 LUT_t.lightness_scale, 

330 atol=TOLERANCE_ABSOLUTE_TESTS, 

331 ) 

332 np.testing.assert_allclose( 

333 LUT.coefficients, 

334 LUT_t.coefficients, 

335 atol=1e-6, 

336 ) 

337 finally: 

338 shutil.rmtree(temporary_directory) 

339 

340 for RGB in [ 

341 np.array([1, 0, 0]), 

342 np.array([0, 1, 0]), 

343 np.array([0, 0, 1]), 

344 zeros(3), 

345 full(3, 0.5), 

346 ones(3), 

347 ]: 

348 XYZ = RGB_to_XYZ(RGB, self._RGB_colourspace, self._xy_D65) 

349 Lab = XYZ_to_Lab(XYZ, self._xy_D65) 

350 

351 recovered_sd = LUT.RGB_to_sd(RGB) 

352 recovered_XYZ = sd_to_XYZ(recovered_sd, self._cmfs, self._sd_D65) / 100 

353 recovered_Lab = XYZ_to_Lab(recovered_XYZ, self._xy_D65) 

354 

355 error = delta_E_CIE1976(Lab, recovered_Lab) 

356 

357 if error > 2 * JND_CIE1976 / 100: # pragma: no cover 

358 pytest.fail( 

359 f"Delta E for RGB={RGB} in colourspace " 

360 f"{self._RGB_colourspace.name} is {error}!" 

361 ) 

362 

363 def test_raise_exception_RGB_to_coefficients(self) -> None: 

364 """ 

365 Test :func:`colour.recovery.jakob2019.LUT3D_Jakob2019.\ 

366RGB_to_coefficients` method raised exception. 

367 """ 

368 

369 LUT = LUT3D_Jakob2019() 

370 

371 pytest.raises(RuntimeError, LUT.RGB_to_coefficients, np.array([1, 2, 3, 4])) 

372 

373 def test_raise_exception_read(self) -> None: 

374 """ 

375 Test :func:`colour.recovery.jakob2019.LUT3D_Jakob2019.read` method 

376 raised exception. 

377 """ 

378 

379 LUT = LUT3D_Jakob2019() 

380 pytest.raises(ValueError, LUT.read, __file__)