Coverage for colour/io/luts/operator.py: 100%

99 statements  

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

1""" 

2LUT Operator 

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

4 

5Define operator classes for Look-Up Table (LUT) transformations within the 

6colour processing pipeline. 

7 

8- :class:`colour.io.AbstractLUTSequenceOperator` 

9- :class:`colour.LUTOperatorMatrix` 

10""" 

11 

12from __future__ import annotations 

13 

14import typing 

15from abc import ABC, abstractmethod 

16 

17import numpy as np 

18 

19from colour.algebra import vecmul 

20 

21if typing.TYPE_CHECKING: 

22 from colour.hints import ( 

23 Any, 

24 ArrayLike, 

25 List, 

26 NDArrayFloat, 

27 Sequence, 

28 ) 

29 

30from colour.utilities import as_float_array, attest, is_iterable, ones, optional, 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 "AbstractLUTSequenceOperator", 

41 "LUTOperatorMatrix", 

42] 

43 

44 

45class AbstractLUTSequenceOperator(ABC): 

46 """ 

47 Define the base class for *LUT* sequence operators. 

48 

49 Provide an abstract base class that establishes the interface for all 

50 *LUT* sequence operator implementations. This :class:`ABCMeta` abstract 

51 class must be inherited by concrete sub-classes that implement specific 

52 operator functionality within *LUT* processing pipelines. 

53 

54 Parameters 

55 ---------- 

56 name 

57 *LUT* sequence operator name. 

58 comments 

59 Comments to add to the *LUT* sequence operator. 

60 

61 Attributes 

62 ---------- 

63 - :attr:`~colour.io.AbstractLUTSequenceOperator.name` 

64 - :attr:`~colour.io.AbstractLUTSequenceOperator.comments` 

65 

66 Methods 

67 ------- 

68 - :meth:`~colour.io.AbstractLUTSequenceOperator.apply` 

69 """ 

70 

71 def __init__( 

72 self, 

73 name: str | None = None, 

74 comments: Sequence[str] | None = None, 

75 ) -> None: 

76 self._name = f"LUT Sequence Operator {id(self)}" 

77 self.name = optional(name, self._name) 

78 self._comments: List[str] = [] 

79 self.comments = optional(comments, self._comments) 

80 

81 @property 

82 def name(self) -> str: 

83 """ 

84 Getter and setter for the *LUT* name. 

85 

86 Parameters 

87 ---------- 

88 value 

89 Value to set the *LUT* name with. 

90 

91 Returns 

92 ------- 

93 :class:`str` 

94 *LUT* name. 

95 """ 

96 

97 return self._name 

98 

99 @name.setter 

100 def name(self, value: str) -> None: 

101 """Setter for the **self.name** property.""" 

102 

103 attest( 

104 isinstance(value, str), 

105 f'"name" property: "{value}" type is not "str"!', 

106 ) 

107 

108 self._name = value 

109 

110 @property 

111 def comments(self) -> List[str]: 

112 """ 

113 Getter and setter for the *LUT* comments. 

114 

115 Parameters 

116 ---------- 

117 value 

118 Value to set the *LUT* comments with. 

119 

120 Returns 

121 ------- 

122 :class:`list` 

123 *LUT* comments. 

124 """ 

125 

126 return self._comments 

127 

128 @comments.setter 

129 def comments(self, value: Sequence[str]) -> None: 

130 """Setter for the **self.comments** property.""" 

131 

132 attest( 

133 is_iterable(value), 

134 f'"comments" property: "{value}" must be a sequence!', 

135 ) 

136 

137 self._comments = list(value) 

138 

139 @abstractmethod 

140 def apply(self, RGB: ArrayLike, *args: Any, **kwargs: Any) -> NDArrayFloat: 

141 """ 

142 Apply the *LUT* sequence operator to the specified *RGB* colourspace 

143 array. 

144 

145 Parameters 

146 ---------- 

147 RGB 

148 *RGB* colourspace array to apply the *LUT* sequence operator onto. 

149 

150 Other Parameters 

151 ---------------- 

152 args 

153 Arguments. 

154 kwargs 

155 Keywords arguments. 

156 

157 Returns 

158 ------- 

159 :class:`numpy.ndarray` 

160 Processed *RGB* colourspace array. 

161 """ 

162 

163 

164class LUTOperatorMatrix(AbstractLUTSequenceOperator): 

165 """ 

166 Define the *LUT* operator that applies matrix transformations and offset 

167 vectors for colour space conversions. 

168 

169 Support 3x3 or 4x4 matrix operations with optional offset vectors to 

170 perform affine transformations on *RGB* colourspace data. The operator 

171 internally reshapes matrices to 4x4 and offsets to 4-element vectors to 

172 maintain computational consistency. 

173 

174 Parameters 

175 ---------- 

176 matrix 

177 3x3 or 4x4 matrix for the operator. 

178 offset 

179 Offset for the operator. 

180 name 

181 *LUT* operator name. 

182 comments 

183 Comments to add to the *LUT* operator. 

184 

185 Attributes 

186 ---------- 

187 - :meth:`~colour.LUTOperatorMatrix.matrix` 

188 - :meth:`~colour.LUTOperatorMatrix.offset` 

189 

190 Methods 

191 ------- 

192 - :meth:`~colour.LUTOperatorMatrix.__str__` 

193 - :meth:`~colour.LUTOperatorMatrix.__repr__` 

194 - :meth:`~colour.LUTOperatorMatrix.__eq__` 

195 - :meth:`~colour.LUTOperatorMatrix.__ne__` 

196 - :meth:`~colour.LUTOperatorMatrix.apply` 

197 

198 Notes 

199 ----- 

200 - The internal :attr:`colour.io.Matrix.matrix` and 

201 :attr:`colour.io.Matrix.offset` properties are reshaped to (4, 4) and 

202 (4, ) respectively. 

203 

204 Examples 

205 -------- 

206 Instantiating an identity matrix: 

207 

208 >>> print(LUTOperatorMatrix(name="Identity")) 

209 LUTOperatorMatrix - Identity 

210 ---------------------------- 

211 <BLANKLINE> 

212 Matrix : [[ 1. 0. 0. 0.] 

213 [ 0. 1. 0. 0.] 

214 [ 0. 0. 1. 0.] 

215 [ 0. 0. 0. 1.]] 

216 Offset : [ 0. 0. 0. 0.] 

217 

218 Instantiating a matrix with comments: 

219 

220 >>> matrix = np.array( 

221 ... [ 

222 ... [1.45143932, -0.23651075, -0.21492857], 

223 ... [-0.07655377, 1.1762297, -0.09967593], 

224 ... [0.00831615, -0.00603245, 0.9977163], 

225 ... ] 

226 ... ) 

227 >>> print( 

228 ... LUTOperatorMatrix( 

229 ... matrix, 

230 ... name="AP0 to AP1", 

231 ... comments=["A first comment.", "A second comment."], 

232 ... ) 

233 ... ) 

234 LUTOperatorMatrix - AP0 to AP1 

235 ------------------------------ 

236 <BLANKLINE> 

237 Matrix : [[ 1.45143932 -0.23651075 -0.21492857 0. ] 

238 [-0.07655377 1.1762297 -0.09967593 0. ] 

239 [ 0.00831615 -0.00603245 0.9977163 0. ] 

240 [ 0. 0. 0. 1. ]] 

241 Offset : [ 0. 0. 0. 0.] 

242 <BLANKLINE> 

243 A first comment. 

244 A second comment. 

245 """ 

246 

247 def __init__( 

248 self, 

249 matrix: ArrayLike | None = None, 

250 offset: ArrayLike | None = None, 

251 *args: Any, 

252 **kwargs: Any, 

253 ) -> None: 

254 super().__init__(*args, **kwargs) 

255 

256 self._matrix: NDArrayFloat = np.diag(ones(4)) 

257 self.matrix = optional(matrix, self._matrix) 

258 self._offset: NDArrayFloat = zeros(4) 

259 self.offset = optional(offset, self._offset) 

260 

261 @property 

262 def matrix(self) -> NDArrayFloat: 

263 """ 

264 Getter and setter for the *LUT* operator matrix. 

265 

266 Parameters 

267 ---------- 

268 value 

269 Value to set the *LUT* operator matrix with. 

270 

271 Returns 

272 ------- 

273 :class:`numpy.ndarray` 

274 Operator matrix. 

275 """ 

276 

277 return self._matrix 

278 

279 @matrix.setter 

280 def matrix(self, value: ArrayLike) -> None: 

281 """Setter for the **self.matrix** property.""" 

282 

283 value = as_float_array(value) 

284 

285 shape_t = value.shape[-1] 

286 

287 value = np.reshape(value, (shape_t, shape_t)) 

288 

289 attest( 

290 value.shape in [(3, 3), (4, 4)], 

291 f'"matrix" property: "{value}" shape is not (3, 3) or (4, 4)!', 

292 ) 

293 

294 M = np.identity(4) 

295 M[:shape_t, :shape_t] = value 

296 

297 self._matrix = M 

298 

299 @property 

300 def offset(self) -> NDArrayFloat: 

301 """ 

302 Getter and setter for the *LUT* operator offset vector. 

303 

304 The offset vector is applied after the matrix transformation in the LUT 

305 operator, enabling translation operations in the colour space. 

306 

307 Parameters 

308 ---------- 

309 value 

310 Value to set the *LUT* operator offset with. 

311 

312 Returns 

313 ------- 

314 :class:`numpy.ndarray` 

315 Operator offset vector. 

316 """ 

317 

318 return self._offset 

319 

320 @offset.setter 

321 def offset(self, value: ArrayLike) -> None: 

322 """Setter for the **self.offset** property.""" 

323 

324 value = as_float_array(value) 

325 

326 shape_t = value.shape[-1] 

327 

328 attest( 

329 value.shape in [(3,), (4,)], 

330 f'"offset" property: "{value}" shape is not (3, ) or (4, )!', 

331 ) 

332 

333 offset = zeros(4) 

334 offset[:shape_t] = value 

335 

336 self._offset = offset 

337 

338 def __str__(self) -> str: 

339 """ 

340 Return a formatted string representation of the *LUT* operator. 

341 

342 Returns 

343 ------- 

344 :class:`str` 

345 Formatted string representation. 

346 

347 Examples 

348 -------- 

349 >>> print(LUTOperatorMatrix()) # doctest: +ELLIPSIS 

350 LUTOperatorMatrix - LUT Sequence Operator ... 

351 ------------------------------------------... 

352 <BLANKLINE> 

353 Matrix : [[ 1. 0. 0. 0.] 

354 [ 0. 1. 0. 0.] 

355 [ 0. 0. 1. 0.] 

356 [ 0. 0. 0. 1.]] 

357 Offset : [ 0. 0. 0. 0.] 

358 """ 

359 

360 def _format(a: ArrayLike) -> str: 

361 """Format specified array string representation.""" 

362 

363 return str(a).replace(" [", " " * 14 + "[") 

364 

365 comments = "\n".join(self._comments) 

366 comments = f"\n\n{comments}" if self._comments else "" 

367 

368 underline = "-" * (len(self.__class__.__name__) + 3 + len(self._name)) 

369 

370 return "\n".join( 

371 [ 

372 f"{self.__class__.__name__} - {self._name}", 

373 f"{underline}", 

374 "", 

375 f"Matrix : {_format(self._matrix)}", 

376 f"Offset : {_format(self._offset)}{comments}", 

377 ] 

378 ) 

379 

380 def __repr__(self) -> str: 

381 """ 

382 Return an evaluable string representation of the *LUT* operator. 

383 

384 Returns 

385 ------- 

386 :class:`str` 

387 Evaluable string representation. 

388 

389 Examples 

390 -------- 

391 >>> LUTOperatorMatrix(comments=["A first comment.", "A second comment."]) 

392 ... # doctest: +ELLIPSIS 

393 LUTOperatorMatrix([[ 1., 0., 0., 0.], 

394 [ 0., 1., 0., 0.], 

395 [ 0., 0., 1., 0.], 

396 [ 0., 0., 0., 1.]], 

397 [ 0., 0., 0., 0.], 

398 name='LUT Sequence Operator ...', 

399 comments=['A first comment.', 'A second comment.']) 

400 """ 

401 

402 representation = repr(self._matrix) 

403 representation = representation.replace("array", self.__class__.__name__) 

404 representation = representation.replace( 

405 " [", f"{' ' * (len(self.__class__.__name__) + 2)}[" 

406 ) 

407 

408 indentation = " " * (len(self.__class__.__name__) + 1) 

409 

410 comments = ( 

411 f",\n{indentation}comments={self._comments!r}" if self._comments else "" 

412 ) 

413 

414 return "\n".join( 

415 [ 

416 f"{representation[:-1]},", 

417 f"{indentation}" 

418 f"{repr(self._offset).replace('array(', '').replace(')', '')},", 

419 f"{indentation}name='{self._name}'{comments})", 

420 ] 

421 ) 

422 

423 __hash__ = None # pyright: ignore 

424 

425 def __eq__(self, other: object) -> bool: 

426 """ 

427 Determine whether the *LUT* operator is equal to the specified other 

428 object. 

429 

430 Parameters 

431 ---------- 

432 other 

433 Object to test whether it is equal to the *LUT* operator. 

434 

435 Returns 

436 ------- 

437 :class:`bool` 

438 Whether specified object equal to the *LUT* operator. 

439 

440 Examples 

441 -------- 

442 >>> LUTOperatorMatrix() == LUTOperatorMatrix() 

443 True 

444 """ 

445 

446 return isinstance(other, LUTOperatorMatrix) and all( 

447 [ 

448 np.array_equal(self._matrix, other._matrix), 

449 np.array_equal(self._offset, other._offset), 

450 ] 

451 ) 

452 

453 def __ne__(self, other: object) -> bool: 

454 """ 

455 Determine whether the *LUT* operator is not equal to the specified other 

456 object. 

457 

458 Parameters 

459 ---------- 

460 other 

461 Object to test whether it is not equal to the *LUT* operator. 

462 

463 Returns 

464 ------- 

465 :class:`bool` 

466 Whether the specified object is not equal to the *LUT* operator. 

467 

468 Examples 

469 -------- 

470 >>> LUTOperatorMatrix() != LUTOperatorMatrix( 

471 ... np.reshape(np.linspace(0, 1, 16), (4, 4)) 

472 ... ) 

473 True 

474 """ 

475 

476 return not (self == other) 

477 

478 def apply( 

479 self, 

480 RGB: ArrayLike, 

481 *args: Any, # noqa: ARG002 

482 **kwargs: Any, 

483 ) -> NDArrayFloat: 

484 """ 

485 Apply the *LUT* operator to the specified *RGB* array. 

486 

487 Parameters 

488 ---------- 

489 RGB 

490 *RGB* array to apply the *LUT* operator transform to. 

491 

492 Other Parameters 

493 ---------------- 

494 apply_offset_first 

495 Whether to apply the offset first and then the matrix. 

496 

497 Returns 

498 ------- 

499 :class:`numpy.ndarray` 

500 Transformed *RGB* array. 

501 

502 Examples 

503 -------- 

504 >>> matrix = np.array( 

505 ... [ 

506 ... [1.45143932, -0.23651075, -0.21492857], 

507 ... [-0.07655377, 1.1762297, -0.09967593], 

508 ... [0.00831615, -0.00603245, 0.9977163], 

509 ... ] 

510 ... ) 

511 >>> M = LUTOperatorMatrix(matrix) 

512 >>> RGB = np.array([0.3, 0.4, 0.5]) 

513 >>> M.apply(RGB) # doctest: +ELLIPSIS 

514 array([ 0.2333632..., 0.3976877..., 0.4989400...]) 

515 """ 

516 

517 RGB = as_float_array(RGB) 

518 apply_offset_first = kwargs.get("apply_offset_first", False) 

519 

520 has_alpha_channel = RGB.shape[-1] == 4 

521 M = self._matrix 

522 offset = self._offset 

523 

524 if not has_alpha_channel: 

525 M = M[:3, :3] 

526 offset = offset[:3] 

527 

528 if apply_offset_first: 

529 RGB += offset 

530 

531 RGB = vecmul(M, RGB) 

532 

533 if not apply_offset_first: 

534 RGB += offset 

535 

536 return RGB