"""
Process Nodes
============
Defines the available process nodes in a *CLF* file.
"""
from __future__ import annotations
import typing
from abc import ABC
from dataclasses import dataclass
if typing.TYPE_CHECKING:
from collections.abc import Callable
import lxml.etree
from colour_clf_io.elements import (
Array,
ExponentParams,
LogParams,
SatNode,
SOPNode,
)
from colour_clf_io.errors import ParsingError, ValidationError
from colour_clf_io.parsing import (
ParserConfig,
XMLParsable,
XMLWritable,
child_element,
child_elements,
element_as_float,
elements_as_text_list,
map_optional,
retrieve_attributes,
set_attr_if_not_none,
set_element_if_not_none,
sliding_window,
)
from colour_clf_io.values import (
ASC_CDLStyle,
BitDepth,
ExponentStyle,
Interpolation1D,
Interpolation3D,
LogStyle,
RangeStyle,
)
__author__ = "Colour Developers"
__copyright__ = "Copyright 2024 Colour Developers"
__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
__maintainer__ = "Colour Developers"
__email__ = "colour-developers@colour-science.org"
__status__ = "Production"
__all__ = [
"PROCESSING_NODE_CONSTRUCTORS",
"register_process_node_xml_constructor",
"ProcessNode",
"assert_bit_depth_compatibility",
"parse_process_node",
"LUT1D",
"LUT3D",
"Matrix",
"Range",
"Log",
"Exponent",
"ASC_CDL",
]
PROCESSING_NODE_CONSTRUCTORS: dict = {}
"""
Hold the processing node constructors.
"""
def register_process_node_xml_constructor(name: str) -> Callable:
"""
Add the constructor method to the :attr:`PROCESSING_NODE_CONSTRUCTORS`
dictionary. Adds the wrapped function as value with the given name as key.
Parameters
----------
name
Name to use as key for adding.
"""
def register(constructor: Callable) -> Callable:
"""Register the given callable."""
PROCESSING_NODE_CONSTRUCTORS[name] = constructor
return constructor
return register
[docs]
@dataclass
class ProcessNode(XMLParsable, XMLWritable, ABC):
"""
Represent a *ProcessNode*, an operation to be applied to the image data.
At least one *ProcessNode* sub-class must be included in a
:class:`colour_clf_io.ProcessList` class instance. The base *ProcessNode*
class contains attributes and elements that are common to and inherited
by the specific sub-types of the *ProcessNode* class.
References
----------
- https://docs.acescentral.com/specifications/clf/#processNode
"""
id: str | None
"""A unique identifier for the *ProcessNode*."""
name: str | None
"""
A concise string defining a name for the *ProcessNode* that can be used
by an application for display in a user interface.
"""
in_bit_depth: BitDepth
"""
A string that is used by some *ProcessNodes* to indicate how array or
parameter values have been scaled.
"""
out_bit_depth: BitDepth
"""
A string that is used by some *ProcessNodes* to indicate how array or
parameter values have been scaled.
"""
description: list[str] | None
"""
An arbitrary string for describing the function, usage, or notes about the
*ProcessNode*.
"""
[docs]
@staticmethod
def parse_attributes(xml: lxml.etree._Element, config: ParserConfig) -> dict:
"""
Parse the default attributes of a *ProcessNode* and return them as a
dictionary of names and their values.
Parameters
----------
xml
XML element to parse.
config
XML parser config.
Returns
-------
:class:`dict`
*dict* of attribute names and their values.
"""
attributes = retrieve_attributes(
xml,
{
"id": "id",
"name": "name",
},
)
in_bit_depth = BitDepth(xml.get("inBitDepth"))
out_bit_depth = BitDepth(xml.get("outBitDepth"))
description = elements_as_text_list(xml, "Description", config)
return {
"in_bit_depth": in_bit_depth,
"out_bit_depth": out_bit_depth,
"description": description,
**attributes,
}
[docs]
def write_process_node_attributes(self, node: lxml.etree._Element) -> None:
"""
Add the data of the *ProcessNode* as attributes to the given XML node.
Parameters
----------
node
Target node that will receive the new attributes.
"""
set_attr_if_not_none(node, "id", self.id)
set_attr_if_not_none(node, "name", self.name)
set_attr_if_not_none(node, "inBitDepth", self.in_bit_depth.value)
set_attr_if_not_none(node, "outBitDepth", self.out_bit_depth.value)
if self.description is None:
return
for description_text in self.description:
description_element = lxml.etree.SubElement(node, "Description")
description_element.text = description_text
def assert_bit_depth_compatibility(process_nodes: list[ProcessNode]) -> bool:
"""
Check that the input and output values of adjacent process nodes are
compatible. Return true if all nodes are compatible, false otherwise.
Examples
--------
>>> from colour_clf_io.process_nodes import assert_bit_depth_compatibility, LUT1D
>>> from colour_clf_io.elements import Array
>>> lut = Array(values=[0, 1], dim=(2, 1))
>>> node_i8 = LUT1D(
... id=None,
... name=None,
... description=None,
... half_domain=False,
... raw_halfs=False,
... interpolation=None,
... array=lut,
... in_bit_depth=BitDepth.i8,
... out_bit_depth=BitDepth.i8,
... )
>>> node_f16 = LUT1D(
... id=None,
... name=None,
... description=None,
... half_domain=False,
... raw_halfs=False,
... interpolation=None,
... array=lut,
... in_bit_depth=BitDepth.f16,
... out_bit_depth=BitDepth.f16,
... )
>>> assert_bit_depth_compatibility([node_i8, node_i8])
True
>>> assert_bit_depth_compatibility(
... [node_i8, node_f16]
... ) # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValidationError: ...
"""
for node_a, node_b in sliding_window(process_nodes, 2):
is_compatible = node_a.out_bit_depth == node_b.in_bit_depth
if not is_compatible:
exception = (
f"Encountered incompatible bit depth between two processing nodes: "
f"{node_a} and {node_b}"
)
raise ValidationError(exception)
return True
def parse_process_node(xml: lxml.etree._Element, config: ParserConfig) -> ProcessNode:
"""
Return the *ProcessNode* that corresponds to given XML element.
Parameters
----------
xml
XML element to parse.
config
XML parser config.
Returns
-------
:class: colour.clf.ProcessNode
A subclass of `ProcessNode` that represents the given Process Node.
Raises
------
:class: ParsingError
If the given element does not match any valid process node, or the node does not
correctly correspond to the specification.
"""
tag = lxml.etree.QName(xml).localname
constructor = PROCESSING_NODE_CONSTRUCTORS.get(tag)
if constructor is not None:
return PROCESSING_NODE_CONSTRUCTORS[tag](xml, config)
exception = f"Encountered invalid processing node with tag '{xml.tag}'"
raise ParsingError(exception)
[docs]
@dataclass
class LUT1D(ProcessNode):
"""
Represent a *LUT1D* element.
References
----------
- https://docs.acescentral.com/specifications/clf/#lut1d
"""
array: Array
half_domain: bool
raw_halfs: bool
interpolation: Interpolation1D | None
[docs]
@staticmethod
@register_process_node_xml_constructor("LUT1D")
def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> LUT1D | None:
"""
Parse and return a :class:`colour_clf_io.LUT1D` class instance from the
given XML element. Returns `None`` if the given XML element is ``None``.
Expects the XML element to be a valid element according to the *CLF*
specification.
Parameters
----------
xml
XML element to parse.
config
XML parser config.
Returns
-------
class:`colour_clf_io.LUT1D` or :py:data:`None`
Parsed XML node.
Raises
------
:class:`colour_clf_io.errors.ParsingError`
If the node does not conform to the specification, a ``ParsingError``
exception will be raised. The error message will indicate the
details of the issue that was encountered.
"""
if xml is None:
return None
super_args = ProcessNode.parse_attributes(xml, config)
array = Array.from_xml(child_element(xml, "Array", config), config)
if array is None:
exception = "LUT1D processing node does not have an Array element."
raise ParsingError(exception)
half_domain = xml.get("halfDomain") == "true"
raw_halfs = xml.get("rawHalfs") == "true"
interpolation = map_optional(Interpolation1D, xml.get("interpolation"))
return LUT1D(
array=array,
half_domain=half_domain,
raw_halfs=raw_halfs,
interpolation=interpolation,
**super_args,
)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("LUT1D")
self.write_process_node_attributes(xml)
if self.half_domain:
xml.set("halfDomain", "true")
if self.raw_halfs:
xml.set("rawHalfs", "true")
if self.interpolation is not None:
xml.set("interpolation", self.interpolation.value)
xml.append(self.array.to_xml())
return xml
[docs]
@dataclass
class LUT3D(ProcessNode):
"""
Represent a *LUT3D* element.
References
----------
- https://docs.acescentral.com/specifications/clf/#lut3d
"""
array: Array
half_domain: bool
raw_halfs: bool
interpolation: Interpolation3D | None
[docs]
@staticmethod
@register_process_node_xml_constructor("LUT3D")
def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> LUT3D | None:
"""
Parse and return a :class:`colour_clf_io.LUT3D` class instance from the
given XML element. Returns `None`` if the given XML element is ``None``.
Expects the XML element to be a valid element according to the *CLF*
specification.
Parameters
----------
xml
XML element to parse.
config
XML parser config.
Returns
-------
class:`colour_clf_io.LUT3D` or :py:data:`None`
Parsed XML node.
Raises
------
:class:`colour_clf_io.errors.ParsingError`
If the node does not conform to the specification, a ``ParsingError``
exception will be raised. The error message will indicate the
details of the issue that was encountered.
"""
if xml is None:
return None
super_args = ProcessNode.parse_attributes(xml, config)
array = Array.from_xml(child_element(xml, "Array", config), config)
if array is None:
exception = "LUT3D processing node does not have an Array element."
raise ParsingError(exception)
half_domain = xml.get("halfDomain") == "true"
raw_halfs = xml.get("rawHalfs") == "true"
interpolation = Interpolation3D(xml.get("interpolation"))
return LUT3D(
array=array,
half_domain=half_domain,
raw_halfs=raw_halfs,
interpolation=interpolation,
**super_args,
)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("LUT3D")
self.write_process_node_attributes(xml)
if self.half_domain:
xml.set("halfDomain", "true")
if self.raw_halfs:
xml.set("rawHalfs", "true")
if self.interpolation is not None:
xml.set("interpolation", self.interpolation.value)
xml.append(self.array.to_xml())
return xml
[docs]
@dataclass
class Matrix(ProcessNode):
"""
Represent a *Matrix* element.
References
----------
- https://docs.acescentral.com/specifications/clf/#matrix
"""
array: Array
[docs]
@staticmethod
@register_process_node_xml_constructor("Matrix")
def from_xml(
xml: lxml.etree._Element | None, config: ParserConfig
) -> Matrix | None:
"""
Parse and return a :class:`colour_clf_io.Matrix` class instance from
the given XML element. Returns `None`` if the given XML element is ``None``.
Expects the XML element to be a valid element according to the *CLF*
specification.
Parameters
----------
xml
XML element to parse.
config
XML parser config.
Returns
-------
class:`colour_clf_io.Matrix` or :py:data:`None`
Parsed XML node.
Raises
------
:class:`colour_clf_io.errors.ParsingError`
If the node does not conform to the specification, a ``ParsingError``
exception will be raised. The error message will indicate the
details of the issue that was encountered.
"""
if xml is None:
return None
super_args = ProcessNode.parse_attributes(xml, config)
array = Array.from_xml(child_element(xml, "Array", config), config)
if array is None:
exception = "Matrix processing node does not have an Array element."
raise ParsingError(exception)
return Matrix(array=array, **super_args)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("Matrix")
self.write_process_node_attributes(xml)
xml.append(self.array.to_xml())
return xml
[docs]
@dataclass
class Range(ProcessNode):
"""
Represent a *Range* element.
References
----------
- https://docs.acescentral.com/specifications/clf/#range
"""
min_in_value: float | None
max_in_value: float | None
min_out_value: float | None
max_out_value: float | None
style: RangeStyle | None
[docs]
@staticmethod
@register_process_node_xml_constructor("Range")
def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> Range | None:
"""
Parse and return a :class:`colour_clf_io.Range` class instance from the
given XML element. Returns `None`` if the given XML element is ``None``.
Expects the XML element to be a valid element according to the *CLF*
specification.
Parameters
----------
xml
XML element to parse.
config
XML parser config.
Returns
-------
class:`colour_clf_io.Range` or :py:data:`None`
Parsed XML node.
Raises
------
:class:`colour_clf_io.errors.ParsingError`
If the node does not conform to the specification, a ``ParsingError``
exception will be raised. The error message will indicate the
details of the issue that was encountered.
"""
if xml is None:
return None
super_args = ProcessNode.parse_attributes(xml, config)
def optional_float(name: str) -> float | None:
"""Convert given name to float."""
return element_as_float(xml, name, config)
min_in_value = optional_float("minInValue")
max_in_value = optional_float("maxInValue")
min_out_value = optional_float("minOutValue")
max_out_value = optional_float("maxOutValue")
style = map_optional(RangeStyle, xml.get("style"))
return Range(
min_in_value=min_in_value,
max_in_value=max_in_value,
min_out_value=min_out_value,
max_out_value=max_out_value,
style=style,
**super_args,
)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("Range")
self.write_process_node_attributes(xml)
set_element_if_not_none(xml, "minInValue", self.min_in_value)
set_element_if_not_none(xml, "maxInValue", self.max_in_value)
set_element_if_not_none(xml, "minOutValue", self.min_out_value)
set_element_if_not_none(xml, "maxOutValue", self.max_out_value)
if self.style is not None:
xml.set("style", self.style.value)
return xml
[docs]
@dataclass
class Log(ProcessNode):
"""
Represent a *Log* element.
References
----------
- https://docs.acescentral.com/specifications/clf/#log
"""
style: LogStyle
log_params: list[LogParams]
[docs]
@staticmethod
@register_process_node_xml_constructor("Log")
def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> Log | None:
"""
Parse and return a :class:`colour_clf_io.Log` class instance from the
given XML element. Returns `None`` if the given XML element is ``None``.
Expects the XML element to be a valid element according to the *CLF*
specification.
Parameters
----------
xml
XML element to parse.
config
XML parser config.
Returns
-------
class:`colour_clf_io.Log` or :py:data:`None`
Parsed XML node.
Raises
------
:class:`colour_clf_io.errors.ParsingError`
If the node does not conform to the specification, a ``ParsingError``
exception will be raised. The error message will indicate the
details of the issue that was encountered.
"""
if xml is None:
return None
super_args = ProcessNode.parse_attributes(xml, config)
style = LogStyle(xml.get("style"))
param_elements = child_elements(xml, "LogParams", config)
params = [
param
for param in [
LogParams.from_xml(param_element, config)
for param_element in param_elements
]
if param is not None
]
return Log(style=style, log_params=params, **super_args)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("Log")
self.write_process_node_attributes(xml)
xml.set("style", self.style.value)
for log_params in self.log_params:
xml.append(log_params.to_xml())
return xml
[docs]
@dataclass
class Exponent(ProcessNode):
"""
Represent an *Exponent* element.
References
----------
- https://docs.acescentral.com/specifications/clf/#exponent
"""
style: ExponentStyle
exponent_params: list[ExponentParams]
[docs]
@staticmethod
@register_process_node_xml_constructor("Exponent")
def from_xml(
xml: lxml.etree._Element | None, config: ParserConfig
) -> Exponent | None:
"""
Parse and return a :class:`colour_clf_io.Exponent` class instance from
the given XML element. Returns `None`` if the given XML element is ``None``.
Expects the XML element to be a valid element according to the *CLF*
specification.
Parameters
----------
xml
XML element to parse.
config
XML parser config.
Returns
-------
class:`colour_clf_io.Exponent` or :py:data:`None`
Parsed XML node.
Raises
------
:class:`colour_clf_io.errors.ParsingError`
If the node does not conform to the specification, a ``ParsingError``
exception will be raised. The error message will indicate the
details of the issue that was encountered.
"""
if xml is None:
return None
super_args = ProcessNode.parse_attributes(xml, config)
style = map_optional(ExponentStyle, xml.get("style"))
if style is None:
exception = "Exponent process node has no `style' value."
raise ParsingError(exception)
param_elements = child_elements(xml, "ExponentParams", config)
params = [
param
for param in [
ExponentParams.from_xml(param_element, config)
for param_element in param_elements
]
if param is not None
]
if not params:
exception = "Exponent process node has no `ExponentParams' element."
raise ParsingError(exception)
return Exponent(style=style, exponent_params=params, **super_args)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("Exponent")
self.write_process_node_attributes(xml)
xml.set("style", self.style.value)
for exponent_params in self.exponent_params:
xml.append(exponent_params.to_xml())
return xml
[docs]
@dataclass
class ASC_CDL(ProcessNode):
"""
Represent an *ASC_CDL* element.
References
----------
- https://docs.acescentral.com/specifications/clf/#asc_cdl
"""
style: ASC_CDLStyle
sopnode: SOPNode | None
sat_node: SatNode | None
[docs]
@staticmethod
@register_process_node_xml_constructor("ASC_CDL")
def from_xml(
xml: lxml.etree._Element | None, config: ParserConfig
) -> ASC_CDL | None:
"""
Parse and return a :class:`colour_clf_io.ASC_CDL` class instance from
the given XML element. Returns `None`` if the given XML element is ``None``.
Expects the XML element to be a valid element according to the *CLF*
specification.
Parameters
----------
xml
XML element to parse.
config
XML parser config.
Returns
-------
class:`colour_clf_io.ASC_CDL` or :py:data:`None`
Parsed XML node.
Raises
------
:class:`colour_clf_io.errors.ParsingError`
If the node does not conform to the specification, a ``ParsingError``
exception will be raised. The error message will indicate the
details of the issue that was encountered.
"""
if xml is None:
return None
super_args = ProcessNode.parse_attributes(xml, config)
style = ASC_CDLStyle(xml.get("style"))
sop_node = SOPNode.from_xml(child_element(xml, "SOPNode", config), config)
sat_node = SatNode.from_xml(child_element(xml, "SatNode", config), config)
return ASC_CDL(style=style, sopnode=sop_node, sat_node=sat_node, **super_args)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("ASC_CDL")
self.write_process_node_attributes(xml)
xml.set("style", self.style.value)
if self.sopnode is not None:
xml.append(self.sopnode.to_xml())
if self.sat_node is not None:
xml.append(self.sat_node.to_xml())
return xml