Coverage for io/ctl.py: 65%
65 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
1"""
2CTL Processing
3==============
5Define objects and functions for *Color Transformation Language* (CTL)
6processing, enabling programmatic colour transformations through the Academy
7Color Encoding System (ACES) CTL interpreter.
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"""
15from __future__ import annotations
17import os
18import subprocess
19import tempfile
20import textwrap
21import typing
23import numpy as np
25if typing.TYPE_CHECKING:
26 from colour.hints import (
27 Any,
28 ArrayLike,
29 Dict,
30 NDArrayFloat,
31 PathLike,
32 )
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
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"
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]
54EXECUTABLE_CTL_RENDER: str = "ctlrender"
55"""
56*ctlrender* executable name.
57"""
59ARGUMENTS_CTL_RENDER_DEFAULTS: tuple = ("-verbose", "-force")
60"""
61*ctlrender* invocation default arguments.
62"""
65# TODO: Reinstate coverage when "ctlrender" is trivially available
66# cross-platform.
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.
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.
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``.
102 Returns
103 -------
104 :class:`subprocess.CompletedProcess`
105 *ctlrender* process completed output.
107 Raises
108 ------
109 FileNotFoundError
110 If a specified *CTL* transform file does not exist.
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*.
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 """
160 path_input = str(path_input)
161 path_output = str(path_output)
163 if len(args) == 0:
164 args = ARGUMENTS_CTL_RENDER_DEFAULTS
166 kwargs["capture_output"] = kwargs.get("capture_output", True)
168 command = [EXECUTABLE_CTL_RENDER]
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
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!'
187 raise FileNotFoundError(error)
189 command.extend(["-ctl", ctl_transform])
190 for parameter in parameters:
191 command.extend(parameter.split())
193 command += [path_input, path_output]
195 for arg in args:
196 command += arg.split()
198 completed_process = subprocess.run( # noqa: S603
199 command,
200 check=False,
201 **kwargs,
202 )
204 for temp_filename in temp_filenames:
205 os.remove(temp_filename)
207 return completed_process
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.
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.
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``.
240 Returns
241 -------
242 :class:`numpy.ndarray`
243 Processed image data.
245 Raises
246 ------
247 RuntimeError
248 If the *ctlrender* process returns a non-zero exit code.
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*.
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 """
289 a = as_float_array(a)
290 shape, dtype = a.shape, a.dtype
291 a = as_3_channels_image(a)
293 _descriptor, temp_input_filename = tempfile.mkstemp(suffix="-input.exr")
294 _descriptor, temp_output_filename = tempfile.mkstemp(suffix="-output.exr")
296 write_image(a, temp_input_filename)
298 output = ctl_render(
299 temp_input_filename,
300 temp_output_filename,
301 ctl_transforms,
302 *args,
303 **kwargs,
304 )
306 if output.returncode != 0: # pragma: no cover
307 raise RuntimeError(output.stderr.decode("utf-8"))
309 b = read_image(temp_output_filename).astype(dtype)[..., 0:3]
311 os.remove(temp_input_filename)
312 os.remove(temp_output_filename)
314 if len(shape) == 0:
315 return as_float(np.squeeze(b)[0])
317 if shape[-1] == 1:
318 return np.reshape(b[..., 0], shape)
320 return np.reshape(b, shape)
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.
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.
354 Returns
355 -------
356 :class:`str`
357 *CTL* transform code.
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 """
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, "")
432 ctl_file_content = ""
434 if description:
435 ctl_file_content += f"// {description}\n"
436 else:
437 ctl_file_content += '// "float" Processing Function\n'
439 ctl_file_content += "\n"
441 if imports:
442 ctl_file_content += "\n".join(imports)
443 ctl_file_content += "\n\n"
445 if header:
446 ctl_file_content += f"{header}\n"
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()
461 if parameters:
462 ctl_file_content += ",\n"
463 ctl_file_content += textwrap.indent(",\n".join(parameters), " " * 4)
464 ctl_file_content += "\n"
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()
476 return ctl_file_content
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.
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.
504 Returns
505 -------
506 :class:`str`
507 *CTL* transform code.
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 """
553 parameters = optional(parameters, "")
554 imports = optional(imports, [])
555 header = optional(header, "")
557 ctl_file_content = ""
559 if description:
560 ctl_file_content += f"// {description}\n"
561 else:
562 ctl_file_content += '// "float3" Processing Function\n'
564 ctl_file_content += "\n"
566 if imports:
567 ctl_file_content += "\n".join(imports)
568 ctl_file_content += "\n\n"
570 if header:
571 ctl_file_content += f"{header}\n"
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()
586 if parameters:
587 ctl_file_content += ",\n"
588 ctl_file_content += textwrap.indent(",\n".join(parameters), " " * 4)
589 ctl_file_content += "\n"
591 ctl_file_content += """
592)
593{{
594 float rgbIn[3] = {{rIn, gIn, bIn}};
596 float rgbOut[3] = {RGB_function};
598 rOut = rgbOut[0];
599 gOut = rgbOut[1];
600 bOut = rgbOut[2];
601 aOut = aIn;
602}}
603""".strip().format(RGB_function=RGB_function)
605 return ctl_file_content