"""
Elements
========
Defines objects that hold data from elements contained in a *CLF* file. These
typically are child elements of *Process* Nodes.
"""
from __future__ import annotations
import sys
import typing
from dataclasses import dataclass
if typing.TYPE_CHECKING:
import numpy.typing as npt
import lxml.etree
from colour_clf_io.errors import ParsingError
from colour_clf_io.parsing import (
ParserConfig,
XMLParsable,
XMLWritable,
check_none,
child_element,
child_element_or_exception,
map_optional,
retrieve_attributes,
retrieve_attributes_as_float,
set_attr_if_not_none,
set_element_if_not_none,
three_floats,
)
from colour_clf_io.values import Channel
__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__ = [
"Array",
"CalibrationInfo",
"SOPNode",
"SatNode",
"Info",
"LogParams",
"ExponentParams",
]
if sys.version_info >= (3, 12):
from itertools import batched
else:
from itertools import islice
T = typing.TypeVar("T")
def batched(iterable: typing.Iterable[T], n: int) -> typing.Iterator[tuple[T, ...]]:
if n < 1:
err = "n must be at least one"
raise ValueError(err)
it = iter(iterable)
while batch := tuple(islice(it, n)):
yield batch
[docs]
@dataclass
class Array(XMLParsable, XMLWritable):
"""
Represent an *Array* element.
Attributes
----------
- :attr:`~colour_clf_io.Array.values`
- :attr:`~colour_clf_io.Array.dim`
Methods
-------
- :meth:`~colour_clf_io.Array.from_xml`
- :meth:`~colour_clf_io.Array.as_array`
References
----------
- https://docs.acescentral.com/specifications/clf/#array
"""
values: list[float]
"""Values contained by the element."""
dim: tuple[int, ...]
"""
Specifies the dimension of the LUT or the matrix and the number of
colour components.
"""
[docs]
@staticmethod
def from_xml(
xml: lxml.etree._Element | None,
config: ParserConfig, # noqa: ARG004
) -> Array | None:
"""
Parse and return a :class:`colour_clf_io.Array` 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.Array` 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
dim = xml.get("dim")
check_none(
xml,
'Array must have a "dim" attribute',
)
dimensions = tuple(map(int, dim.split())) # pyright: ignore
values = list(map(float, xml.text.split())) # pyright: ignore
return Array(values=values, dim=dimensions)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("Array")
xml.set("dim", " ".join(map(str, self.dim)))
def wrap_with_newlines(s: str) -> str:
return f"\n{s}\n"
if len(self.dim) <= 1:
text = "\n".join(map(str, self.values))
else:
row_length = self.dim[-1]
text = "\n".join(
" ".join(map(str, row)) for row in batched(self.values, row_length)
)
xml.text = wrap_with_newlines(text)
return xml
[docs]
def as_array(self) -> npt.NDArray:
"""
Convert the *CLF* element into a numpy array.
Returns
-------
:class:`numpy`ndarray``
Array of shape `dim` with the data from `values`.
"""
import numpy as np # noqa: PLC0415
dim = self.dim
# Strip the dimensions with value 1.
while dim[-1] == 1:
dim = dim[:-1]
return np.array(self.values).reshape(dim)
[docs]
@dataclass
class CalibrationInfo(XMLParsable, XMLWritable):
"""
Represent a *CalibrationInfo* container element for a
:class:`colour_clf_io.ProcessList` class instance.
Attributes
----------
- :attr:`~colour_clf_io.CalibrationInfo.display_device_serial_num`
- :attr:`~colour_clf_io.CalibrationInfo.display_device_host_name`
- :attr:`~colour_clf_io.CalibrationInfo.operator_name`
- :attr:`~colour_clf_io.CalibrationInfo.calibration_date_time`
- :attr:`~colour_clf_io.CalibrationInfo.measurement_probe`
- :attr:`~colour_clf_io.CalibrationInfo.calibration_software_name`
- :attr:`~colour_clf_io.CalibrationInfo.calibration_software_version`
Methods
-------
- :meth:`~colour_clf_io.CalibrationInfo.from_xml`
References
----------
- https://docs.acescentral.com/specifications/clf/#processlist
"""
display_device_serial_num: str | None
display_device_host_name: str | None
operator_name: str | None
calibration_date_time: str | None
measurement_probe: str | None
calibration_software_name: str | None
calibration_software_version: str | None
[docs]
@staticmethod
def from_xml(
xml: lxml.etree._Element | None,
config: ParserConfig, # noqa: ARG004
) -> CalibrationInfo | None:
"""
Parse and return a :class:`colour_clf_io.CalibrationInfo` 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.CalibrationInfo` 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
attributes = retrieve_attributes(
xml,
{
"display_device_serial_num": "DisplayDeviceSerialNum",
"display_device_host_name": "DisplayDeviceHostName",
"operator_name": "OperatorName",
"calibration_date_time": "CalibrationDateTime",
"measurement_probe": "MeasurementProbe",
"calibration_software_name": "CalibrationSoftwareName",
"calibration_software_version": "CalibrationSoftwareVersion",
},
)
return CalibrationInfo(**attributes)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("CalibrationInfo")
set_attr_if_not_none(
xml, "DisplayDeviceSerialNum", self.display_device_serial_num
)
set_attr_if_not_none(
xml, "DisplayDeviceHostName", self.display_device_host_name
)
set_attr_if_not_none(xml, "OperatorName", self.operator_name)
set_attr_if_not_none(xml, "CalibrationDateTime", self.calibration_date_time)
set_attr_if_not_none(xml, "MeasurementProbe", self.measurement_probe)
set_attr_if_not_none(
xml, "CalibrationSoftwareName", self.calibration_software_name
)
return xml
[docs]
@dataclass
class SOPNode(XMLParsable, XMLWritable):
"""
Represent a *SOPNode* element for a :class:`colour_clf_io.ASC_CDL`
*Process Node*.
Attributes
----------
- :attr:`~colour_clf_io.SOPNode.slope`
- :attr:`~colour_clf_io.SOPNode.offset`
- :attr:`~colour_clf_io.SOPNode.power`
Methods
-------
- :meth:`~colour_clf_io.SOPNode.from_xml`
References
----------
- https://docs.acescentral.com/specifications/clf/#asc_cdl
"""
slope: tuple[float, float, float]
"""
Three decimal values representing the R, G, and B slope values, which is
similar to gain, but changes the slope of the transfer function without
shifting the black level established by offset. Valid values for slope must
be greater than or equal to zero. The nominal value is 1.0 for all channels.
"""
offset: tuple[float, float, float]
"""
Three decimal values representing the R, G, and B offset values, which
raise or lower overall brightness of a color component by shifting the
transfer function up or down while holding the slope constant. The nominal
value is 0.0 for all channels.
"""
power: tuple[float, float, float]
"""
Three decimal values representing the R, G, and B power values, which
change the intermediate shape of the transfer function. Valid values for
power must be greater than zero. The nominal value is 1.0 for all channels.
"""
[docs]
@staticmethod
def from_xml(
xml: lxml.etree._Element | None, config: ParserConfig
) -> SOPNode | None:
"""
Parse and return a :class:`colour_clf_io.SOPNode` 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.SOPNode` 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
slope = three_floats(child_element_or_exception(xml, "Slope", config).text)
offset = three_floats(child_element_or_exception(xml, "Offset", config).text)
power = three_floats(child_element_or_exception(xml, "Power", config).text)
return SOPNode(slope=slope, offset=offset, power=power)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("SOPNode")
set_element_if_not_none(xml, "Slope", " ".join(map(str, self.slope)))
set_element_if_not_none(xml, "Offset", " ".join(map(str, self.offset)))
set_element_if_not_none(xml, "Power", " ".join(map(str, self.power)))
return xml
[docs]
@classmethod
def default(cls) -> SOPNode:
"""
Return the default SOPNode instance. Contains the default values that
should be used per specification in case the actual value is not provided.
Returns
-------
class:`colour_clf_io.SOPNode`
Parsed XML node.
"""
return cls(
slope=(1.0, 1.0, 1.0),
offset=(0.0, 0.0, 0.0),
power=(1.0, 1.0, 1.0),
)
[docs]
@dataclass
class SatNode(XMLParsable, XMLWritable):
"""
Represent a *SatNode* element for a :class:`colour_clf_io.ASC_CDL`
*Process Node*.
Attributes
----------
- :attr:`~colour_clf_io.SatNode.saturation`
Methods
-------
- :meth:`~colour_clf_io.SatNode.from_xml`
References
----------
- https://docs.acescentral.com/specifications/clf/#asc_cdl
"""
saturation: float
"""
A single decimal value applied to all color channels. Valid values for
saturation must be greater than or equal to zero. The nominal value is 1.0.
"""
[docs]
@staticmethod
def from_xml(
xml: lxml.etree._Element | None, config: ParserConfig
) -> SatNode | None:
"""
Parse and return a :class:`colour_clf_io.SatNode` 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.SatNode` 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
saturation = child_element_or_exception(xml, "Saturation", config).text
if saturation is None:
exception = "Saturation node in SatNode contains no value."
raise ParsingError(exception)
saturation = float(saturation)
return SatNode(saturation=saturation)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("SatNode")
set_element_if_not_none(xml, "Saturation", self.saturation)
return xml
[docs]
@classmethod
def default(cls) -> SatNode:
"""
Return the default SatNode instance. Contains the default values that
should be used per specification in case the actual value is not provided.
Returns
-------
class:`colour_clf_io.SatNode`
Parsed XML node.
"""
return cls(saturation=1.0)
[docs]
@dataclass
class Info(XMLParsable, XMLWritable):
"""
Represent an *Info* element.
Attributes
----------
- :attr:`~colour_clf_io.Info.app_release`
- :attr:`~colour_clf_io.Info.copyright`
- :attr:`~colour_clf_io.Info.revision`
- :attr:`~colour_clf_io.Info.aces_transform_id`
- :attr:`~colour_clf_io.Info.aces_user_name`
- :attr:`~colour_clf_io.Info.calibration_info`
- :attr:`~colour_clf_io.Info.saturation`
Methods
-------
- :meth:`~colour_clf_io.Info.from_xml`
References
----------
- https://docs.acescentral.com/specifications/clf/#processList
"""
app_release: str | None
"""A string used for indicating application software release level."""
copyright: str | None
"""A string containing a copyright notice for authorship of the *CLF* file."""
revision: str | None
"""
A string used to track the version of the LUT itself (e.g., an increased
resolution from a previous version of the LUT).
"""
aces_transform_id: str | None
"""
A string containing an ACES transform identifier as described in
Academy S-2014-002. If the transform described by the ProcessList is the
concatenation of several ACES transforms, this element may contain several
ACES Transform IDs, separated by white space or line separators. This
element is mandatory for ACES transforms and may be referenced from ACES
Metadata Files.
"""
aces_user_name: str | None
"""
A string containing the user-friendly name recommended for use in product
user interfaces as described in Academy TB-2014-002.
"""
calibration_info: CalibrationInfo | None
"""
Container element for calibration metadata used when making a LUT for a
specific device.
"""
[docs]
@staticmethod
def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> Info | None:
"""
Parse and return a :class:`colour_clf_io.Info` 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.Info` 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
attributes = retrieve_attributes(
xml,
{
"app_release": "AppRelease",
"copyright": "Copyright",
"revision": "Revision",
"aces_transform_id": "ACEStransformID",
"aces_user_name": "ACESuserName",
},
)
calibration_info = CalibrationInfo.from_xml(
child_element(xml, "CalibrationInfo", config),
config,
)
return Info(calibration_info=calibration_info, **attributes)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("Info")
set_attr_if_not_none(xml, "AppRelease", self.app_release)
set_attr_if_not_none(xml, "Copyright", self.copyright)
set_attr_if_not_none(xml, "Revision", self.revision)
set_attr_if_not_none(xml, "AcesTransformID", self.aces_transform_id)
set_attr_if_not_none(xml, "AcesUserName", self.aces_user_name)
if self.calibration_info is not None:
xml.append(self.calibration_info.to_xml())
return xml
[docs]
@dataclass
class LogParams(XMLParsable, XMLWritable):
"""
Represent a *LogParams* element for a :class:`colour_clf_io.Log`
*Process Node*.
Attributes
----------
- :attr:`~colour_clf_io.LogParams.base`
- :attr:`~colour_clf_io.LogParams.log_side_slope`
- :attr:`~colour_clf_io.LogParams.log_side_offset`
- :attr:`~colour_clf_io.LogParams.lin_side_slope`
- :attr:`~colour_clf_io.LogParams.lin_side_offset`
- :attr:`~colour_clf_io.LogParams.lin_side_break`
- :attr:`~colour_clf_io.LogParams.linear_slope`
- :attr:`~colour_clf_io.LogParams.channel`
Methods
-------
- :meth:`~colour_clf_io.LogParams.from_xml`
References
----------
- https://docs.acescentral.com/specifications/clf/#log
"""
base: float | None
"""The base of the logarithmic function. Default is 2."""
log_side_slope: float | None
"""
Slope" (or gain) applied to the log side of the logarithmic segment. Default is 1.
"""
log_side_offset: float | None
"""
Offset applied to the log side of the logarithmic segment. Default is 0.
"""
lin_side_slope: float | None
"""
Slope of the linear side of the logarithmic segment. Default is 1.
"""
lin_side_offset: float | None
"""
Offset applied to the linear side of the logarithmic segment. Default is 0.
"""
lin_side_break: float | None
"""
The break-point, defined in linear space, at which the piece-wise function
transitions between the logarithmic and linear segments. This is required
if style="cameraLinToLog" or "cameraLogToLin".
"""
linear_slope: float | None
"""
The slope of the linear segment of the piecewise function. This attribute
does not need to be provided unless the formula being implemented requires
it. The default is to calculate using linSideBreak such that the linear
portion is continuous in value with the logarithmic portion of the curve,
by using the value of the logarithmic portion of the curve at the break-point.
"""
channel: Channel | None
"""
The colour channel to which the exponential function is applied. Possible
values are "R", "G", "B". If this attribute is utilized to target different
adjustments per channel, then up to three *LogParams* elements may be used,
provided that "channel" is set differently in each. However, the same value
of base must be used for all channels. If this attribute is not otherwise
specified, the logarithmic function is applied identically to all three
colour channels.
"""
[docs]
@staticmethod
def from_xml(
xml: lxml.etree._Element | None,
config: ParserConfig, # noqa: ARG004
) -> LogParams | None:
"""
Parse and return a :class:`colour_clf_io.LogParams` 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.LogParams` 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
attributes = retrieve_attributes_as_float(
xml,
{
"base": "base",
"log_side_slope": "logSideSlope",
"log_side_offset": "logSideOffset",
"lin_side_slope": "linSideSlope",
"lin_side_offset": "linSideOffset",
"lin_side_break": "linSideBreak",
"linear_slope": "linearSlope",
},
)
channel = map_optional(Channel, xml.get("channel"))
return LogParams(channel=channel, **attributes)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("LogParams")
set_attr_if_not_none(xml, "base", self.base)
set_attr_if_not_none(xml, "logSideSlope", self.log_side_slope)
set_attr_if_not_none(xml, "logSideOffset", self.log_side_offset)
set_attr_if_not_none(xml, "linSideSlope", self.lin_side_slope)
set_attr_if_not_none(xml, "linSideOffset", self.lin_side_offset)
set_attr_if_not_none(xml, "linSideBreak", self.lin_side_break)
set_attr_if_not_none(xml, "linearSlope", self.linear_slope)
if self.channel is not None:
xml.set("channel", self.channel.value)
return xml
[docs]
@classmethod
def default(cls) -> LogParams:
"""
Return the default LogParams instance. Contains the default values that
should be used per specification in case the actual value is not provided.
Returns
-------
class:`colour_clf_io.LogParams`
Parsed XML node.
"""
return cls(
base=2.0,
log_side_slope=1.0,
log_side_offset=0.0,
lin_side_slope=1.0,
lin_side_offset=0.0,
lin_side_break=None,
linear_slope=None,
channel=None,
)
[docs]
@dataclass
class ExponentParams(XMLParsable, XMLWritable):
"""
Represent a *ExponentParams* element for a :class:`colour_clf_io.Exponent`
*Process Node*.
Attributes
----------
- :attr:`~colour_clf_io.ExponentParams.exponent`
- :attr:`~colour_clf_io.ExponentParams.offset`
- :attr:`~colour_clf_io.ExponentParams.channel`
Methods
-------
- :meth:`~colour_clf_io.ExponentParams.from_xml`
References
----------
- https://docs.acescentral.com/specifications/clf/#exponent
"""
exponent: float
"""
The power to which the value is to be raised. If style is any of the
"monCurve" types, the valid range is [1.0, 10.0]. The nominal value is 1.0.
"""
offset: float | None
"""
The offset value to use. If offset is used, the enclosing Exponent
element's style attribute must be set to one of the "monCurve" types.
Offset is not allowed when style is any of the "basic" types. The valid
range is [0.0, 0.9]. The nominal value is 0.0.
"""
channel: Channel | None
"""
The colour channel to which the exponential function is applied. Possible
values are "R", "G", "B". If this attribute is utilized to target different
adjustments per channel, then up to three *ExponentParams* elements may be used,
provided that "channel" is set differently in each. However, the same value
of base must be used for all channels. If this attribute is not otherwise
specified, the logarithmic function is applied identically to all three
colour channels.
"""
[docs]
@staticmethod
def from_xml(
xml: lxml.etree._Element | None,
config: ParserConfig, # noqa: ARG004
) -> ExponentParams | None:
"""
Parse and return a :class:`colour_clf_io.ExponentParams` 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.ExponentParams` 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
attributes = retrieve_attributes_as_float(
xml,
{
"exponent": "exponent",
"offset": "offset",
},
)
exponent = attributes.pop("exponent")
if exponent is None:
exception = "Exponent process node has no `exponent' value."
raise ParsingError(exception)
channel = map_optional(Channel, xml.get("channel"))
return ExponentParams(channel=channel, exponent=exponent, **attributes)
[docs]
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("ExponentParams")
set_attr_if_not_none(xml, "exponent", self.exponent)
set_attr_if_not_none(xml, "offset", self.offset)
if self.channel is not None:
xml.set("channel", self.channel.value)
return xml
[docs]
@classmethod
def default(cls) -> ExponentParams:
"""
Return the default ExponentParams instance. Contains the default values that
should be used per specification in case the actual value is not provided.
Returns
-------
class:`colour_clf_io.ExponentParams`
Parsed XML node.
"""
return cls(
exponent=1.0,
offset=0.0,
channel=None,
)