Coverage for io/ctl.py: 65%

1"""

2 CTL Processing

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

4

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

6 processing, enabling programmatic colour transformations through the Academy

7 Color 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

15 from __future__ import annotations

16

17 import os

18 import subprocess

19 import tempfile

20 import textwrap

21 import typing

22

23 import numpy as np

24

25 if typing.TYPE_CHECKING:

26 from colour.hints import (

27 Any,

28 ArrayLike,

29 Dict,

30 NDArrayFloat,

31 PathLike,

32 )

33

34 from colour.hints import Sequence

35 from colour.io import as_3_channels_image, read_image, write_image

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

54 EXECUTABLE_CTL_RENDER: str = "ctlrender"

55"""

56*ctlrender* executable name.

57"""

58

59 ARGUMENTS_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")

70 def 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")

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

323 def 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 += """

449 void 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

479 def 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 += """

574 void 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