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

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

2

3 from __future__ import annotations

4

5 import os

6 import shutil

7 import tempfile

8 import typing

9

10 import numpy as np

11 import pytest

12

13 from colour.characterisation import SDS_COLOURCHECKERS

14 from colour.colorimetry import handle_spectral_arguments, sd_to_XYZ

15 from colour.constants import TOLERANCE_ABSOLUTE_TESTS

16 from colour.difference import JND_CIE1976, delta_E_CIE1976

17

18 if typing.TYPE_CHECKING:

19 from colour.hints import Type

20

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

22 from 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)

30 from 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

46 class 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

144 class 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

208 class 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.\

366 RGB_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__)