Coverage for recovery/jakob2019.py: 77%

1"""

2 Jakob and Hanika (2019) - Reflectance Recovery

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

4

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

6*Jakob and Hanika (2019)* method.

7

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

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

10- :func:`colour.recovery.XYZ_to_sd_Jakob2019`

11- :class:`colour.recovery.LUT3D_Jakob2019`

12

13 References

14----------

15- :cite:`Jakob2019` : Jakob, W., & Hanika, J. (2019). A Low-Dimensional

16 Function Space for Efficient Spectral Upsampling. Computer Graphics Forum,

17 38(2), 147-155. doi:10.1111/cgf.13626

18"""

19

20 from __future__ import annotations

21

22 import struct

23 import typing

24

25 import numpy as np

26

27 from colour.algebra import smoothstep_function, spow

28 from colour.colorimetry import (

29 MultiSpectralDistributions,

30 SpectralDistribution,

31 SpectralShape,

32 handle_spectral_arguments,

33 intermediate_lightness_function_CIE1976,

34 sd_to_XYZ_integration,

35)

36 from colour.constants import DTYPE_INT_DEFAULT

37 from colour.difference import JND_CIE1976

38

39 if typing.TYPE_CHECKING:

40 from scipy.interpolate import RegularGridInterpolator

41

42 from colour.hints import (

43 Callable,

44 Literal,

45 PathLike,

46 Tuple,

47 )

48

49 from colour.hints import ArrayLike, Domain1, NDArrayFloat # noqa: TC001

50 from colour.models import RGB_Colourspace, RGB_to_XYZ, XYZ_to_Lab, XYZ_to_xy

51 from colour.utilities import (

52 as_float_array,

53 as_float_scalar,

54 domain_range_scale,

55 full,

56 index_along_last_axis,

57 is_tqdm_installed,

58 message_box,

59 optional,

60 required,

61 to_domain_1,

62 tsplit,

63 zeros,

64)

65

66 if is_tqdm_installed():

67 from tqdm import tqdm

68 else: # pragma: no cover

69 from unittest import mock

70

71 tqdm = mock.MagicMock()

72

73 __author__ = "Colour Developers"

74 __copyright__ = "Copyright 2013 Colour Developers"

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

76 __maintainer__ = "Colour Developers"

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

78 __status__ = "Production"

79

80 __all__ = [

81 "SPECTRAL_SHAPE_JAKOB2019",

82 "StopMinimizationEarlyError",

83 "sd_Jakob2019",

84 "error_function",

85 "dimensionalise_coefficients",

86 "lightness_scale",

87 "find_coefficients_Jakob2019",

88 "XYZ_to_sd_Jakob2019",

89 "LUT3D_Jakob2019",

90]

91

92 SPECTRAL_SHAPE_JAKOB2019: SpectralShape = SpectralShape(360, 780, 5)

93"""Spectral shape for *Jakob and Hanika (2019)* method."""

94

95

96 class StopMinimizationEarlyError(Exception):

97 """

98 Define an exception to halt :func:`scipy.optimize.minimize` when the

99 minimized function value becomes sufficiently small.

100

101 *SciPy* does not currently provide a native mechanism for early

102 termination based on function value thresholds.

103

104 Attributes

105 ----------

106 - :attr:`~colour.recovery.jakob2019.StopMinimizationEarlyError.coefficients`

107 - :attr:`~colour.recovery.jakob2019.StopMinimizationEarlyError.error`

108 """

109

110 def __init__(self, coefficients: ArrayLike, error: float) -> None:

111 self._coefficients = as_float_array(coefficients)

112 self._error = as_float_scalar(error)

113

114 @property

115 def coefficients(self) -> NDArrayFloat:

116 """

117 Getter for the *Jakob and Hanika (2019)* exception coefficients.

118

119 Returns

120 -------

121 :class:`numpy.ndarray`

122 *Jakob and Hanika (2019)* exception coefficients.

123 """

124

125 return self._coefficients

126

127 @property

128 def error(self) -> float:

129 """

130 Getter for the *Jakob and Hanika (2019)* spectral upsampling error

131 value.

132

133 Returns

134 -------

135 :class:`float`

136 *Jakob and Hanika (2019)* spectral upsampling error value

137 representing the quality of the coefficient fitting process.

138 """

139

140 return self._error

141

142

143 def sd_Jakob2019(

144 coefficients: ArrayLike, shape: SpectralShape = SPECTRAL_SHAPE_JAKOB2019

145) -> SpectralDistribution:

146 """

147 Generate a spectral distribution using the spectral model specified by

148 *Jakob and Hanika (2019)*.

149

150 Parameters

151 ----------

152 coefficients

153 Dimensionless coefficients for the *Jakob and Hanika (2019)*

154 reflectance spectral model.

155 shape

156 Shape used by the spectral distribution.

157

158 Returns

159 -------

160 :class:`colour.SpectralDistribution`

161 *Jakob and Hanika (2019)* spectral distribution.

162

163 References

164 ----------

165 :cite:`Jakob2019`

166

167 Examples

168 --------

169 >>> from colour.utilities import numpy_print_options

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

171 ... sd_Jakob2019([-9e-05, 8.5e-02, -20], SpectralShape(400, 700, 20))

172 ... # doctest: +ELLIPSIS

173 SpectralDistribution([[ 400. , 0.3143046...],

174 [ 420. , 0.4133320...],

175 [ 440. , 0.4880034...],

176 [ 460. , 0.5279562...],

177 [ 480. , 0.5319346...],

178 [ 500. , 0.5 ...],

179 [ 520. , 0.4326202...],

180 [ 540. , 0.3373544...],

181 [ 560. , 0.2353056...],

182 [ 580. , 0.1507665...],

183 [ 600. , 0.0931332...],

184 [ 620. , 0.0577434...],

185 [ 640. , 0.0367011...],

186 [ 660. , 0.0240879...],

187 [ 680. , 0.0163316...],

188 [ 700. , 0.0114118...]],

189 SpragueInterpolator,

190 {},

191 Extrapolator,

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

193 """

194

195 c_0, c_1, c_2 = as_float_array(coefficients)

196 wl = shape.wavelengths

197 U = c_0 * wl**2 + c_1 * wl + c_2

198 R = 1 / 2 + U / (2 * np.sqrt(1 + U**2))

199

200 name = f"{coefficients! r} (COEFF) - Jakob (2019)"

201

202 return SpectralDistribution(R, wl, name=name)

203

204

205@typing.overload

206 def error_function(

207 coefficients: ArrayLike,

208 target: ArrayLike,

209 cmfs: MultiSpectralDistributions,

210 illuminant: SpectralDistribution,

211 max_error: float | None = ...,

212 additional_data: Literal[True] = True,

213) -> Tuple[float, NDArrayFloat, NDArrayFloat, NDArrayFloat, NDArrayFloat]: ...

214@typing.overload

215 def error_function(

216 coefficients: ArrayLike,

217 target: ArrayLike,

218 cmfs: MultiSpectralDistributions,

219 illuminant: SpectralDistribution,

220 max_error: float | None = ...,

221 *,

222 additional_data: Literal[False],

223) -> Tuple[float, NDArrayFloat]: ...

224@typing.overload

225 def error_function(

226 coefficients: ArrayLike,

227 target: ArrayLike,

228 cmfs: MultiSpectralDistributions,

229 illuminant: SpectralDistribution,

230 max_error: float | None,

231 additional_data: Literal[False],

232) -> Tuple[float, NDArrayFloat]: ...

233 def error_function(

234 coefficients: ArrayLike,

235 target: ArrayLike,

236 cmfs: MultiSpectralDistributions,

237 illuminant: SpectralDistribution,

238 max_error: float | None = None,

239 additional_data: bool = False,

240) -> (

241 Tuple[float, NDArrayFloat]

242 | Tuple[float, NDArrayFloat, NDArrayFloat, NDArrayFloat, NDArrayFloat]

243):

244 """

245 Compute :math:`\\Delta E_{76}` between the target colour and the colour

246 defined by the specified spectral model, along with its gradient.

247

248 Parameters

249 ----------

250 coefficients

251 Dimensionless coefficients for *Jakob and Hanika (2019)*

252 reflectance spectral model.

253 target

254 *CIE L\\*a\\*b\\** colourspace array of the target colour.

255 cmfs

256 Standard observer colour matching functions.

257 illuminant

258 Illuminant spectral distribution.

259 max_error

260 Raise ``StopMinimizationEarlyError`` if the error is smaller than

261 this. The default is *None* and the function doesn't raise

262 anything.

263 additional_data

264 If *True*, some intermediate calculations are returned, for use in

265 correctness tests: R, XYZ and Lab.

266

267 Returns

268 -------

269 :class:`tuple` or :class:`tuple`

270 Tuple of computed :math:`\\Delta E_{76}` error and gradient of

271 error, i.e., the first derivatives of error with respect to the

272 input coefficients or tuple of computed :math:`\\Delta E_{76}`

273 error, gradient of error, computed spectral reflectance, *CIE XYZ*

274 tristimulus values corresponding to ``R`` and *CIE L\\*a\\*b\\**

275 colourspace array corresponding to ``XYZ``.

276

277 Raises

278 ------

279 StopMinimizationEarlyError

280 Raised when the error is below ``max_error``.

281 """

282

283 target = as_float_array(target)

284

285 c_0, c_1, c_2 = as_float_array(coefficients)

286 wv = np.linspace(0, 1, len(cmfs.shape))

287

288 U = c_0 * wv**2 + c_1 * wv + c_2

289 t1 = np.sqrt(1 + U**2)

290 R = 1 / 2 + U / (2 * t1)

291

292 t2 = 1 / (2 * t1) - U**2 / (2 * t1**3)

293 dR = np.array([wv**2 * t2, wv * t2, t2])

294

295 XYZ = sd_to_XYZ_integration(R, cmfs, illuminant, shape=cmfs.shape) / 100

296 dXYZ = np.transpose(

297 sd_to_XYZ_integration(dR, cmfs, illuminant, shape=cmfs.shape) / 100

298 )

299

300 XYZ_n = sd_to_XYZ_integration(illuminant, cmfs)

301 XYZ_n /= XYZ_n[1]

302 XYZ_XYZ_n = XYZ / XYZ_n

303

304 XYZ_f = intermediate_lightness_function_CIE1976(XYZ, XYZ_n)

305 dXYZ_f = np.where(

306 XYZ_XYZ_n[..., None] > (24 / 116) ** 3,

307 1 / (3 * spow(XYZ_n[..., None], 1 / 3) * spow(XYZ[..., None], 2 / 3)) * dXYZ,

308 (841 / 108) * dXYZ / XYZ_n[..., None],

309 )

310

311 def intermediate_XYZ_to_Lab(

312 XYZ_i: NDArrayFloat, offset: float | None = 16

313 ) -> NDArrayFloat:

314 """

315 Return the final intermediate value for the *CIE Lab* to *CIE XYZ*

316 conversion.

317 """

318

319 return np.array(

320 [

321 116 * XYZ_i[1] - offset,

322 500 * (XYZ_i[0] - XYZ_i[1]),

323 200 * (XYZ_i[1] - XYZ_i[2]),

324 ]

325 )

326

327 Lab_i = intermediate_XYZ_to_Lab(XYZ_f)

328 dLab_i = intermediate_XYZ_to_Lab(dXYZ_f, 0)

329

330 error = np.sqrt(np.sum((Lab_i - target) ** 2))

331 if max_error is not None and error <= max_error:

332 raise StopMinimizationEarlyError(coefficients, error)

333

334 derror = np.sum(dLab_i * (Lab_i[..., None] - target[..., None]), axis=0) / error

335

336 if additional_data:

337 return error, derror, R, XYZ, Lab_i

338

339 return error, derror

340

341

342 def dimensionalise_coefficients(

343 coefficients: ArrayLike, shape: SpectralShape

344) -> NDArrayFloat:

345 """

346 Rescale dimensionless coefficients to the specified spectral shape.

347

348 A dimensionless form of the reflectance spectral model is used in the

349 optimisation process. Instead of the usual spectral shape, specified in

350 nanometres, it is normalised to the [0, 1] range. A side effect is

351 that computed coefficients work only with the normalised range and

352 need to be rescaled to regain units and be compatible with standard

353 shapes.

354

355 Parameters

356 ----------

357 coefficients

358 Dimensionless coefficients.

359 shape

360 Spectral distribution shape used in calculations.

361

362 Returns

363 -------

364 :class:`numpy.ndarray`

365 Dimensionful coefficients, with units of

366 :math:`\\frac{1}{\\mathrm{nm}^2}`, :math:`\\frac{1}{\\mathrm{nm}}`

367 and 1, respectively.

368 """

369

370 cp_0, cp_1, cp_2 = tsplit(coefficients)

371 span = shape.end - shape.start

372

373 c_0 = cp_0 / span**2

374 c_1 = cp_1 / span - 2 * cp_0 * shape.start / span**2

375 c_2 = cp_0 * shape.start**2 / span**2 - cp_1 * shape.start / span + cp_2

376

377 return np.array([c_0, c_1, c_2])

378

379

380 def lightness_scale(steps: int) -> NDArrayFloat:

381 """

382 Generate a non-linear lightness scale as described in *Jakob and Hanika

383 (2019)*.

384

385 The scale reduces spacing between very dark and very bright (and

386 saturated) colours, providing finer resolution in regions where

387 coefficients change rapidly.

388

389 Parameters

390 ----------

391 steps

392 Number of samples along the non-linear lightness scale.

393

394 Returns

395 -------

396 :class:`numpy.ndarray`

397 Non-linear lightness scale array.

398

399 Examples

400 --------

401 >>> lightness_scale(5) # doctest: +ELLIPSIS

402 array([ 0. , 0.0656127..., 0.5 , 0.9343872..., \

403 1. ])

404 """

405

406 linear = np.linspace(0, 1, steps)

407

408 return smoothstep_function(smoothstep_function(linear))

409

410

411@required("SciPy")

412 def find_coefficients_Jakob2019(

413 XYZ: ArrayLike,

414 cmfs: MultiSpectralDistributions | None = None,

415 illuminant: SpectralDistribution | None = None,

416 coefficients_0: ArrayLike = (0, 0, 0),

417 max_error: float = JND_CIE1976 / 100,

418 dimensionalise: bool = True,

419) -> Tuple[NDArrayFloat, float]:

420 """

421 Find the coefficients for the *Jakob and Hanika (2019)* reflectance

422 spectral model.

423

424 Parameters

425 ----------

426 XYZ

427 *CIE XYZ* tristimulus values to find the coefficients for.

428 cmfs

429 Standard observer colour matching functions, default to the

430 *CIE 1931 2 Degree Standard Observer*.

431 illuminant

432 Illuminant spectral distribution, default to

433 *CIE Standard Illuminant D65*.

434 coefficients_0

435 Starting coefficients for the solver.

436 max_error

437 Maximal acceptable error. Set higher to save computational time.

438 If *None*, the solver will keep going until it is very close to

439 the minimum. The default is ``ACCEPTABLE_DELTA_E``.

440 dimensionalise

441 If *True*, returned coefficients are dimensionful and will not

442 work correctly if fed back as ``coefficients_0``. The default

443 is *True*.

444

445 Returns

446 -------

447 :class:`tuple`

448 Tuple of computed coefficients that best fit the specified

449 colour and :math:`\\Delta E_{76}` between the target colour and

450 the colour corresponding to the computed coefficients.

451

452 References

453 ----------

454 :cite:`Jakob2019`

455

456 Examples

457 --------

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

459 >>> find_coefficients_Jakob2019(XYZ) # doctest: +ELLIPSIS

460 (array([ 1.3723791...e-04, -1.3514399...e-01, 3.0838973...e+01]), \

461 0.0141941...)

462 """

463

464 from scipy.optimize import minimize # noqa: PLC0415

465

466 XYZ = as_float_array(XYZ)

467 coefficients_0 = as_float_array(coefficients_0)

468

469 cmfs, illuminant = handle_spectral_arguments(

470 cmfs, illuminant, shape_default=SPECTRAL_SHAPE_JAKOB2019

471 )

472

473 def optimize(

474 target_o: NDArrayFloat, coefficients_0_o: NDArrayFloat

475 ) -> Tuple[NDArrayFloat, float | np.float64]:

476 """Minimise the error function using *L-BFGS-B* method."""

477

478 try:

479 result = minimize(

480 error_function,

481 coefficients_0_o,

482 (target_o, cmfs, illuminant, max_error),

483 method="L-BFGS-B",

484 jac=True,

485 )

486 except StopMinimizationEarlyError as error:

487 return error.coefficients, error.error

488 else:

489 return result.x, result.fun

490

491 xy_n = XYZ_to_xy(sd_to_XYZ_integration(illuminant, cmfs))

492

493 XYZ_g = full(3, 0.5)

494 coefficients_g = zeros(3)

495

496 divisions = 3

497 while divisions < 10:

498 XYZ_r = XYZ_g

499 coefficient_r = coefficients_g

500 keep_divisions = False

501

502 coefficients_0 = coefficient_r

503 for i in range(1, divisions):

504 XYZ_i = (XYZ - XYZ_r) * i / (divisions - 1) + XYZ_r

505 Lab_i = XYZ_to_Lab(XYZ_i)

506

507 coefficients_0, error = optimize(Lab_i, coefficients_0)

508

509 if error > max_error:

510 break

511 XYZ_g = XYZ_i

512 coefficients_g = coefficients_0

513 keep_divisions = True

514 else:

515 break

516

517 if not keep_divisions:

518 divisions += 2

519

520 target = XYZ_to_Lab(XYZ, xy_n)

521 coefficients, error = optimize(target, coefficients_0)

522

523 if dimensionalise:

524 coefficients = dimensionalise_coefficients(coefficients, cmfs.shape)

525

526 return coefficients, float(error)

527

528

529@typing.overload

530 def XYZ_to_sd_Jakob2019(

531 XYZ: Domain1,

532 cmfs: MultiSpectralDistributions | None = ...,

533 illuminant: SpectralDistribution | None = ...,

534 optimisation_kwargs: dict | None = ...,

535 additional_data: Literal[True] = True,

536) -> Tuple[SpectralDistribution, float]: ...

537

538

539@typing.overload

540 def XYZ_to_sd_Jakob2019(

541 XYZ: Domain1,

542 cmfs: MultiSpectralDistributions | None = ...,

543 illuminant: SpectralDistribution | None = ...,

544 optimisation_kwargs: dict | None = ...,

545 *,

546 additional_data: Literal[False],

547) -> SpectralDistribution: ...

548

549

550@typing.overload

551 def XYZ_to_sd_Jakob2019(

552 XYZ: Domain1,

553 cmfs: MultiSpectralDistributions | None,

554 illuminant: SpectralDistribution | None,

555 optimisation_kwargs: dict | None,

556 additional_data: Literal[False],

557) -> SpectralDistribution: ...

558

559

560 def XYZ_to_sd_Jakob2019(

561 XYZ: Domain1,

562 cmfs: MultiSpectralDistributions | None = None,

563 illuminant: SpectralDistribution | None = None,

564 optimisation_kwargs: dict | None = None,

565 additional_data: bool = False,

566) -> Tuple[SpectralDistribution, float] | SpectralDistribution:

567 """

568 Recover the spectral distribution from the specified *CIE XYZ* tristimulus

569 values using *Jakob and Hanika (2019)* method.

570

571 Parameters

572 ----------

573 XYZ

574 *CIE XYZ* tristimulus values to recover the spectral distribution

575 from.

576 cmfs

577 Standard observer colour matching functions, default to the

578 *CIE 1931 2 Degree Standard Observer*.

579 illuminant

580 Illuminant spectral distribution, default to

581 *CIE Standard Illuminant D65*.

582 optimisation_kwargs

583 Parameters for :func:`colour.recovery.find_coefficients_Jakob2019`

584 definition.

585 additional_data

586 If *True*, ``error`` will be returned alongside the recovered

587 spectral distribution.

588

589 Returns

590 -------

591 :class:`tuple` or :class:`colour.SpectralDistribution`

592 Tuple of recovered spectral distribution and :math:`\\Delta E_{76}`

593 between the target colour and the colour corresponding to the

594 computed coefficients or recovered spectral distribution.

595

596 Notes

597 -----

598 +------------+-----------------------+---------------+

599 | **Domain** | **Scale - Reference** | **Scale - 1** |

600 +============+=======================+===============+

601 | ``XYZ`` | 1 | 1 |

602 +------------+-----------------------+---------------+

603

604 References

605 ----------

606 :cite:`Jakob2019`

607

608 Examples

609 --------

610 >>> from colour import (

611 ... CCS_ILLUMINANTS,

612 ... MSDS_CMFS,

613 ... SDS_ILLUMINANTS,

614 ... XYZ_to_sRGB,

615 ... )

616 >>> from colour.colorimetry import sd_to_XYZ_integration

617 >>> from colour.utilities import numpy_print_options # noqa: PLC0415

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

619 >>> cmfs = (

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

621 ... .copy()

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

623 ... )

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

625 >>> sd = XYZ_to_sd_Jakob2019(XYZ, cmfs, illuminant)

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

627 ... sd # doctest: +ELLIPSIS

628 SpectralDistribution([[ 360. , 0.4893773...],

629 [ 370. , 0.3258214...],

630 [ 380. , 0.2147792...],

631 [ 390. , 0.1482413...],

632 [ 400. , 0.1086169...],

633 [ 410. , 0.0841255...],

634 [ 420. , 0.0683114...],

635 [ 430. , 0.0577144...],

636 [ 440. , 0.0504267...],

637 [ 450. , 0.0453552...],

638 [ 460. , 0.0418520...],

639 [ 470. , 0.0395259...],

640 [ 480. , 0.0381430...],

641 [ 490. , 0.0375741...],

642 [ 500. , 0.0377685...],

643 [ 510. , 0.0387432...],

644 [ 520. , 0.0405871...],

645 [ 530. , 0.0434783...],

646 [ 540. , 0.0477225...],

647 [ 550. , 0.0538256...],

648 [ 560. , 0.0626314...],

649 [ 570. , 0.0755869...],

650 [ 580. , 0.0952675...],

651 [ 590. , 0.1264265...],

652 [ 600. , 0.1779272...],

653 [ 610. , 0.2649393...],

654 [ 620. , 0.4039779...],

655 [ 630. , 0.5832105...],

656 [ 640. , 0.7445440...],

657 [ 650. , 0.8499970...],

658 [ 660. , 0.9094792...],

659 [ 670. , 0.9425378...],

660 [ 680. , 0.9616376...],

661 [ 690. , 0.9732481...],

662 [ 700. , 0.9806562...],

663 [ 710. , 0.9855873...],

664 [ 720. , 0.9889903...],

665 [ 730. , 0.9914117...],

666 [ 740. , 0.9931801...],

667 [ 750. , 0.9945009...],

668 [ 760. , 0.9955066...],

669 [ 770. , 0.9962855...],

670 [ 780. , 0.9968976...]],

671 SpragueInterpolator,

672 {},

673 Extrapolator,

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

675 >>> sd_to_XYZ_integration(sd, cmfs, illuminant) / 100 # doctest: +ELLIPSIS

676 array([ 0.2066217..., 0.1220128..., 0.0513958...])

677 """

678

679 XYZ = to_domain_1(XYZ)

680

681 cmfs, illuminant = handle_spectral_arguments(

682 cmfs, illuminant, shape_default=SPECTRAL_SHAPE_JAKOB2019

683 )

684

685 optimisation_kwargs = optional(optimisation_kwargs, {})

686

687 with domain_range_scale("ignore"):

688 coefficients, error = find_coefficients_Jakob2019(

689 XYZ, cmfs, illuminant, **optimisation_kwargs

690 )

691

692 sd = sd_Jakob2019(coefficients, cmfs.shape)

693 sd.name = f"{XYZ} (XYZ) - Jakob (2019)"

694

695 if additional_data:

696 return sd, error

697

698 return sd

699

700

701 class LUT3D_Jakob2019:

702 r"""

703 Define a class for working with pre-computed lookup tables for the

704 *Jakob and Hanika (2019)* spectral upsampling method. This class

705 enables significant time savings by performing expensive numerical

706 optimisation ahead of time and storing the results in a file.

707

708 The file format is compatible with the code and *\*.coeff* files in the

709 supplemental material published alongside the article. These files are

710 directly available from

711 `Colour - Datasets <https://github.com/colour-science/colour-datasets>`__

712 under the record *4050598*.

713

714 Attributes

715 ----------

716 - :attr:`~colour.recovery.LUT3D_Jakob2019.size`

717 - :attr:`~colour.recovery.LUT3D_Jakob2019.lightness_scale`

718 - :attr:`~colour.recovery.LUT3D_Jakob2019.coefficients`

719 - :attr:`~colour.recovery.LUT3D_Jakob2019.interpolator`

720

721 Methods

722 -------

723 - :meth:`~colour.recovery.LUT3D_Jakob2019.__init__`

724 - :meth:`~colour.recovery.LUT3D_Jakob2019.generate`

725 - :meth:`~colour.recovery.LUT3D_Jakob2019.RGB_to_coefficients`

726 - :meth:`~colour.recovery.LUT3D_Jakob2019.RGB_to_sd`

727 - :meth:`~colour.recovery.LUT3D_Jakob2019.read`

728 - :meth:`~colour.recovery.LUT3D_Jakob2019.write`

729

730 References

731 ----------

732 :cite:`Jakob2019`

733

734 Examples

735 --------

736 >>> import os

737 >>> import colour

738 >>> from colour import CCS_ILLUMINANTS, SDS_ILLUMINANTS, MSDS_CMFS

739 >>> from colour.colorimetry import sd_to_XYZ_integration

740 >>> from colour.models import RGB_COLOURSPACE_sRGB

741 >>> from colour.utilities import numpy_print_options

742 >>> cmfs = (

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

744 ... .copy()

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

746 ... )

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

748 >>> LUT = LUT3D_Jakob2019()

749 >>> LUT.generate(RGB_COLOURSPACE_sRGB, cmfs, illuminant, 3, lambda x: x)

750 >>> path = os.path.join(

751 ... colour.__path__[0],

752 ... "recovery",

753 ... "tests",

754 ... "resources",

755 ... "sRGB_Jakob2019.coeff",

756 ... )

757 >>> LUT.write(path) # doctest: +SKIP

758 >>> LUT.read(path) # doctest: +SKIP

759 >>> RGB = np.array([0.70573936, 0.19248266, 0.22354169])

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

761 ... LUT.RGB_to_sd(RGB, cmfs.shape) # doctest: +ELLIPSIS

762 SpectralDistribution([[ 360. , 0.7666803...],

763 [ 370. , 0.6251547...],

764 [ 380. , 0.4584310...],

765 [ 390. , 0.3161633...],

766 [ 400. , 0.2196155...],

767 [ 410. , 0.1596575...],

768 [ 420. , 0.1225525...],

769 [ 430. , 0.0989784...],

770 [ 440. , 0.0835782...],

771 [ 450. , 0.0733535...],

772 [ 460. , 0.0666049...],

773 [ 470. , 0.0623569...],

774 [ 480. , 0.06006 ...],

775 [ 490. , 0.0594383...],

776 [ 500. , 0.0604201...],

777 [ 510. , 0.0631195...],

778 [ 520. , 0.0678648...],

779 [ 530. , 0.0752834...],

780 [ 540. , 0.0864790...],

781 [ 550. , 0.1033773...],

782 [ 560. , 0.1293883...],

783 [ 570. , 0.1706018...],

784 [ 580. , 0.2374178...],

785 [ 590. , 0.3439472...],

786 [ 600. , 0.4950548...],

787 [ 610. , 0.6604253...],

788 [ 620. , 0.7914669...],

789 [ 630. , 0.8738724...],

790 [ 640. , 0.9213216...],

791 [ 650. , 0.9486880...],

792 [ 660. , 0.9650550...],

793 [ 670. , 0.9752838...],

794 [ 680. , 0.9819499...],

795 [ 690. , 0.9864585...],

796 [ 700. , 0.9896073...],

797 [ 710. , 0.9918680...],

798 [ 720. , 0.9935302...],

799 [ 730. , 0.9947778...],

800 [ 740. , 0.9957312...],

801 [ 750. , 0.9964714...],

802 [ 760. , 0.9970543...],

803 [ 770. , 0.9975190...],

804 [ 780. , 0.9978936...]],

805 SpragueInterpolator,

806 {},

807 Extrapolator,

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

809 """

810

811 def __init__(self) -> None:

812 from scipy.interpolate import RegularGridInterpolator # noqa: PLC0415

813

814 self._interpolator = RegularGridInterpolator((), np.array([]))

815

816 self._size: int = 0

817 self._lightness_scale: NDArrayFloat = np.array([])

818 self._coefficients: NDArrayFloat = np.array([])

819

820 @property

821 def size(self) -> int:

822 """

823 Getter for the *Jakob and Hanika (2019)* interpolator size.

824

825 The size represents the sample count on one side of the 3D lookup

826 table used for spectral upsampling.

827

828 Returns

829 -------

830 :class:`int`

831 *Jakob and Hanika (2019)* interpolator size.

832 """

833

834 return self._size

835

836 @property

837 def lightness_scale(self) -> NDArrayFloat:

838 """

839 Getter for the *Jakob and Hanika (2019)* interpolator lightness

840 scale.

841

842 Returns

843 -------

844 :class:`numpy.ndarray`

845 *Jakob and Hanika (2019)* interpolator lightness scale.

846 """

847

848 return self._lightness_scale

849

850 @property

851 def coefficients(self) -> NDArrayFloat:

852 """

853 Getter for the *Jakob and Hanika (2019)* interpolator coefficients.

854

855 Returns

856 -------

857 :class:`numpy.ndarray`

858 *Jakob and Hanika (2019)* interpolator coefficients.

859 """

860

861 return self._coefficients

862

863 @property

864 def interpolator(self) -> RegularGridInterpolator:

865 """

866 Getter for the *Jakob and Hanika (2019)* interpolator.

867

868 Returns

869 -------

870 :class:`scipy.interpolate.RegularGridInterpolator`

871 *Jakob and Hanika (2019)* interpolator.

872 """

873

874 return self._interpolator

875

876 def _create_interpolator(self) -> None:

877 """

878 Create a :class:`scipy.interpolate.RegularGridInterpolator` class

879 instance for read or generated coefficients.

880 """

881

882 from scipy.interpolate import RegularGridInterpolator # noqa: PLC0415

883

884 samples = np.linspace(0, 1, self._size)

885 axes = ([0, 1, 2], self._lightness_scale, samples, samples)

886

887 self._interpolator = RegularGridInterpolator(

888 axes, self._coefficients, bounds_error=False

889 )

890

891 def generate(

892 self,

893 colourspace: RGB_Colourspace,

894 cmfs: MultiSpectralDistributions | None = None,

895 illuminant: SpectralDistribution | None = None,

896 size: int = 64,

897 print_callable: Callable = print,

898 ) -> None:

899 """

900 Generate the lookup table data for the specified *RGB* colourspace,

901 colour matching functions, illuminant and resolution.

902

903 Parameters

904 ----------

905 colourspace

906 The *RGB* colourspace to create a lookup table for.

907 cmfs

908 Standard observer colour matching functions, default to the

909 *CIE 1931 2 Degree Standard Observer*.

910 illuminant

911 Illuminant spectral distribution, default to

912 *CIE Standard Illuminant D65*.

913 size

914 The resolution of the lookup table. Higher values will decrease

915 errors but at the cost of a much longer run time. The published

916 *\\*.coeff* files have a resolution of 64.

917 print_callable

918 Callable used to print progress and diagnostic information.

919

920 Examples

921 --------

922 >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS

923 >>> from colour.models import RGB_COLOURSPACE_sRGB

924 >>> from colour.utilities import numpy_print_options

925 >>> cmfs = (

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

927 ... .copy()

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

929 ... )

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

931 >>> LUT = LUT3D_Jakob2019()

932 >>> print(LUT.interpolator) # doctest: +ELLIPSIS

933 <scipy...RegularGridInterpolator object at 0x...>

934 >>> LUT.generate(RGB_COLOURSPACE_sRGB, cmfs, illuminant, 3)

935 ======================================================================\

936=========

937 * \

938 *

939 * "Jakob et al. (2018)" LUT Optimisation \

940 *

941 * \

942 *

943 ======================================================================\

944=========

945 <BLANKLINE>

946 Optimising 27 coefficients...

947 <BLANKLINE>

948 >>> print(LUT.interpolator)

949 ... # doctest: +ELLIPSIS

950 <scipy.interpolate...RegularGridInterpolator object at 0x...>

951 """

952

953 cmfs, illuminant = handle_spectral_arguments(

954 cmfs, illuminant, shape_default=SPECTRAL_SHAPE_JAKOB2019

955 )

956 shape = cmfs.shape

957

958 xy_n = XYZ_to_xy(sd_to_XYZ_integration(illuminant, cmfs))

959

960 # It could be interesting to have different resolutions for lightness

961 # and chromaticity, but the current file format doesn't allow it.

962 lightness_steps = size

963 chroma_steps = size

964

965 self._lightness_scale = lightness_scale(lightness_steps)

966 self._coefficients = np.empty(

967 [3, chroma_steps, chroma_steps, lightness_steps, 3]

968 )

969

970 cube_indexes = np.ndindex(3, chroma_steps, chroma_steps)

971 total_coefficients = chroma_steps**2 * 3

972

973 # First, create a list of all the fully bright colours with the order

974 # matching cube_indexes.

975 samples = np.linspace(0, 1, chroma_steps)

976 ij = np.reshape(

977 np.transpose(np.meshgrid([1], samples, samples, indexing="ij")),

978 (-1, 3),

979 )

980 chromas = np.concatenate(

981 [

982 ij,

983 np.roll(ij, 1, axis=1),

984 np.roll(ij, 2, axis=1),

985 ]

986 )

987

988 message_box(

989 '"Jakob et al. (2018)" LUT Optimisation',

990 print_callable=print_callable,

991 )

992

993 print_callable(f"\nOptimising {total_coefficients} coefficients...\n")

994

995 def optimize(

996 ijkL: ArrayLike, coefficients_0: ArrayLike, chroma: NDArrayFloat

997 ) -> NDArrayFloat:

998 """

999 Solve for a specific lightness and stores the result in the

1000 appropriate cell.

1001 """

1002

1003 i, j, k, L = tsplit(ijkL, dtype=DTYPE_INT_DEFAULT)

1004

1005 RGB = self._lightness_scale[L] * chroma

1006

1007 XYZ = RGB_to_XYZ(RGB, colourspace, xy_n)

1008

1009 coefficients, _error = find_coefficients_Jakob2019(

1010 XYZ, cmfs, illuminant, coefficients_0, dimensionalise=False

1011 )

1012

1013 self._coefficients[i, L, j, k, :] = dimensionalise_coefficients(

1014 coefficients, shape

1015 )

1016

1017 return coefficients

1018

1019 with tqdm(total=total_coefficients) as progress:

1020 for ijk, chroma in zip(cube_indexes, chromas, strict=True):

1021 progress.update()

1022

1023 # Starts from somewhere in the middle, similarly to how

1024 # feedback works in "colour.recovery.\

1025 # find_coefficients_Jakob2019" definition.

1026 L_middle = lightness_steps // 3

1027 coefficients_middle = optimize(

1028 np.hstack([ijk, L_middle]), zeros(3), chroma

1029 )

1030

1031 # Down the lightness scale.

1032 coefficients_0 = coefficients_middle

1033 for L in reversed(range(L_middle)):

1034 coefficients_0 = optimize(

1035 np.hstack([ijk, L]), coefficients_0, chroma

1036 )

1037

1038 # Up the lightness scale.

1039 coefficients_0 = coefficients_middle

1040 for L in range(L_middle + 1, lightness_steps):

1041 coefficients_0 = optimize(

1042 np.hstack([ijk, L]), coefficients_0, chroma

1043 )

1044

1045 self._size = size

1046 self._create_interpolator()

1047

1048 def RGB_to_coefficients(self, RGB: ArrayLike) -> NDArrayFloat:

1049 """

1050 Look up the specified *RGB* colourspace array and return the

1051 corresponding coefficients.

1052

1053 Interpolation is used for colours not on the table grid.

1054

1055 Parameters

1056 ----------

1057 RGB

1058 *RGB* colourspace array.

1059

1060 Returns

1061 -------

1062 :class:`numpy.ndarray`

1063 Corresponding coefficients that can be passed to

1064 :func:`colour.recovery.jakob2019.sd_Jakob2019` to obtain a

1065 spectral distribution.

1066

1067 Raises

1068 ------

1069 RuntimeError

1070 If the pre-computed lookup table has not been generated or read.

1071

1072 Examples

1073 --------

1074 >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS

1075 >>> from colour.models import RGB_COLOURSPACE_sRGB

1076 >>> cmfs = (

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

1078 ... .copy()

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

1080 ... )

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

1082 >>> LUT = LUT3D_Jakob2019()

1083 >>> LUT.generate(RGB_COLOURSPACE_sRGB, cmfs, illuminant, 3, lambda x: x)

1084 >>> RGB = np.array([0.70573936, 0.19248266, 0.22354169])

1085 >>> LUT.RGB_to_coefficients(RGB) # doctest: +ELLIPSIS

1086 array([ 1.5013448...e-04, -1.4679754...e-01, 3.4020219...e+01])

1087 """

1088

1089 if len(self._interpolator.grid) != 0:

1090 RGB = as_float_array(RGB)

1091

1092 value_max = np.max(RGB, axis=-1)

1093 chroma = RGB / (value_max[..., None] + 1e-10)

1094

1095 i_m = np.argmax(RGB, axis=-1)

1096 i_1 = index_along_last_axis(RGB, i_m)

1097 i_2 = index_along_last_axis(chroma, (i_m + 2) % 3)

1098 i_3 = index_along_last_axis(chroma, (i_m + 1) % 3)

1099

1100 indexes = np.stack([i_m, i_1, i_2, i_3], axis=-1)

1101

1102 return self._interpolator(indexes).squeeze()

1103

1104 error = "The pre-computed lookup table has not been read or generated!"

1105

1106 raise RuntimeError(error)

1107

1108 def RGB_to_sd(

1109 self, RGB: ArrayLike, shape: SpectralShape = SPECTRAL_SHAPE_JAKOB2019

1110 ) -> SpectralDistribution:

1111 """

1112 Look up a specified *RGB* colourspace array and return the

1113 corresponding spectral distribution.

1114

1115 Parameters

1116 ----------

1117 RGB

1118 *RGB* colourspace array.

1119 shape

1120 Shape used by the spectral distribution.

1121

1122 Returns

1123 -------

1124 :class:`colour.SpectralDistribution`

1125 Spectral distribution corresponding with the *RGB* colourspace

1126 array.

1127

1128 Examples

1129 --------

1130 >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS

1131 >>> from colour.models import RGB_COLOURSPACE_sRGB

1132 >>> from colour.utilities import numpy_print_options

1133 >>> cmfs = (

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

1135 ... .copy()

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

1137 ... )

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

1139 >>> LUT = LUT3D_Jakob2019()

1140 >>> LUT.generate(RGB_COLOURSPACE_sRGB, cmfs, illuminant, 3, lambda x: x)

1141 >>> RGB = np.array([0.70573936, 0.19248266, 0.22354169])

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

1143 ... LUT.RGB_to_sd(RGB, cmfs.shape) # doctest: +ELLIPSIS

1144 SpectralDistribution([[ 360. , 0.7666803...],

1145 [ 370. , 0.6251547...],

1146 [ 380. , 0.4584310...],

1147 [ 390. , 0.3161633...],

1148 [ 400. , 0.2196155...],

1149 [ 410. , 0.1596575...],

1150 [ 420. , 0.1225525...],

1151 [ 430. , 0.0989784...],

1152 [ 440. , 0.0835782...],

1153 [ 450. , 0.0733535...],

1154 [ 460. , 0.0666049...],

1155 [ 470. , 0.0623569...],

1156 [ 480. , 0.06006 ...],

1157 [ 490. , 0.0594383...],

1158 [ 500. , 0.0604201...],

1159 [ 510. , 0.0631195...],

1160 [ 520. , 0.0678648...],

1161 [ 530. , 0.0752834...],

1162 [ 540. , 0.0864790...],

1163 [ 550. , 0.1033773...],

1164 [ 560. , 0.1293883...],

1165 [ 570. , 0.1706018...],

1166 [ 580. , 0.2374178...],

1167 [ 590. , 0.3439472...],

1168 [ 600. , 0.4950548...],

1169 [ 610. , 0.6604253...],

1170 [ 620. , 0.7914669...],

1171 [ 630. , 0.8738724...],

1172 [ 640. , 0.9213216...],

1173 [ 650. , 0.9486880...],

1174 [ 660. , 0.9650550...],

1175 [ 670. , 0.9752838...],

1176 [ 680. , 0.9819499...],

1177 [ 690. , 0.9864585...],

1178 [ 700. , 0.9896073...],

1179 [ 710. , 0.9918680...],

1180 [ 720. , 0.9935302...],

1181 [ 730. , 0.9947778...],

1182 [ 740. , 0.9957312...],

1183 [ 750. , 0.9964714...],

1184 [ 760. , 0.9970543...],

1185 [ 770. , 0.9975190...],

1186 [ 780. , 0.9978936...]],

1187 SpragueInterpolator,

1188 {},

1189 Extrapolator,

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

1191 """

1192

1193 sd = sd_Jakob2019(self.RGB_to_coefficients(RGB), shape)

1194 sd.name = f"{RGB! r} (RGB) - Jakob (2019)"

1195

1196 return sd

1197

1198 def read(self, path: str | PathLike) -> LUT3D_Jakob2019:

1199 """

1200 Load a lookup table from a *\\*.coeff* file.

1201

1202 Parameters

1203 ----------

1204 path

1205 Path to the file.

1206

1207 Returns

1208 -------

1209 LUT3D_Jakob2019

1210 *Jakob and Hanika (2019)* lookup table.

1211

1212 Examples

1213 --------

1214 >>> import os

1215 >>> import colour

1216 >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS

1217 >>> from colour.models import RGB_COLOURSPACE_sRGB

1218 >>> from colour.utilities import numpy_print_options

1219 >>> cmfs = (

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

1221 ... .copy()

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

1223 ... )

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

1225 >>> LUT = LUT3D_Jakob2019()

1226 >>> LUT.generate(RGB_COLOURSPACE_sRGB, cmfs, illuminant, 3, lambda x: x)

1227 >>> path = os.path.join(

1228 ... colour.__path__[0],

1229 ... "recovery",

1230 ... "tests",

1231 ... "resources",

1232 ... "sRGB_Jakob2019.coeff",

1233 ... )

1234 >>> LUT.write(path) # doctest: +SKIP

1235 >>> LUT.read(path) # doctest: +SKIP

1236 """

1237

1238 path = str(path)

1239

1240 with open(path, "rb") as coeff_file:

1241 if coeff_file.read(4).decode("ISO-8859-1") != "SPEC":

1242 error = "Bad magic number, this is likely not the right file type!"

1243

1244 raise ValueError(error)

1245

1246 self._size = struct.unpack("i", coeff_file.read(4))[0]

1247 self._lightness_scale = np.fromfile(

1248 coeff_file, count=self._size, dtype=np.float32

1249 )

1250 self._coefficients = np.fromfile(

1251 coeff_file, count=3 * (self._size**3) * 3, dtype=np.float32

1252 )

1253 self._coefficients = np.reshape(

1254 self._coefficients, (3, self._size, self._size, self._size, 3)

1255 )

1256

1257 self._create_interpolator()

1258

1259 return self

1260

1261 def write(self, path: str | PathLike) -> bool:

1262 """

1263 Write the lookup table to a *\\*.coeff* file.

1264

1265 Parameters

1266 ----------

1267 path

1268 Path to the file.

1269

1270 Returns

1271 -------

1272 :class:`bool`

1273 Definition success.

1274

1275 Examples

1276 --------

1277 >>> import os

1278 >>> import colour

1279 >>> from colour import MSDS_CMFS, SDS_ILLUMINANTS

1280 >>> from colour.models import RGB_COLOURSPACE_sRGB

1281 >>> from colour.utilities import numpy_print_options

1282 >>> cmfs = (

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

1284 ... .copy()

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

1286 ... )

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

1288 >>> LUT = LUT3D_Jakob2019()

1289 >>> LUT.generate(RGB_COLOURSPACE_sRGB, cmfs, illuminant, 3, lambda x: x)

1290 >>> path = os.path.join(

1291 ... colour.__path__[0],

1292 ... "recovery",

1293 ... "tests",

1294 ... "resources",

1295 ... "sRGB_Jakob2019.coeff",

1296 ... )

1297 >>> LUT.write(path) # doctest: +SKIP

1298 >>> LUT.read(path) # doctest: +SKIP

1299 """

1300

1301 path = str(path)

1302

1303 with open(path, "wb") as coeff_file:

1304 coeff_file.write(b"SPEC")

1305 coeff_file.write(struct.pack("i", self._coefficients.shape[1]))

1306 np.float32(self._lightness_scale).tofile(coeff_file)

1307 np.float32(self._coefficients).tofile(coeff_file)

1308

1309 return True