Skip to content

Commit 04ff7b5

Browse files
authored
Merge pull request #4 from colour-science/feature/xml_writing
PR: Writing CLF files.
2 parents 348209d + 8628d48 commit 04ff7b5

File tree

9 files changed

+875
-51
lines changed

9 files changed

+875
-51
lines changed

README.rst

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,44 @@ The main entry point of the library is the ``read_clf`` function in the main nam
5858
5959
import colour_clf_io
6060
61-
example = """
62-
<?xml version="1.0" ?>
63-
<ProcessList xmlns="urn:AMPAS:CLF:v3.0" id="Example Wrapper" compCLFversion="2.0">
64-
<LUT3D id="lut-24" name="green look" interpolation="trilinear" inBitDepth="12i" outBitDepth="16f">
61+
example = """<?xml version="1.0" ?>
62+
<ProcessList xmlns="urn:AMPAS:CLF:v3.0" id="Example Wrapper" compCLFversion="2.0">
63+
<LUT3D id="lut-24" name="green look" interpolation="trilinear" inBitDepth="12i" outBitDepth="16f">
64+
<Description>3D LUT</Description>
65+
<Array dim="2 2 2 3">
66+
0.0 0.0 0.0
67+
0.0 0.0 1.0
68+
0.0 1.0 0.0
69+
0.0 1.0 1.0
70+
1.0 0.0 0.0
71+
1.0 0.0 1.0
72+
1.0 1.0 0.0
73+
1.0 1.0 1.0
74+
</Array>
75+
</LUT3D>
76+
</ProcessList>
77+
""" # noqa: E501
78+
clf_doc = colour_clf_io.parse_clf(example)
79+
print(clf_doc)
80+
81+
.. code-block:: text
82+
83+
ProcessList(id='Example Wrapper', compatible_CLF_version='3.0', process_nodes=[LUT3D(id='lut-24', name='green look', in_bit_depth=<BitDepth.i12: '12i'>, out_bit_depth=<BitDepth.f16: '16f'>, description='3D LUT', array=Array(values=[0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0], dim=(2, 2, 2, 3)), half_domain=False, raw_halfs=False, interpolation=<Interpolation3D.TRILINEAR: 'trilinear'>)], name=None, inverse_of=None, description=[], input_descriptor='', output_descriptor='', info=Info(app_release=None, copyright=None, revision=None, aces_transform_id=None, aces_user_name=None, calibration_info=None))
84+
85+
and for writing a CLF file the ``write_clf`` function can be used to serialise a ``ProcessList`` back to XML
86+
87+
88+
.. code-block:: python
89+
90+
xml = colour_clf_io.write_clf(clf_doc)
91+
print(xml)
92+
93+
.. code-block:: text
94+
95+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
96+
<ProcessList xmlns="urn:AMPAS:CLF:v3.0" compCLFversion="3.0" id="Example Wrapper">
97+
<Info/>
98+
<LUT3D id="lut-24" inBitDepth="12i" interpolation="trilinear" name="green look" outBitDepth="16f">
6599
<Description>3D LUT</Description>
66100
<Array dim="2 2 2 3">
67101
0.0 0.0 0.0
@@ -73,15 +107,8 @@ The main entry point of the library is the ``read_clf`` function in the main nam
73107
1.0 1.0 0.0
74108
1.0 1.0 1.0
75109
</Array>
76-
</LUT3D>
110+
</LUT3D>
77111
</ProcessList>
78-
""" # noqa: E501
79-
clf_doc = colour_clf_io.read_clf(EXAMPLE_WRAPPER.format(example))
80-
print(clf_doc)
81-
82-
.. code-block:: text
83-
84-
ProcessList(id='Example Wrapper', compatible_CLF_version='3.0', process_nodes=[LUT3D(id='lut-24', name='green look', in_bit_depth=<BitDepth.i12: '12i'>, out_bit_depth=<BitDepth.f16: '16f'>, description='3D LUT', array=Array(values=[0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0], dim=(2, 2, 2, 3)), half_domain=False, raw_halfs=False, interpolation=<Interpolation3D.TRILINEAR: 'trilinear'>)], name=None, inverse_of=None, description=[], input_descriptor='', output_descriptor='', info=Info(app_release=None, copyright=None, revision=None, aces_transform_id=None, aces_user_name=None, calibration_info=None))
85112
86113
User Guide
87114
----------

colour_clf_io/__init__.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@
104104
__version__ = f"{__major_version__}.{__minor_version__}.{__change_version__}"
105105

106106

107-
def read_clf(path: str | Path) -> ProcessList | None:
107+
def read_clf_from_file(path: str | Path) -> ProcessList:
108108
"""
109109
Read given *CLF* file and return a *ProcessList*.
110110
@@ -127,10 +127,14 @@ def read_clf(path: str | Path) -> ProcessList | None:
127127
xml = lxml.etree.parse(str(path)) # noqa: S320
128128
xml_process_list = xml.getroot()
129129

130-
return ProcessList.from_xml(xml_process_list)
130+
process_list = ProcessList.from_xml(xml_process_list)
131+
if process_list is None:
132+
err = "Process list could not be parsed."
133+
raise ValueError(err)
134+
return process_list
131135

132136

133-
def parse_clf(text: str | bytes) -> ProcessList | None:
137+
def read_clf(text: str | bytes) -> ProcessList | None:
134138
"""
135139
Read given string as a *CLF* file and return a *ProcessList*.
136140
@@ -153,3 +157,29 @@ def parse_clf(text: str | bytes) -> ProcessList | None:
153157
xml = lxml.etree.fromstring(text) # noqa: S320
154158

155159
return ProcessList.from_xml(xml)
160+
161+
162+
def write_clf(process_list: ProcessList, path: str | Path | None = None) -> None | str:
163+
"""
164+
Write the given *ProcessList* as a CLF file to the target
165+
location. If no *path* is given the CLF document will be returned as a string.
166+
167+
Parameters
168+
----------
169+
process_list
170+
*ProcessList* that should be written.
171+
path
172+
Location of the file, or *None* to return a string representation of the
173+
CLF document.
174+
175+
Returns
176+
-------
177+
:class:`colour_clf_io.ProcessList`
178+
"""
179+
xml = process_list.to_xml()
180+
serialised = lxml.etree.tostring(xml)
181+
if path is None:
182+
return serialised.decode("utf-8")
183+
with open(path, "wb") as f:
184+
f.write(serialised)
185+
return None

colour_clf_io/elements.py

Lines changed: 153 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,28 @@
88

99
from __future__ import annotations
1010

11+
import sys
1112
import typing
1213
from dataclasses import dataclass
1314

1415
if typing.TYPE_CHECKING:
1516
import numpy.typing as npt
1617

17-
if typing.TYPE_CHECKING:
18-
import lxml.etree
18+
import lxml.etree
1919

2020
from colour_clf_io.errors import ParsingError
2121
from colour_clf_io.parsing import (
2222
ParserConfig,
2323
XMLParsable,
24+
XMLWritable,
2425
check_none,
2526
child_element,
2627
child_element_or_exception,
2728
map_optional,
2829
retrieve_attributes,
2930
retrieve_attributes_as_float,
31+
set_attr_if_not_none,
32+
set_element_if_not_none,
3033
three_floats,
3134
)
3235
from colour_clf_io.values import Channel
@@ -48,9 +51,24 @@
4851
"ExponentParams",
4952
]
5053

54+
if sys.version_info >= (3, 12):
55+
from itertools import batched
56+
else:
57+
from itertools import islice
58+
59+
T = typing.TypeVar("T")
60+
61+
def batched(iterable: typing.Iterable[T], n: int) -> typing.Iterator[tuple[T, ...]]:
62+
if n < 1:
63+
err = "n must be at least one"
64+
raise ValueError(err)
65+
it = iter(iterable)
66+
while batch := tuple(islice(it, n)):
67+
yield batch
68+
5169

5270
@dataclass
53-
class Array(XMLParsable):
71+
class Array(XMLParsable, XMLWritable):
5472
"""
5573
Represent an *Array* element.
5674
@@ -124,6 +142,30 @@ def from_xml(
124142

125143
return Array(values=values, dim=dimensions)
126144

145+
def to_xml(self) -> lxml.etree._Element:
146+
"""
147+
Serialise this object as an XML object.
148+
149+
Returns
150+
-------
151+
:class:`lxml.etree._Element`
152+
"""
153+
xml = lxml.etree.Element("Array")
154+
xml.set("dim", " ".join(map(str, self.dim)))
155+
156+
def wrap_with_newlines(s: str) -> str:
157+
return f"\n{s}\n"
158+
159+
if len(self.dim) <= 1:
160+
text = "\n".join(map(str, self.values))
161+
else:
162+
row_length = self.dim[-1]
163+
text = "\n".join(
164+
" ".join(map(str, row)) for row in batched(self.values, row_length)
165+
)
166+
xml.text = wrap_with_newlines(text)
167+
return xml
168+
127169
def as_array(self) -> npt.NDArray:
128170
"""
129171
Convert the *CLF* element into a numpy array.
@@ -144,7 +186,7 @@ def as_array(self) -> npt.NDArray:
144186

145187

146188
@dataclass
147-
class CalibrationInfo(XMLParsable):
189+
class CalibrationInfo(XMLParsable, XMLWritable):
148190
"""
149191
Represent a *CalibrationInfo* container element for a
150192
:class:`colour_clf_io.ProcessList` class instance.
@@ -227,9 +269,32 @@ def from_xml(
227269

228270
return CalibrationInfo(**attributes)
229271

272+
def to_xml(self) -> lxml.etree._Element:
273+
"""
274+
Serialise this object as an XML object.
275+
276+
Returns
277+
-------
278+
:class:`lxml.etree._Element`
279+
"""
280+
xml = lxml.etree.Element("CalibrationInfo")
281+
set_attr_if_not_none(
282+
xml, "DisplayDeviceSerialNum", self.display_device_serial_num
283+
)
284+
set_attr_if_not_none(
285+
xml, "DisplayDeviceHostName", self.display_device_host_name
286+
)
287+
set_attr_if_not_none(xml, "OperatorName", self.operator_name)
288+
set_attr_if_not_none(xml, "CalibrationDateTime", self.calibration_date_time)
289+
set_attr_if_not_none(xml, "MeasurementProbe", self.measurement_probe)
290+
set_attr_if_not_none(
291+
xml, "CalibrationSoftwareName", self.calibration_software_name
292+
)
293+
return xml
294+
230295

231296
@dataclass
232-
class SOPNode(XMLParsable):
297+
class SOPNode(XMLParsable, XMLWritable):
233298
"""
234299
Represent a *SOPNode* element for a :class:`colour_clf_io.ASC_CDL`
235300
*Process Node*.
@@ -312,6 +377,20 @@ def from_xml(
312377

313378
return SOPNode(slope=slope, offset=offset, power=power)
314379

380+
def to_xml(self) -> lxml.etree._Element:
381+
"""
382+
Serialise this object as an XML object.
383+
384+
Returns
385+
-------
386+
:class:`lxml.etree._Element`
387+
"""
388+
xml = lxml.etree.Element("SOPNode")
389+
set_element_if_not_none(xml, "Slope", " ".join(map(str, self.slope)))
390+
set_element_if_not_none(xml, "Offset", " ".join(map(str, self.offset)))
391+
set_element_if_not_none(xml, "Power", " ".join(map(str, self.power)))
392+
return xml
393+
315394
@classmethod
316395
def default(cls) -> SOPNode:
317396
"""
@@ -331,7 +410,7 @@ def default(cls) -> SOPNode:
331410

332411

333412
@dataclass
334-
class SatNode(XMLParsable):
413+
class SatNode(XMLParsable, XMLWritable):
335414
"""
336415
Represent a *SatNode* element for a :class:`colour_clf_io.ASC_CDL`
337416
*Process Node*.
@@ -399,6 +478,18 @@ def from_xml(
399478

400479
return SatNode(saturation=saturation)
401480

481+
def to_xml(self) -> lxml.etree._Element:
482+
"""
483+
Serialise this object as an XML object.
484+
485+
Returns
486+
-------
487+
:class:`lxml.etree._Element`
488+
"""
489+
xml = lxml.etree.Element("SatNode")
490+
set_element_if_not_none(xml, "Saturation", self.saturation)
491+
return xml
492+
402493
@classmethod
403494
def default(cls) -> SatNode:
404495
"""
@@ -414,7 +505,7 @@ def default(cls) -> SatNode:
414505

415506

416507
@dataclass
417-
class Info(XMLParsable):
508+
class Info(XMLParsable, XMLWritable):
418509
"""
419510
Represent an *Info* element.
420511
@@ -520,9 +611,27 @@ def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> Info | No
520611

521612
return Info(calibration_info=calibration_info, **attributes)
522613

614+
def to_xml(self) -> lxml.etree._Element:
615+
"""
616+
Serialise this object as an XML object.
617+
618+
Returns
619+
-------
620+
:class:`lxml.etree._Element`
621+
"""
622+
xml = lxml.etree.Element("Info")
623+
set_attr_if_not_none(xml, "AppRelease", self.app_release)
624+
set_attr_if_not_none(xml, "Copyright", self.copyright)
625+
set_attr_if_not_none(xml, "Revision", self.revision)
626+
set_attr_if_not_none(xml, "AcesTransformID", self.aces_transform_id)
627+
set_attr_if_not_none(xml, "AcesUserName", self.aces_user_name)
628+
if self.calibration_info is not None:
629+
xml.append(self.calibration_info.to_xml())
630+
return xml
631+
523632

524633
@dataclass
525-
class LogParams(XMLParsable):
634+
class LogParams(XMLParsable, XMLWritable):
526635
"""
527636
Represent a *LogParams* element for a :class:`colour_clf_io.Log`
528637
*Process Node*.
@@ -649,6 +758,26 @@ def from_xml(
649758

650759
return LogParams(channel=channel, **attributes)
651760

761+
def to_xml(self) -> lxml.etree._Element:
762+
"""
763+
Serialise this object as an XML object.
764+
765+
Returns
766+
-------
767+
:class:`lxml.etree._Element`
768+
"""
769+
xml = lxml.etree.Element("LogParams")
770+
set_attr_if_not_none(xml, "base", self.base)
771+
set_attr_if_not_none(xml, "logSideSlope", self.log_side_slope)
772+
set_attr_if_not_none(xml, "logSideOffset", self.log_side_offset)
773+
set_attr_if_not_none(xml, "linSideSlope", self.lin_side_slope)
774+
set_attr_if_not_none(xml, "linSideOffset", self.lin_side_offset)
775+
set_attr_if_not_none(xml, "linSideBreak", self.lin_side_break)
776+
set_attr_if_not_none(xml, "linearSlope", self.linear_slope)
777+
if self.channel is not None:
778+
xml.set("channel", self.channel.value)
779+
return xml
780+
652781
@classmethod
653782
def default(cls) -> LogParams:
654783
"""
@@ -673,7 +802,7 @@ def default(cls) -> LogParams:
673802

674803

675804
@dataclass
676-
class ExponentParams(XMLParsable):
805+
class ExponentParams(XMLParsable, XMLWritable):
677806
"""
678807
Represent a *ExponentParams* element for a :class:`colour_clf_io.Exponent`
679808
*Process Node*.
@@ -772,6 +901,21 @@ def from_xml(
772901

773902
return ExponentParams(channel=channel, exponent=exponent, **attributes)
774903

904+
def to_xml(self) -> lxml.etree._Element:
905+
"""
906+
Serialise this object as an XML object.
907+
908+
Returns
909+
-------
910+
:class:`lxml.etree._Element`
911+
"""
912+
xml = lxml.etree.Element("ExponentParams")
913+
set_attr_if_not_none(xml, "exponent", self.exponent)
914+
set_attr_if_not_none(xml, "offset", self.offset)
915+
if self.channel is not None:
916+
xml.set("channel", self.channel.value)
917+
return xml
918+
775919
@classmethod
776920
def default(cls) -> ExponentParams:
777921
"""

0 commit comments

Comments
 (0)