diff --git a/.github/workflows/continuous-integration-quality-unit-tests.yml b/.github/workflows/continuous-integration-quality-unit-tests.yml index 7dc92aef50..7842c255d7 100644 --- a/.github/workflows/continuous-integration-quality-unit-tests.yml +++ b/.github/workflows/continuous-integration-quality-unit-tests.yml @@ -30,7 +30,7 @@ jobs: - name: Install Dependencies (macOS) if: matrix.os == 'macOS-latest' run: | - brew install gnu-sed graphviz + brew install ctl gnu-sed graphviz ln -s /usr/local/bin/gsed /usr/local/bin/sed shell: bash - name: Install Dependencies (Ubuntu) diff --git a/colour/io/__init__.py b/colour/io/__init__.py index 3e11fd0103..bf9c7992b0 100644 --- a/colour/io/__init__.py +++ b/colour/io/__init__.py @@ -6,6 +6,12 @@ from .image import READ_IMAGE_METHODS, WRITE_IMAGE_METHODS from .image import read_image, write_image from .image import as_3_channels_image +from .ctl import ( + ctl_render, + process_image_ctl, + template_ctl_transform_float, + template_ctl_transform_float3, +) from .ocio import process_image_OpenColorIO from .tabular import ( read_spectral_data_from_csv_file, @@ -41,6 +47,12 @@ "read_image", "write_image", ] +__all__ += [ + "ctl_render", + "process_image_ctl", + "template_ctl_transform_float", + "template_ctl_transform_float3", +] __all__ += [ "as_3_channels_image", ] diff --git a/colour/io/ctl.py b/colour/io/ctl.py new file mode 100644 index 0000000000..186716f79c --- /dev/null +++ b/colour/io/ctl.py @@ -0,0 +1,760 @@ +""" +CTL Processing +============== + +Defines the object for the *Color Transformation Language* (CTL) processing: + +- :func:`colour.io.ctl_render` +- :func:`colour.io.process_image_ctl` +- :func:`colour.io.template_ctl_transform_float` +- :func:`colour.io.template_ctl_transform_float3` +""" + +from __future__ import annotations + +import os +import numpy as np +import subprocess +import textwrap +import tempfile + +from colour.hints import ( + Any, + ArrayLike, + Dict, + List, + NDArray, + Optional, + Sequence, + Union, +) +from colour.io import as_3_channels_image, read_image, write_image +from colour.utilities import ( + as_float_array, + as_float_scalar, + optional, + required, +) + +__author__ = "Colour Developers" +__copyright__ = "Copyright 2013 Colour Developers" +__license__ = "New BSD License - https://opensource.org/licenses/BSD-3-Clause" +__maintainer__ = "Colour Developers" +__email__ = "colour-developers@colour-science.org" +__status__ = "Production" + +__all__ = [ + "EXECUTABLE_CTL_RENDER", + "ARGUMENTS_CTL_RENDER_DEFAULTS", + "ctl_render", + "process_image_ctl", + "template_ctl_transform_float", + "template_ctl_transform_float3", +] + + +EXECUTABLE_CTL_RENDER: str = "ctlrender" +""" +*ctlrender* executable name. +""" + +ARGUMENTS_CTL_RENDER_DEFAULTS: List = ["-verbose", "-force"] +""" +*ctlrender* invocation default arguments. +""" + + +@required("ctlrender") +def ctl_render( + path_input: str, + path_output: str, + ctl_transforms: Union[Sequence[str], Dict[str, Sequence[str]]], + *args: Any, + **kwargs: Any, +) -> str: + """ + Call *ctlrender* on given input image using given *CTL* transforms. + + Parameters + ---------- + path_input + Input image path. + path_output + Output image path. + ctl_transforms + Sequence of *CTL* transforms to apply on the image, either paths to + existing *CTL* transforms, multi-line *CTL* code transforms or a mix of + both or dictionary of sequence of *CTL* transforms to apply on the + image and their sequence of parameters. + + Other Parameters + ---------------- + args + Arguments passed to *ctlrender*, e.g. ``-verbose``, ``-force``. + kwargs + Keywords arguments passed to the sub-process calling *ctlrender*, e.g. + to define the environment variables such as ``CTL_MODULE_PATH``. + + Notes + ----- + - The multi-line *CTL* code transforms are written to disk in a temporary + location so that they can be used by *ctlrender*. + + Returns + ------- + :class:`subprocess.CompletedProcess` + *ctlrender* process completed output. + + Examples + -------- + >>> ctl_adjust_exposure_float = template_ctl_transform_float( + ... "rIn * pow(2, exposure)", + ... description="Adjust Exposure", + ... parameters=["input float exposure = 0.0"], + ... ) + >>> TESTS_RESOURCES_DIRECTORY = os.path.join( + ... os.path.dirname(__file__), 'tests', 'resources') + >>> print(ctl_render( + ... f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern.exr", + ... f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern_Float.exr", + ... {ctl_adjust_exposure_float: ["-param1 exposure 3.0"]}, + ... "-verbose", + ... "-force", + ... ).stderr.decode("utf-8")) # doctest: +SKIP + global ctl parameters: + + destination format: exr + input scale: default + output scale: default + + ctl script file: \ +/var/folders/xr/sf4r3m2s761fl25h8zsl3k4w0000gn/T/tmponm0kvu2.ctl + function name: main + input arguments: + rIn: float (varying) + gIn: float (varying) + bIn: float (varying) + aIn: float (varying) + exposure: float (defaulted) + output arguments: + rOut: float (varying) + gOut: float (varying) + bOut: float (varying) + aOut: float (varying) + + + """ + + if len(args) == 0: + args = ARGUMENTS_CTL_RENDER_DEFAULTS + + kwargs["capture_output"] = kwargs.get("capture_output", True) + + command = [EXECUTABLE_CTL_RENDER] + + if isinstance(ctl_transforms, Sequence): + ctl_transforms_mapping = dict.fromkeys(ctl_transforms, []) + else: + ctl_transforms_mapping = ctl_transforms + + temp_filenames = [] + for ctl_transform, parameters in ctl_transforms_mapping.items(): + if "\n" in ctl_transform: + _descriptor, temp_filename = tempfile.mkstemp(suffix=".ctl") + with open(temp_filename, "w") as temp_file: + temp_file.write(ctl_transform) + ctl_transform = temp_filename + temp_filenames.append(temp_filename) + elif not os.path.exists(ctl_transform): + raise FileNotFoundError( + f'{ctl_transform} "CTL" transform does not exist!' + ) + + command.extend(["-ctl", ctl_transform]) + for parameter in parameters: + command.extend(parameter.split()) + + command += [path_input, path_output] + + for arg in args: + command += arg.split() + + completed_process = subprocess.run(command, **kwargs) + + for temp_filename in temp_filenames: + os.remove(temp_filename) + + return completed_process + + +@required("ctlrender") +def process_image_ctl( + a: ArrayLike, + ctl_transforms: Union[Sequence[str], Dict[str, Sequence[str]]], + *args: Any, + **kwargs: Any, +) -> NDArray: + """ + Process given image data with *ctlrender* using given *CTL* transforms. + + Parameters + ---------- + a + Image data to process with *ctlrender*. + ctl_transforms + Sequence of *CTL* transforms to apply on the image, either paths to + existing *CTL* transforms, multi-line *CTL* code transforms or a mix of + both or dictionary of sequence of *CTL* transforms to apply on the + image and their sequence of parameters. + + Other Parameters + ---------------- + args + Arguments passed to *ctlrender*, e.g. ``-verbose``, ``-force``. + kwargs + Keywords arguments passed to the sub-process calling *ctlrender*, e.g. + to define the environment variables such as ``CTL_MODULE_PATH``. + + Notes + ----- + - The multi-line *CTL* code transforms are written to disk in a temporary + location so that they can be used by *ctlrender*. + + Returns + ------- + :class`numpy.ndarray` + Processed image data. + + Examples + -------- + >>> from colour.utilities import full + >>> ctl_transform = template_ctl_transform_float("rIn * 2") + >>> a = 0.18 + >>> process_image_ctl(a, [ctl_transform]) # doctest: +SKIP + 0.3601074... + >>> a = [0.18] + >>> process_image_ctl(a, [ctl_transform]) # doctest: +SKIP + array([ 0.3601074...]) + >>> a = [0.18, 0.18, 0.18] + >>> process_image_ctl(a, [ctl_transform]) # doctest: +SKIP + array([ 0.3601074..., 0.3601074..., 0.3601074...]) + >>> a = [[0.18, 0.18, 0.18]] + >>> process_image_ctl(a, [ctl_transform]) # doctest: +SKIP + array([[ 0.3601074..., 0.3601074..., 0.3601074...]]) + >>> a = [[[0.18, 0.18, 0.18]]] + >>> process_image_ctl(a, [ctl_transform]) # doctest: +SKIP + array([[[ 0.3601074..., 0.3601074..., 0.3601074...]]]) + >>> a = full([4, 2, 3], 0.18) + >>> process_image_ctl(a, [ctl_transform]) # doctest: +SKIP + array([[[ 0.3601074..., 0.3601074..., 0.3601074...], + [ 0.3601074..., 0.3601074..., 0.3601074...]], + + [[ 0.3601074..., 0.3601074..., 0.3601074...], + [ 0.3601074..., 0.3601074..., 0.3601074...]], + + [[ 0.3601074..., 0.3601074..., 0.3601074...], + [ 0.3601074..., 0.3601074..., 0.3601074...]], + + [[ 0.3601074..., 0.3601074..., 0.3601074...], + [ 0.3601074..., 0.3601074..., 0.3601074...]]]) + """ + + a = as_float_array(a) + shape, dtype = a.shape, a.dtype + a = as_3_channels_image(a) + + _descriptor, temp_input_filename = tempfile.mkstemp(suffix="-input.exr") + _descriptor, temp_output_filename = tempfile.mkstemp(suffix="-output.exr") + + write_image(a, temp_input_filename) + + ctl_render( + temp_input_filename, + temp_output_filename, + ctl_transforms, + *args, + **kwargs, + ) + + b = read_image(temp_output_filename).astype(dtype)[..., 0:3] + + os.remove(temp_input_filename) + os.remove(temp_output_filename) + + if len(shape) == 0: + return as_float_scalar(np.squeeze(b)[0]).astype(dtype) + elif shape[-1] == 1: + return np.reshape(b[..., 0], shape) + else: + return np.reshape(b, shape) + + +def template_ctl_transform_float( + R_function: str, + G_function: Optional[str] = None, + B_function: Optional[str] = None, + description: Optional[str] = None, + parameters: Optional[Sequence[str]] = None, + imports: Optional[Sequence[str]] = None, + header: Optional[str] = None, +) -> str: # noqa: D405,D407,D410,D411 + """ + Generate the code for a *CTL* transform to test a function processing + per-float channel. + + Parameters + ---------- + R_function + Function call to process the *red* channel. + G_function + Function call to process the *green* channel. + B_function + Function call to process the *blue* channel. + description + Description of the *CTL* transform. + parameters + List of parameters to use with the *CTL* transform. + imports + List of imports to use with the *CTL* transform. + header + Header code that can be used to define various functions and globals. + + Returns + ------- + :class:`str` + *CTL* transform code. + + Examples + -------- + >>> print(template_ctl_transform_float( + ... "rIn * pow(2, exposure)", + ... description="Adjust Exposure", + ... parameters=["input float exposure = 0.0"], + ... ) + ... ) + // Adjust Exposure + + void main + ( + input varying float rIn, + input varying float gIn, + input varying float bIn, + input varying float aIn, + output varying float rOut, + output varying float gOut, + output varying float bOut, + output varying float aOut, + input float exposure = 0.0 + ) + { + rOut = rIn * pow(2, exposure); + gOut = rIn * pow(2, exposure); + bOut = rIn * pow(2, exposure); + aOut = aIn; + } + >>> def format_imports(imports): + ... return [f'import "{i}";' for i in imports] + >>> print(template_ctl_transform_float( + ... "Y_2_linCV(rIn, CINEMA_WHITE, CINEMA_BLACK)", + ... "Y_2_linCV(gIn, CINEMA_WHITE, CINEMA_BLACK)", + ... "Y_2_linCV(bIn, CINEMA_WHITE, CINEMA_BLACK)", + ... imports=format_imports( + ... [ + ... "ACESlib.Utilities", + ... "ACESlib.Transform_Common", + ... ] + ... ), + ... ) + ... ) + // "float" Processing Function + + import "ACESlib.Utilities"; + import "ACESlib.Transform_Common"; + + void main + ( + input varying float rIn, + input varying float gIn, + input varying float bIn, + input varying float aIn, + output varying float rOut, + output varying float gOut, + output varying float bOut, + output varying float aOut) + { + rOut = Y_2_linCV(rIn, CINEMA_WHITE, CINEMA_BLACK); + gOut = Y_2_linCV(gIn, CINEMA_WHITE, CINEMA_BLACK); + bOut = Y_2_linCV(bIn, CINEMA_WHITE, CINEMA_BLACK); + aOut = aIn; + } + """ + + G_function = optional(G_function, R_function) + B_function = optional(B_function, R_function) + parameters = optional(parameters, "") + imports = optional(imports, []) + header = optional(header, "") + + ctl_file_content = "" + + if description: + ctl_file_content += f"// {description}\n" + else: + ctl_file_content += '// "float" Processing Function\n' + + ctl_file_content += "\n" + + if imports: + ctl_file_content += "\n".join(imports) + ctl_file_content += "\n\n" + + if header: + ctl_file_content += f"{header}\n" + + ctl_file_content += """ +void main +( + input varying float rIn, + input varying float gIn, + input varying float bIn, + input varying float aIn, + output varying float rOut, + output varying float gOut, + output varying float bOut, + output varying float aOut +""".strip() + + if parameters: + ctl_file_content += ",\n" + ctl_file_content += textwrap.indent(",\n".join(parameters), " " * 4) + ctl_file_content += "\n" + + ctl_file_content += f""" +) +{{ + rOut = {R_function}; + gOut = {G_function}; + bOut = {B_function}; + aOut = aIn; +}} +""".strip() + + return ctl_file_content + + +def template_ctl_transform_float3( + RGB_function: str, + description: Optional[str] = None, + parameters: Optional[Sequence[str]] = None, + imports: Optional[Sequence[str]] = None, + header: Optional[str] = None, +) -> str: # noqa: D405,D407,D410,D411 + """ + Generate the code for a *CTL* transform to test a function processing + RGB channels. + + Parameters + ---------- + RGB_function + Function call to process the *RGB* channels. + description + Description of the *CTL* transform. + parameters + List of parameters to use with the *CTL* transform. + imports + List of imports to use with the *CTL* transform. + header + Header code that can be used to define various functions and globals. + + Returns + ------- + :class:`str` + *CTL* transform code. + + Examples + -------- + >>> def format_imports(imports): + ... return [f'import "{i}";' for i in imports] + >>> print(template_ctl_transform_float3( + ... "darkSurround_to_dimSurround(rgbIn)", + ... imports=format_imports( + ... [ + ... "ACESlib.Utilities", + ... "ACESlib.Transform_Common", + ... "ACESlib.ODT_Common", + ... ] + ... ), + ... ) + ... ) + // "float3" Processing Function + + import "ACESlib.Utilities"; + import "ACESlib.Transform_Common"; + import "ACESlib.ODT_Common"; + + void main + ( + input varying float rIn, + input varying float gIn, + input varying float bIn, + input varying float aIn, + output varying float rOut, + output varying float gOut, + output varying float bOut, + output varying float aOut) + { + float rgbIn[3] = {rIn, gIn, bIn}; + + float rgbOut[3] = darkSurround_to_dimSurround(rgbIn); + + rOut = rgbOut[0]; + gOut = rgbOut[1]; + bOut = rgbOut[2]; + aOut = aIn; + } + """ + + parameters = optional(parameters, "") + imports = optional(imports, []) + header = optional(header, "") + + ctl_file_content = "" + + if description: + ctl_file_content += f"// {description}\n" + else: + ctl_file_content += '// "float3" Processing Function\n' + + ctl_file_content += "\n" + + if imports: + ctl_file_content += "\n".join(imports) + ctl_file_content += "\n\n" + + if header: + ctl_file_content += f"{header}\n" + + ctl_file_content += """ +void main +( + input varying float rIn, + input varying float gIn, + input varying float bIn, + input varying float aIn, + output varying float rOut, + output varying float gOut, + output varying float bOut, + output varying float aOut +""".strip() + + if parameters: + ctl_file_content += ",\n" + ctl_file_content += textwrap.indent(",\n".join(parameters), " " * 4) + ctl_file_content += "\n" + + ctl_file_content += """ +) +{{ + float rgbIn[3] = {{rIn, gIn, bIn}}; + + float rgbOut[3] = {RGB_function}; + + rOut = rgbOut[0]; + gOut = rgbOut[1]; + bOut = rgbOut[2]; + aOut = aIn; +}} +""".strip().format( + RGB_function=RGB_function + ) + + return ctl_file_content + + +if __name__ == "__main__": + TESTS_RESOURCES_DIRECTORY = os.path.join( + os.path.dirname(__file__), "tests", "resources" + ) + + ctl_adjust_exposure_float = template_ctl_transform_float( + "rIn * pow(2, exposure)", + description="Adjust Exposure", + parameters=["input float exposure = 0.0"], + ) + with open( + f"{TESTS_RESOURCES_DIRECTORY}/Adjust_Exposure_Float.ctl", + "w", + ) as ctl_file: + ctl_file.write(ctl_adjust_exposure_float) + + # Using "CTL" string. + ctl_render( + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern.exr", + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern_Float.exr", + {ctl_adjust_exposure_float: ["-param1 exposure 3.0"]}, + "-verbose", + "-force", + ) + # Using "CTL" transform file. + ctl_render( + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern.exr", + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern_Float.exr", + { + f"{TESTS_RESOURCES_DIRECTORY}/Adjust_Exposure_Float.ctl": [ + "-param1 exposure 3.0" + ] + }, + "-verbose", + "-force", + ) + + ctl_adjust_exposure_float3 = template_ctl_transform_float3( + "adjust_exposure(rgbIn, exposure)", + description="Adjust Exposure", + header=""" +float[3] adjust_exposure(float rgbIn[3], float exposureIn) +{ + float rgbOut[3]; + + float exposure = pow(2, exposureIn); + + rgbOut[0] = rgbIn[0] * exposure; + rgbOut[1] = rgbIn[1] * exposure; + rgbOut[2] = rgbIn[2] * exposure; + + return rgbOut; +} +"""[ + 1: + ], + parameters=["input float exposure = 0.0"], + ) + with open( + f"{TESTS_RESOURCES_DIRECTORY}/Adjust_Exposure_Float3.ctl", + "w", + ) as ctl_file: + ctl_file.write(ctl_adjust_exposure_float3) + + # Using "CTL" string. + ctl_render( + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern.exr", + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern_Float3.exr", + {ctl_adjust_exposure_float3: ["-param1 exposure 3.0"]}, + "-verbose", + "-force", + ) + + # Using "CTL" transform file. + ctl_render( + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern.exr", + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern_Float3.exr", + { + f"{TESTS_RESOURCES_DIRECTORY}/Adjust_Exposure_Float3.ctl": [ + "-param1 exposure 3.0" + ] + }, + "-verbose", + "-force", + ) + + CTL_MODULE_PATH = ( + "/Users/kelsolaar/Documents/Development/ampas/aces-dev/transforms/ctl:" + "/Users/kelsolaar/Documents/Development/ampas/aces-dev/transforms/ctl/lib:" + "/Users/kelsolaar/Documents/Development/ampas/aces-dev/transforms/ctl/utilities" + ) + + # Using "RRT" "CTL" transform file. + print( + ctl_render( + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern.exr", + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern_RRT.exr", + [ + ( + "/Users/kelsolaar/Documents/Development/ampas/aces-dev/" + "transforms/ctl/rrt/RRT.ctl" + ) + ], + env=dict( + os.environ, + CTL_MODULE_PATH=CTL_MODULE_PATH, + ), + ) + ) + + def format_imports(imports): + return [f'import "{i}";' for i in imports] + + # Running the "Y_2_linCV" "CTL" function. + ctl_Y_2_linCV_float = template_ctl_transform_float( + "Y_2_linCV(rIn, CINEMA_WHITE, CINEMA_BLACK)", + "Y_2_linCV(gIn, CINEMA_WHITE, CINEMA_BLACK)", + "Y_2_linCV(bIn, CINEMA_WHITE, CINEMA_BLACK)", + imports=format_imports( + [ + "ACESlib.Utilities", + "ACESlib.Transform_Common", + "ACESlib.ODT_Common", + ] + ), + ) + print(ctl_Y_2_linCV_float) + print( + ctl_render( + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern.exr", + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern_Y_2_linCV.exr", + [ctl_Y_2_linCV_float], + env=dict( + os.environ, + CTL_MODULE_PATH=CTL_MODULE_PATH, + ), + ) + ) + + # Running the "darkSurround_to_dimSurround" "CTL" function. + ctl_darkSurround_to_dimSurround_float3 = template_ctl_transform_float3( + "darkSurround_to_dimSurround(rgbIn)", + imports=format_imports( + [ + "ACESlib.Utilities", + "ACESlib.Transform_Common", + "ACESlib.ODT_Common", + ] + ), + ) + print(ctl_darkSurround_to_dimSurround_float3) + print( + ctl_render( + f"{TESTS_RESOURCES_DIRECTORY}/CMS_Test_Pattern.exr", + ( + f"{TESTS_RESOURCES_DIRECTORY}/" + f"CMS_Test_Pattern_darkSurround_to_dimSurround.exr" + ), + [ctl_darkSurround_to_dimSurround_float3], + env=dict( + os.environ, + CTL_MODULE_PATH=CTL_MODULE_PATH, + ), + ) + ) + + print( + process_image_ctl( + 0.18, + [ctl_darkSurround_to_dimSurround_float3], + env=dict( + os.environ, + CTL_MODULE_PATH=CTL_MODULE_PATH, + ), + ) + ) + + print( + process_image_ctl( + [0.18, 0.18, 0.18], + [ctl_darkSurround_to_dimSurround_float3], + env=dict( + os.environ, + CTL_MODULE_PATH=CTL_MODULE_PATH, + ), + ) + ) diff --git a/colour/io/tests/resources/Adjust_Exposure_Float.ctl b/colour/io/tests/resources/Adjust_Exposure_Float.ctl new file mode 100644 index 0000000000..c19a0186c0 --- /dev/null +++ b/colour/io/tests/resources/Adjust_Exposure_Float.ctl @@ -0,0 +1,20 @@ +// Adjust Exposure + +void main +( + input varying float rIn, + input varying float gIn, + input varying float bIn, + input varying float aIn, + output varying float rOut, + output varying float gOut, + output varying float bOut, + output varying float aOut, + input float exposure = 0.0 +) +{ + rOut = rIn * pow(2, exposure); + gOut = rIn * pow(2, exposure); + bOut = rIn * pow(2, exposure); + aOut = aIn; +} \ No newline at end of file diff --git a/colour/io/tests/resources/Adjust_Exposure_Float3.ctl b/colour/io/tests/resources/Adjust_Exposure_Float3.ctl new file mode 100644 index 0000000000..13612ce80e --- /dev/null +++ b/colour/io/tests/resources/Adjust_Exposure_Float3.ctl @@ -0,0 +1,37 @@ +// Adjust Exposure + +float[3] adjust_exposure(float rgbIn[3], float exposureIn) +{ + float rgbOut[3]; + + float exposure = pow(2, exposureIn); + + rgbOut[0] = rgbIn[0] * exposure; + rgbOut[1] = rgbIn[1] * exposure; + rgbOut[2] = rgbIn[2] * exposure; + + return rgbOut; +} + +void main +( + input varying float rIn, + input varying float gIn, + input varying float bIn, + input varying float aIn, + output varying float rOut, + output varying float gOut, + output varying float bOut, + output varying float aOut, + input float exposure = 0.0 +) +{ + float rgbIn[3] = {rIn, gIn, bIn}; + + float rgbOut[3] = adjust_exposure(rgbIn, exposure); + + rOut = rgbOut[0]; + gOut = rgbOut[1]; + bOut = rgbOut[2]; + aOut = aIn; +} \ No newline at end of file diff --git a/colour/io/tests/test_ctl.py b/colour/io/tests/test_ctl.py new file mode 100644 index 0000000000..371a02e6b1 --- /dev/null +++ b/colour/io/tests/test_ctl.py @@ -0,0 +1,347 @@ +# !/usr/bin/env python +"""Define the unit tests for the :mod:`colour.io.ctl` module.""" + +from __future__ import annotations + +import numpy as np +import os +import shutil +import tempfile +import textwrap +import unittest + +from colour.io import ( + ctl_render, + process_image_ctl, + template_ctl_transform_float, + template_ctl_transform_float3, +) +from colour.io import read_image +from colour.utilities import full, is_ctlrender_installed + +__author__ = "Colour Developers" +__copyright__ = "Copyright 2013 Colour Developers" +__license__ = "New BSD License - https://opensource.org/licenses/BSD-3-Clause" +__maintainer__ = "Colour Developers" +__email__ = "colour-developers@colour-science.org" +__status__ = "Production" + +__all__ = [ + "RESOURCES_DIRECTORY", + "TestCtlRender", + "TestProcessImageCtl", + "TestTemplateCtlTransformFloat", + "TestTemplateCtlTransformFloat3", +] + +RESOURCES_DIRECTORY: str = os.path.join(os.path.dirname(__file__), "resources") + + +class TestCtlRender(unittest.TestCase): + """Define :func:`colour.io.ctl.ctl_render` definition unit tests methods.""" + + def setUp(self): + """Initialise the common tests attributes.""" + + self._temporary_directory = tempfile.mkdtemp() + + def tearDown(self): + """After tests actions.""" + + shutil.rmtree(self._temporary_directory) + + def test_ctl_render(self): + """Test :func:`colour.io.ctl.ctl_render` definition.""" + + if not is_ctlrender_installed(): # pragma: no cover + return + + ctl_adjust_gain_float = template_ctl_transform_float( + "rIn * gain[0]", + "gIn * gain[1]", + "bIn * gain[2]", + description="Adjust Gain", + parameters=["input float gain[3] = {1.0, 1.0, 1.0}"], + ) + + ctl_adjust_exposure_float = template_ctl_transform_float( + "rIn * pow(2, exposure)", + "gIn * pow(2, exposure)", + "bIn * pow(2, exposure)", + description="Adjust Exposure", + parameters=["input float exposure = 0.0"], + ) + + path_input = os.path.join(RESOURCES_DIRECTORY, "CMS_Test_Pattern.exr") + path_output = os.path.join( + self._temporary_directory, "CMS_Test_Pattern_Float.exr" + ) + + ctl_render( + path_input, + path_output, + { + ctl_adjust_gain_float: ["-param3 gain 0.5 1.0 2.0"], + ctl_adjust_exposure_float: ["-param1 exposure 1.0"], + }, + "-verbose", + "-force", + ) + + np.testing.assert_almost_equal( + read_image(path_output)[..., 0:3], + read_image(path_input) * [1, 2, 4], + decimal=7, + ) + + ctl_render( + path_input, + path_output, + { + os.path.join( + RESOURCES_DIRECTORY, "Adjust_Exposure_Float3.ctl" + ): ["-param1 exposure 1.0"], + }, + "-verbose", + "-force", + env=dict(os.environ, CTL_MODULE_PATH=RESOURCES_DIRECTORY), + ) + + np.testing.assert_almost_equal( + read_image(path_output)[..., 0:3], + read_image(path_input) * 2, + decimal=7, + ) + + +class TestProcessImageCtl(unittest.TestCase): + """ + Define :func:`colour.io.ctl.process_image_ctl` definition unit tests + methods. + """ + + def test_process_image_ctl(self): + """Test :func:`colour.io.ctl.process_image_ctl` definition.""" + + if not is_ctlrender_installed(): # pragma: no cover + return + + ctl_adjust_gain_float = template_ctl_transform_float( + "rIn * gain[0]", + "gIn * gain[1]", + "bIn * gain[2]", + description="Adjust Gain", + parameters=["input float gain[3] = {1.0, 1.0, 1.0}"], + ) + + np.testing.assert_allclose( + process_image_ctl( + 0.18, + { + ctl_adjust_gain_float: ["-param3 gain 0.5 1.0 2.0"], + }, + "-verbose", + "-force", + ), + 0.18 / 2, + rtol=0.0001, + atol=0.0001, + ) + + np.testing.assert_allclose( + process_image_ctl( + np.array([0.18, 0.18, 0.18]), + { + ctl_adjust_gain_float: ["-param3 gain 0.5 1.0 2.0"], + }, + ), + np.array([0.18 / 2, 0.18, 0.18 * 2]), + rtol=0.0001, + atol=0.0001, + ) + + np.testing.assert_allclose( + process_image_ctl( + np.array([[0.18, 0.18, 0.18]]), + { + ctl_adjust_gain_float: ["-param3 gain 0.5 1.0 2.0"], + }, + ), + np.array([[0.18 / 2, 0.18, 0.18 * 2]]), + rtol=0.0001, + atol=0.0001, + ) + + np.testing.assert_allclose( + process_image_ctl( + np.array([[[0.18, 0.18, 0.18]]]), + { + ctl_adjust_gain_float: ["-param3 gain 0.5 1.0 2.0"], + }, + ), + np.array([[[0.18 / 2, 0.18, 0.18 * 2]]]), + rtol=0.0001, + atol=0.0001, + ) + + np.testing.assert_allclose( + process_image_ctl( + full([4, 2, 3], 0.18), + { + ctl_adjust_gain_float: ["-param3 gain 0.5 1.0 2.0"], + }, + ), + full([4, 2, 3], 0.18) * [0.5, 1.0, 2.0], + rtol=0.0001, + atol=0.0001, + ) + + +class TestTemplateCtlTransformFloat(unittest.TestCase): + """ + Define :func:`colour.io.ctl.template_ctl_transform_float` definition unit + tests methods. + """ + + def test_template_ctl_transform_float(self): + """Test :func:`colour.io.ctl.template_ctl_transform_float` definition.""" + + ctl_foo_bar_float = template_ctl_transform_float( + "rIn + foo[0]", + "gIn + foo[1]", + "bIn + foo[2]", + description="Foo & Bar", + imports=['import "Foo.ctl";', 'import "Bar.ctl";'], + parameters=[ + "input float foo[3] = {1.0, 1.0, 1.0}", + "input float bar = 1.0", + ], + header="// Custom Header\n", + ) + + self.assertEqual( + ctl_foo_bar_float, + textwrap.dedent( + """ + // Foo & Bar + + import "Foo.ctl"; + import "Bar.ctl"; + + // Custom Header + + void main + ( + input varying float rIn, + input varying float gIn, + input varying float bIn, + input varying float aIn, + output varying float rOut, + output varying float gOut, + output varying float bOut, + output varying float aOut, + input float foo[3] = {1.0, 1.0, 1.0}, + input float bar = 1.0 + ) + { + rOut = rIn + foo[0]; + gOut = gIn + foo[1]; + bOut = bIn + foo[2]; + aOut = aIn; + }"""[ + 1: + ] + ), + ) + + +class TestTemplateCtlTransformFloat3(unittest.TestCase): + """ + Define :func:`colour.io.ctl.template_ctl_transform_float3` definition unit + tests methods. + """ + + def test_template_ctl_transform_float3(self): + """Test :func:`colour.io.ctl.template_ctl_transform_float3` definition.""" + + ctl_foo_bar_float3 = template_ctl_transform_float3( + "baz(rgbIn, foo, bar)", + description="Foo, Bar & Baz", + imports=[ + '// import "Foo.ctl";', + '// import "Bar.ctl";', + '// import "Baz.ctl";', + ], + parameters=[ + "input float foo[3] = {1.0, 1.0, 1.0}", + "input float bar = 1.0", + ], + header=textwrap.dedent( + """ + float[3] baz(float rgbIn[3], float foo[3], float qux) + { + float rgbOut[3]; + + rgbOut[0] = rgbIn[0] * foo[0]* qux; + rgbOut[1] = rgbIn[1] * foo[1]* qux; + rgbOut[2] = rgbIn[2] * foo[2]* qux; + + return rgbOut; + }\n"""[ + 1: + ] + ), + ) + + self.assertEqual( + ctl_foo_bar_float3, + textwrap.dedent( + """ + // Foo, Bar & Baz + + // import "Foo.ctl"; + // import "Bar.ctl"; + // import "Baz.ctl"; + + float[3] baz(float rgbIn[3], float foo[3], float qux) + { + float rgbOut[3]; + + rgbOut[0] = rgbIn[0] * foo[0]* qux; + rgbOut[1] = rgbIn[1] * foo[1]* qux; + rgbOut[2] = rgbIn[2] * foo[2]* qux; + + return rgbOut; + } + + void main + ( + input varying float rIn, + input varying float gIn, + input varying float bIn, + input varying float aIn, + output varying float rOut, + output varying float gOut, + output varying float bOut, + output varying float aOut, + input float foo[3] = {1.0, 1.0, 1.0}, + input float bar = 1.0 + ) + { + float rgbIn[3] = {rIn, gIn, bIn}; + + float rgbOut[3] = baz(rgbIn, foo, bar); + + rOut = rgbOut[0]; + gOut = rgbOut[1]; + bOut = rgbOut[2]; + aOut = aIn; + }"""[ + 1: + ] + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/colour/utilities/__init__.py b/colour/utilities/__init__.py index 6dcefd2e52..abb66e2a23 100644 --- a/colour/utilities/__init__.py +++ b/colour/utilities/__init__.py @@ -24,6 +24,7 @@ batch, disable_multiprocessing, multiprocessing_pool, + is_ctlrender_installed, is_matplotlib_installed, is_networkx_installed, is_opencolorio_installed, @@ -142,6 +143,7 @@ "batch", "disable_multiprocessing", "multiprocessing_pool", + "is_ctlrender_installed", "is_matplotlib_installed", "is_networkx_installed", "is_opencolorio_installed", diff --git a/colour/utilities/common.py b/colour/utilities/common.py index f45a4d1248..1a5b421dd4 100644 --- a/colour/utilities/common.py +++ b/colour/utilities/common.py @@ -19,6 +19,7 @@ import functools import numpy as np import re +import subprocess import types import warnings from contextlib import contextmanager @@ -64,6 +65,7 @@ "batch", "disable_multiprocessing", "multiprocessing_pool", + "is_ctlrender_installed", "is_matplotlib_installed", "is_networkx_installed", "is_opencolorio_installed", @@ -559,6 +561,46 @@ def terminate(self): pool.terminate() +def is_ctlrender_installed(raise_exception: Boolean = False) -> Boolean: + """ + Return whether *ctlrender* is installed and available. + + Parameters + ---------- + raise_exception + Whether to raise an exception if *ctlrender* is unavailable. + + Returns + ------- + :class:`bool` + Whether *ctlrender* is installed. + + Raises + ------ + :class:`ImportError` + If *ctlrender* is not installed. + """ + + try: # pragma: no cover + stdout = subprocess.run( + ["ctlrender", "-help"], capture_output=True + ).stdout.decode("utf-8") + + if "transforms an image using one or more CTL scripts" not in stdout: + raise FileNotFoundError() + + return True + except FileNotFoundError as error: # pragma: no cover + if raise_exception: + raise FileNotFoundError( + '"ctlrender" related API features are not available: ' + f'"{error}".\nSee the installation guide for more information: ' + "https://www.colour-science.org/installation-guide/" + ) + + return False + + def is_matplotlib_installed(raise_exception: Boolean = False) -> Boolean: """ Return whether *Matplotlib* is installed and available. @@ -851,6 +893,7 @@ def is_trimesh_installed(raise_exception: Boolean = False) -> Boolean: _REQUIREMENTS_TO_CALLABLE: CaseInsensitiveMapping = CaseInsensitiveMapping( { + "ctlrender": is_ctlrender_installed, "Matplotlib": is_matplotlib_installed, "NetworkX": is_networkx_installed, "OpenColorIO": is_opencolorio_installed, @@ -863,15 +906,12 @@ def is_trimesh_installed(raise_exception: Boolean = False) -> Boolean: ) """ Mapping of requirements to their respective callables. - -_REQUIREMENTS_TO_CALLABLE - **{'Matplotlib', 'NetworkX', 'OpenColorIO', 'OpenImageIO', 'Pandas', - 'Scikit-Learn', 'tqdm', 'trimesh'}** """ def required( *requirements: Literal[ + "ctlrender", "Matplotlib", "NetworkX", "OpenColorIO", diff --git a/docs/colour.utilities.rst b/docs/colour.utilities.rst index d2b04c5113..5aabfcf596 100644 --- a/docs/colour.utilities.rst +++ b/docs/colour.utilities.rst @@ -42,6 +42,7 @@ Common batch disable_multiprocessing multiprocessing_pool + is_ctlrender_installed is_matplotlib_installed is_networkx_installed is_opencolorio_installed