Coverage for recovery/jakob2019.py: 77%

221 statements  

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

1""" 

2Jakob and Hanika (2019) - Reflectance Recovery 

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

4 

5Define 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 

13References 

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 

20from __future__ import annotations 

21 

22import struct 

23import typing 

24 

25import numpy as np 

26 

27from colour.algebra import smoothstep_function, spow 

28from colour.colorimetry import ( 

29 MultiSpectralDistributions, 

30 SpectralDistribution, 

31 SpectralShape, 

32 handle_spectral_arguments, 

33 intermediate_lightness_function_CIE1976, 

34 sd_to_XYZ_integration, 

35) 

36from colour.constants import DTYPE_INT_DEFAULT 

37from colour.difference import JND_CIE1976 

38 

39if 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 

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

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

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

66if is_tqdm_installed(): 

67 from tqdm import tqdm 

68else: # 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 

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

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

94 

95 

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

143def 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 

206def 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 

215def 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 

225def 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]: ... 

233def 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 

342def 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 

380def 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..., \ 

4031. ]) 

404 """ 

405 

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

407 

408 return smoothstep_function(smoothstep_function(linear)) 

409 

410 

411@required("SciPy") 

412def 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]), \ 

4610.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 

530def 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 

540def 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 

551def 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 

560def 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 

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