Coverage for colour/io/ctl.py: 100%

65 statements  

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

1""" 

2CTL Processing 

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

4 

5Define objects and functions for *Color Transformation Language* (CTL) 

6processing, enabling programmatic colour transformations through the Academy 

7Color Encoding System (ACES) CTL interpreter. 

8 

9- :func:`colour.io.ctl_render` 

10- :func:`colour.io.process_image_ctl` 

11- :func:`colour.io.template_ctl_transform_float` 

12- :func:`colour.io.template_ctl_transform_float3` 

13""" 

14 

15from __future__ import annotations 

16 

17import os 

18import subprocess 

19import tempfile 

20import textwrap 

21import typing 

22 

23import numpy as np 

24 

25if typing.TYPE_CHECKING: 

26 from colour.hints import ( 

27 Any, 

28 ArrayLike, 

29 Dict, 

30 NDArrayFloat, 

31 PathLike, 

32 ) 

33 

34from colour.hints import Sequence 

35from colour.io import as_3_channels_image, read_image, write_image 

36from colour.utilities import as_float, as_float_array, optional, required 

37 

38__author__ = "Colour Developers" 

39__copyright__ = "Copyright 2013 Colour Developers" 

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

41__maintainer__ = "Colour Developers" 

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

43__status__ = "Production" 

44 

45__all__ = [ 

46 "EXECUTABLE_CTL_RENDER", 

47 "ARGUMENTS_CTL_RENDER_DEFAULTS", 

48 "ctl_render", 

49 "process_image_ctl", 

50 "template_ctl_transform_float", 

51 "template_ctl_transform_float3", 

52] 

53 

54EXECUTABLE_CTL_RENDER: str = "ctlrender" 

55""" 

56*ctlrender* executable name. 

57""" 

58 

59ARGUMENTS_CTL_RENDER_DEFAULTS: tuple = ("-verbose", "-force") 

60""" 

61*ctlrender* invocation default arguments. 

62""" 

63 

64 

65# TODO: Reinstate coverage when "ctlrender" is trivially available 

66# cross-platform. 

67 

68 

69@required("ctlrender") 

70def ctl_render( 

71 path_input: str | PathLike, 

72 path_output: str | PathLike, 

73 ctl_transforms: Sequence[str] | Dict[str, Sequence[str]], 

74 *args: Any, 

75 **kwargs: Any, 

76) -> subprocess.CompletedProcess: # pragma: no cover 

77 """ 

78 Invoke *ctlrender* on the specified input image using the specified 

79 *CTL* transforms. 

80 

81 Parameters 

82 ---------- 

83 path_input 

84 Input image path. 

85 path_output 

86 Output image path. 

87 ctl_transforms 

88 Sequence of *CTL* transforms to apply on the image, either paths to 

89 existing *CTL* transforms, multi-line *CTL* code transforms or a mix 

90 of both, or dictionary of sequence of *CTL* transforms to apply on 

91 the image and their sequence of parameters. 

92 

93 Other Parameters 

94 ---------------- 

95 args 

96 Arguments passed to *ctlrender*, e.g., ``-verbose``, ``-force``. 

97 kwargs 

98 Keywords arguments passed to the sub-process calling *ctlrender*, 

99 e.g., to define the environment variables such as 

100 ``CTL_MODULE_PATH``. 

101 

102 Returns 

103 ------- 

104 :class:`subprocess.CompletedProcess` 

105 *ctlrender* process completed output. 

106 

107 Raises 

108 ------ 

109 FileNotFoundError 

110 If a specified *CTL* transform file does not exist. 

111 

112 Notes 

113 ----- 

114 - The multi-line *CTL* code transforms are written to disk in a 

115 temporary location so that they can be used by *ctlrender*. 

116 

117 Examples 

118 -------- 

119 >>> ctl_adjust_exposure_float = template_ctl_transform_float( 

120 ... "rIn * pow(2, exposure)", 

121 ... description="Adjust Exposure", 

122 ... parameters=["input float exposure = 0.0"], 

123 ... ) 

124 >>> TESTS_ROOT_RESOURCES = os.path.join( 

125 ... os.path.dirname(__file__), "tests", "resources" 

126 ... ) 

127 >>> print( 

128 ... ctl_render( 

129 ... f"{TESTS_ROOT_RESOURCES}/CMS_Test_Pattern.exr", 

130 ... f"{TESTS_ROOT_RESOURCES}/CMS_Test_Pattern_Float.exr", 

131 ... {ctl_adjust_exposure_float: ["-param1 exposure 3.0"]}, 

132 ... "-verbose", 

133 ... "-force", 

134 ... ).stderr.decode("utf-8") 

135 ... ) # doctest: +SKIP 

136 global ctl parameters: 

137 <BLANKLINE> 

138 destination format: exr 

139 input scale: default 

140 output scale: default 

141 <BLANKLINE> 

142 ctl script file: \ 

143/var/folders/xr/sf4r3m2s761fl25h8zsl3k4w0000gn/T/tmponm0kvu2.ctl 

144 function name: main 

145 input arguments: 

146 rIn: float (varying) 

147 gIn: float (varying) 

148 bIn: float (varying) 

149 aIn: float (varying) 

150 exposure: float (defaulted) 

151 output arguments: 

152 rOut: float (varying) 

153 gOut: float (varying) 

154 bOut: float (varying) 

155 aOut: float (varying) 

156 <BLANKLINE> 

157 <BLANKLINE> 

158 """ 

159 

160 path_input = str(path_input) 

161 path_output = str(path_output) 

162 

163 if len(args) == 0: 

164 args = ARGUMENTS_CTL_RENDER_DEFAULTS 

165 

166 kwargs["capture_output"] = kwargs.get("capture_output", True) 

167 

168 command = [EXECUTABLE_CTL_RENDER] 

169 

170 ctl_transforms_mapping: Dict[str, Sequence] 

171 if isinstance(ctl_transforms, Sequence): 

172 ctl_transforms_mapping = {ctl_transform: [] for ctl_transform in ctl_transforms} 

173 else: 

174 ctl_transforms_mapping = ctl_transforms 

175 

176 temp_filenames = [] 

177 for ctl_transform, parameters in ctl_transforms_mapping.items(): 

178 if "\n" in ctl_transform: 

179 _descriptor, temp_filename = tempfile.mkstemp(suffix=".ctl") 

180 with open(temp_filename, "w") as temp_file: 

181 temp_file.write(ctl_transform) 

182 ctl_transform = temp_filename # noqa: PLW2901 

183 temp_filenames.append(temp_filename) 

184 elif not os.path.exists(ctl_transform): 

185 error = f'{ctl_transform} "CTL" transform does not exist!' 

186 

187 raise FileNotFoundError(error) 

188 

189 command.extend(["-ctl", ctl_transform]) 

190 for parameter in parameters: 

191 command.extend(parameter.split()) 

192 

193 command += [path_input, path_output] 

194 

195 for arg in args: 

196 command += arg.split() 

197 

198 completed_process = subprocess.run( # noqa: S603 

199 command, 

200 check=False, 

201 **kwargs, 

202 ) 

203 

204 for temp_filename in temp_filenames: 

205 os.remove(temp_filename) 

206 

207 return completed_process 

208 

209 

210@required("ctlrender") 

211def process_image_ctl( 

212 a: ArrayLike, 

213 ctl_transforms: Sequence[str] | Dict[str, Sequence[str]], 

214 *args: Any, 

215 **kwargs: Any, 

216) -> NDArrayFloat: # pragma: no cover 

217 """ 

218 Process the specified image data with *ctlrender* using the specified 

219 *CTL* transforms. 

220 

221 Parameters 

222 ---------- 

223 a 

224 Image data to process with *ctlrender*. 

225 ctl_transforms 

226 Sequence of *CTL* transforms to apply to the image, either paths to 

227 existing *CTL* transform files, multi-line *CTL* code transforms, or 

228 a combination of both. Alternatively, a dictionary mapping sequences 

229 of *CTL* transforms to their corresponding parameter sequences. 

230 

231 Other Parameters 

232 ---------------- 

233 args 

234 Arguments passed to *ctlrender*, e.g., ``-verbose``, ``-force``. 

235 kwargs 

236 Keywords arguments passed to the sub-process calling *ctlrender*, 

237 e.g., to define the environment variables such as 

238 ``CTL_MODULE_PATH``. 

239 

240 Returns 

241 ------- 

242 :class:`numpy.ndarray` 

243 Processed image data. 

244 

245 Raises 

246 ------ 

247 RuntimeError 

248 If the *ctlrender* process returns a non-zero exit code. 

249 

250 Notes 

251 ----- 

252 - The multi-line *CTL* code transforms are written to disk in a 

253 temporary location so that they can be used by *ctlrender*. 

254 

255 Examples 

256 -------- 

257 >>> from colour.utilities import full 

258 >>> ctl_transform = template_ctl_transform_float("rIn * 2") 

259 >>> a = 0.18 

260 >>> process_image_ctl(a, [ctl_transform]) # doctest: +SKIP 

261 0.3601074... 

262 >>> a = [0.18] 

263 >>> process_image_ctl(a, [ctl_transform]) # doctest: +SKIP 

264 array([ 0.3601074...]) 

265 >>> a = [0.18, 0.18, 0.18] 

266 >>> process_image_ctl(a, [ctl_transform]) # doctest: +SKIP 

267 array([ 0.3601074..., 0.3601074..., 0.3601074...]) 

268 >>> a = [[0.18, 0.18, 0.18]] 

269 >>> process_image_ctl(a, [ctl_transform]) # doctest: +SKIP 

270 array([[ 0.3601074..., 0.3601074..., 0.3601074...]]) 

271 >>> a = [[[0.18, 0.18, 0.18]]] 

272 >>> process_image_ctl(a, [ctl_transform]) # doctest: +SKIP 

273 array([[[ 0.3601074..., 0.3601074..., 0.3601074...]]]) 

274 >>> a = full([4, 2, 3], 0.18) 

275 >>> process_image_ctl(a, [ctl_transform]) # doctest: +SKIP 

276 array([[[ 0.3601074..., 0.3601074..., 0.3601074...], 

277 [ 0.3601074..., 0.3601074..., 0.3601074...]], 

278 <BLANKLINE> 

279 [[ 0.3601074..., 0.3601074..., 0.3601074...], 

280 [ 0.3601074..., 0.3601074..., 0.3601074...]], 

281 <BLANKLINE> 

282 [[ 0.3601074..., 0.3601074..., 0.3601074...], 

283 [ 0.3601074..., 0.3601074..., 0.3601074...]], 

284 <BLANKLINE> 

285 [[ 0.3601074..., 0.3601074..., 0.3601074...], 

286 [ 0.3601074..., 0.3601074..., 0.3601074...]]]) 

287 """ 

288 

289 a = as_float_array(a) 

290 shape, dtype = a.shape, a.dtype 

291 a = as_3_channels_image(a) 

292 

293 _descriptor, temp_input_filename = tempfile.mkstemp(suffix="-input.exr") 

294 _descriptor, temp_output_filename = tempfile.mkstemp(suffix="-output.exr") 

295 

296 write_image(a, temp_input_filename) 

297 

298 output = ctl_render( 

299 temp_input_filename, 

300 temp_output_filename, 

301 ctl_transforms, 

302 *args, 

303 **kwargs, 

304 ) 

305 

306 if output.returncode != 0: # pragma: no cover 

307 raise RuntimeError(output.stderr.decode("utf-8")) 

308 

309 b = read_image(temp_output_filename).astype(dtype)[..., 0:3] 

310 

311 os.remove(temp_input_filename) 

312 os.remove(temp_output_filename) 

313 

314 if len(shape) == 0: 

315 return as_float(np.squeeze(b)[0]) 

316 

317 if shape[-1] == 1: 

318 return np.reshape(b[..., 0], shape) 

319 

320 return np.reshape(b, shape) 

321 

322 

323def template_ctl_transform_float( 

324 R_function: str, 

325 G_function: str | None = None, 

326 B_function: str | None = None, 

327 description: str | None = None, 

328 parameters: Sequence[str] | None = None, 

329 imports: Sequence[str] | None = None, 

330 header: str | None = None, 

331) -> str: 

332 """ 

333 Generate *CTL* transform code for testing a function that processes 

334 per-float channels. 

335 

336 Parameters 

337 ---------- 

338 R_function 

339 Function call to process the *red* channel. 

340 G_function 

341 Function call to process the *green* channel. 

342 B_function 

343 Function call to process the *blue* channel. 

344 description 

345 Description of the *CTL* transform. 

346 parameters 

347 List of parameters to use with the *CTL* transform. 

348 imports 

349 List of imports to use with the *CTL* transform. 

350 header 

351 Header code that can be used to define various functions and 

352 globals. 

353 

354 Returns 

355 ------- 

356 :class:`str` 

357 *CTL* transform code. 

358 

359 Examples 

360 -------- 

361 >>> print( 

362 ... template_ctl_transform_float( 

363 ... "rIn * pow(2, exposure)", 

364 ... description="Adjust Exposure", 

365 ... parameters=["input float exposure = 0.0"], 

366 ... ) 

367 ... ) 

368 // Adjust Exposure 

369 <BLANKLINE> 

370 void main 

371 ( 

372 output varying float rOut, 

373 output varying float gOut, 

374 output varying float bOut, 

375 output varying float aOut, 

376 input varying float rIn, 

377 input varying float gIn, 

378 input varying float bIn, 

379 input varying float aIn = 1.0, 

380 input float exposure = 0.0 

381 ) 

382 { 

383 rOut = rIn * pow(2, exposure); 

384 gOut = rIn * pow(2, exposure); 

385 bOut = rIn * pow(2, exposure); 

386 aOut = aIn; 

387 } 

388 >>> def format_imports(imports): 

389 ... return [f'import "{i}";' for i in imports] 

390 >>> print( 

391 ... template_ctl_transform_float( 

392 ... "Y_2_linCV(rIn, CINEMA_WHITE, CINEMA_BLACK)", 

393 ... "Y_2_linCV(gIn, CINEMA_WHITE, CINEMA_BLACK)", 

394 ... "Y_2_linCV(bIn, CINEMA_WHITE, CINEMA_BLACK)", 

395 ... imports=format_imports( 

396 ... [ 

397 ... "ACESlib.Utilities", 

398 ... "ACESlib.Transform_Common", 

399 ... ] 

400 ... ), 

401 ... ) 

402 ... ) 

403 // "float" Processing Function 

404 <BLANKLINE> 

405 import "ACESlib.Utilities"; 

406 import "ACESlib.Transform_Common"; 

407 <BLANKLINE> 

408 void main 

409 ( 

410 output varying float rOut, 

411 output varying float gOut, 

412 output varying float bOut, 

413 output varying float aOut, 

414 input varying float rIn, 

415 input varying float gIn, 

416 input varying float bIn, 

417 input varying float aIn = 1.0) 

418 { 

419 rOut = Y_2_linCV(rIn, CINEMA_WHITE, CINEMA_BLACK); 

420 gOut = Y_2_linCV(gIn, CINEMA_WHITE, CINEMA_BLACK); 

421 bOut = Y_2_linCV(bIn, CINEMA_WHITE, CINEMA_BLACK); 

422 aOut = aIn; 

423 } 

424 """ 

425 

426 G_function = optional(G_function, R_function) 

427 B_function = optional(B_function, R_function) 

428 parameters = optional(parameters, "") 

429 imports = optional(imports, []) 

430 header = optional(header, "") 

431 

432 ctl_file_content = "" 

433 

434 if description: 

435 ctl_file_content += f"// {description}\n" 

436 else: 

437 ctl_file_content += '// "float" Processing Function\n' 

438 

439 ctl_file_content += "\n" 

440 

441 if imports: 

442 ctl_file_content += "\n".join(imports) 

443 ctl_file_content += "\n\n" 

444 

445 if header: 

446 ctl_file_content += f"{header}\n" 

447 

448 ctl_file_content += """ 

449void main 

450( 

451 output varying float rOut, 

452 output varying float gOut, 

453 output varying float bOut, 

454 output varying float aOut, 

455 input varying float rIn, 

456 input varying float gIn, 

457 input varying float bIn, 

458 input varying float aIn = 1.0 

459""".strip() 

460 

461 if parameters: 

462 ctl_file_content += ",\n" 

463 ctl_file_content += textwrap.indent(",\n".join(parameters), " " * 4) 

464 ctl_file_content += "\n" 

465 

466 ctl_file_content += f""" 

467) 

468{{ 

469 rOut = {R_function}; 

470 gOut = {G_function}; 

471 bOut = {B_function}; 

472 aOut = aIn; 

473}} 

474""".strip() 

475 

476 return ctl_file_content 

477 

478 

479def template_ctl_transform_float3( 

480 RGB_function: str, 

481 description: str | None = None, 

482 parameters: Sequence[str] | None = None, 

483 imports: Sequence[str] | None = None, 

484 header: str | None = None, 

485) -> str: 

486 """ 

487 Generate *CTL* transform code for testing a function that processes 

488 *RGB* channels. 

489 

490 Parameters 

491 ---------- 

492 RGB_function 

493 Function call to process the *RGB* channels. 

494 description 

495 Description of the *CTL* transform. 

496 parameters 

497 List of parameters to use with the *CTL* transform. 

498 imports 

499 List of imports to use with the *CTL* transform. 

500 header 

501 Header code that can be used to define various functions and 

502 globals. 

503 

504 Returns 

505 ------- 

506 :class:`str` 

507 *CTL* transform code. 

508 

509 Examples 

510 -------- 

511 >>> def format_imports(imports): 

512 ... return [f'import "{i}";' for i in imports] 

513 >>> print( 

514 ... template_ctl_transform_float3( 

515 ... "darkSurround_to_dimSurround(rgbIn)", 

516 ... imports=format_imports( 

517 ... [ 

518 ... "ACESlib.Utilities", 

519 ... "ACESlib.Transform_Common", 

520 ... "ACESlib.ODT_Common", 

521 ... ] 

522 ... ), 

523 ... ) 

524 ... ) 

525 // "float3" Processing Function 

526 <BLANKLINE> 

527 import "ACESlib.Utilities"; 

528 import "ACESlib.Transform_Common"; 

529 import "ACESlib.ODT_Common"; 

530 <BLANKLINE> 

531 void main 

532 ( 

533 output varying float rOut, 

534 output varying float gOut, 

535 output varying float bOut, 

536 output varying float aOut, 

537 input varying float rIn, 

538 input varying float gIn, 

539 input varying float bIn, 

540 input varying float aIn = 1.0) 

541 { 

542 float rgbIn[3] = {rIn, gIn, bIn}; 

543 <BLANKLINE> 

544 float rgbOut[3] = darkSurround_to_dimSurround(rgbIn); 

545 <BLANKLINE> 

546 rOut = rgbOut[0]; 

547 gOut = rgbOut[1]; 

548 bOut = rgbOut[2]; 

549 aOut = aIn; 

550 } 

551 """ 

552 

553 parameters = optional(parameters, "") 

554 imports = optional(imports, []) 

555 header = optional(header, "") 

556 

557 ctl_file_content = "" 

558 

559 if description: 

560 ctl_file_content += f"// {description}\n" 

561 else: 

562 ctl_file_content += '// "float3" Processing Function\n' 

563 

564 ctl_file_content += "\n" 

565 

566 if imports: 

567 ctl_file_content += "\n".join(imports) 

568 ctl_file_content += "\n\n" 

569 

570 if header: 

571 ctl_file_content += f"{header}\n" 

572 

573 ctl_file_content += """ 

574void main 

575( 

576 output varying float rOut, 

577 output varying float gOut, 

578 output varying float bOut, 

579 output varying float aOut, 

580 input varying float rIn, 

581 input varying float gIn, 

582 input varying float bIn, 

583 input varying float aIn = 1.0 

584""".strip() 

585 

586 if parameters: 

587 ctl_file_content += ",\n" 

588 ctl_file_content += textwrap.indent(",\n".join(parameters), " " * 4) 

589 ctl_file_content += "\n" 

590 

591 ctl_file_content += """ 

592) 

593{{ 

594 float rgbIn[3] = {{rIn, gIn, bIn}}; 

595 

596 float rgbOut[3] = {RGB_function}; 

597 

598 rOut = rgbOut[0]; 

599 gOut = rgbOut[1]; 

600 bOut = rgbOut[2]; 

601 aOut = aIn; 

602}} 

603""".strip().format(RGB_function=RGB_function) 

604 

605 return ctl_file_content