Coverage for colour/io/luts/cinespace_csp.py: 100%
157 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-15 19:01 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-15 19:01 +1300
1"""
2Cinespace .csp LUT Format Input / Output Utilities
3==================================================
5Define the *Cinespace* *.csp* *LUT* format related input / output utilities
6objects:
8- :func:`colour.io.read_LUT_Cinespace`
9- :func:`colour.io.write_LUT_Cinespace`
11References
12----------
13- :cite:`RisingSunResearch` : Rising Sun Research. (n.d.). cineSpace LUT
14 Library. Retrieved November 30, 2018, from
15 https://sourceforge.net/projects/cinespacelutlib/
16"""
18from __future__ import annotations
20import typing
22import numpy as np
24if typing.TYPE_CHECKING:
25 from colour.hints import ArrayLike, List, NDArrayFloat, NDArrayInt, PathLike
27from colour.io.luts import LUT1D, LUT3D, LUT3x1D, LUTSequence
28from colour.utilities import (
29 as_float_array,
30 as_int_array,
31 attest,
32 format_array_as_row,
33 tsplit,
34 tstack,
35)
37__author__ = "Colour Developers"
38__copyright__ = "Copyright 2013 Colour Developers"
39__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
40__maintainer__ = "Colour Developers"
41__email__ = "colour-developers@colour-science.org"
42__status__ = "Production"
44__all__ = [
45 "read_LUT_Cinespace",
46 "write_LUT_Cinespace",
47]
50def read_LUT_Cinespace(path: str | PathLike) -> LUT3x1D | LUT3D | LUTSequence:
51 """
52 Read the specified *Cinespace* *.csp* *LUT* file.
54 Parameters
55 ----------
56 path
57 *LUT* file path.
59 Returns
60 -------
61 :class:`colour.LUT3x1D` or :class:`colour.LUT3D` or \
62 :class:`colour.LUTSequence`
63 :class:`LUT3x1D`, :class:`LUT3D`, or :class:`LUTSequence`
64 class instance.
66 References
67 ----------
68 :cite:`RisingSunResearch`
70 Examples
71 --------
72 Reading a 3x1D *Cinespace* *.csp* *LUT*:
74 >>> import os
75 >>> path = os.path.join(
76 ... os.path.dirname(__file__),
77 ... "tests",
78 ... "resources",
79 ... "cinespace",
80 ... "ACES_Proxy_10_to_ACES.csp",
81 ... )
82 >>> print(read_LUT_Cinespace(path))
83 LUT3x1D - ACES Proxy 10 to ACES
84 -------------------------------
85 <BLANKLINE>
86 Dimensions : 2
87 Domain : [[ 0. 0. 0.]
88 [ 1. 1. 1.]]
89 Size : (32, 3)
91 Reading a 3D *Cinespace* *.csp* *LUT*:
93 >>> path = os.path.join(
94 ... os.path.dirname(__file__),
95 ... "tests",
96 ... "resources",
97 ... "cinespace",
98 ... "Colour_Correct.csp",
99 ... )
100 >>> print(read_LUT_Cinespace(path))
101 LUT3D - Generated by Foundry::LUT
102 ---------------------------------
103 <BLANKLINE>
104 Dimensions : 3
105 Domain : [[ 0. 0. 0.]
106 [ 1. 1. 1.]]
107 Size : (4, 4, 4, 3)
108 """
110 unity_range = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
112 def _parse_metadata_section(metadata: list) -> tuple:
113 """Parse the metadata at specified lines."""
115 return (metadata[0], metadata[1:]) if len(metadata) > 0 else ("", [])
117 def _parse_domain_section(lines: List[str]) -> NDArrayFloat:
118 """Parse the domain at specified lines."""
120 pre_LUT_size = max(int(lines[i]) for i in [0, 3, 6])
121 pre_LUT = [as_float_array(lines[i].split()) for i in [1, 2, 4, 5, 7, 8]]
123 pre_LUT_padded = []
124 for array in pre_LUT:
125 if len(array) != pre_LUT_size:
126 pre_LUT_padded.append(
127 np.pad(
128 array,
129 (0, pre_LUT_size - array.shape[0]),
130 mode="constant",
131 constant_values=np.nan,
132 )
133 )
134 else:
135 pre_LUT_padded.append(array)
137 return np.asarray(pre_LUT_padded)
139 def _parse_table_section(lines: list[str]) -> tuple[NDArrayInt, NDArrayFloat]:
140 """Parse the table at specified lines."""
142 size = as_int_array(lines[0].split())
143 table = as_float_array([line.split() for line in lines[1:]])
145 return size, table
147 with open(path) as csp_file:
148 lines = csp_file.readlines()
149 attest(len(lines) > 0, '"LUT" is empty!')
150 lines = [line.strip() for line in lines if line.strip()]
152 header = lines[0]
153 attest(header == "CSPLUTV100", '"LUT" header is invalid!')
155 kind = lines[1]
156 attest(kind in ("1D", "3D"), '"LUT" type must be "1D" or "3D"!')
158 is_3D = kind == "3D"
160 seek = 2
161 metadata = []
162 is_metadata = False
163 for i, line in enumerate(lines[2:]):
164 line = line.strip() # noqa: PLW2901
165 if line == "BEGIN METADATA":
166 is_metadata = True
167 continue
168 if line == "END METADATA":
169 seek += i
170 break
172 if is_metadata:
173 metadata.append(line)
175 title, comments = _parse_metadata_section(metadata)
177 seek += 1
178 pre_LUT = _parse_domain_section(lines[seek : seek + 9])
180 seek += 9
181 size, table = _parse_table_section(lines[seek:])
183 attest(np.prod(size) == len(table), '"LUT" table size is invalid!')
185 LUT: LUT3x1D | LUT3D | LUTSequence
186 if (
187 is_3D
188 and pre_LUT.shape == (6, 2)
189 and np.array_equal(np.transpose(np.reshape(pre_LUT, (3, 4)))[2:4], unity_range)
190 ):
191 table = np.reshape(table, (size[0], size[1], size[2], 3), order="F")
192 LUT = LUT3D(
193 domain=np.transpose(np.reshape(pre_LUT, (3, 4)))[0:2],
194 name=title,
195 comments=comments,
196 table=table,
197 )
199 elif (
200 not is_3D
201 and pre_LUT.shape == (6, 2)
202 and np.array_equal(np.transpose(np.reshape(pre_LUT, (3, 4)))[2:4], unity_range)
203 ):
204 LUT = LUT3x1D(
205 domain=np.reshape(pre_LUT, (3, 4)).transpose()[0:2],
206 name=title,
207 comments=comments,
208 table=table,
209 )
211 elif is_3D:
212 pre_domain = tstack((pre_LUT[0], pre_LUT[2], pre_LUT[4]))
213 pre_table = tstack((pre_LUT[1], pre_LUT[3], pre_LUT[5]))
214 shaper_name = f"{title} - Shaper"
215 cube_name = f"{title} - Cube"
216 table = np.reshape(table, (size[0], size[1], size[2], 3), order="F")
218 LUT = LUTSequence(
219 LUT3x1D(pre_table, shaper_name, pre_domain),
220 LUT3D(table, cube_name, comments=comments),
221 )
223 elif not is_3D:
224 pre_domain = tstack((pre_LUT[0], pre_LUT[2], pre_LUT[4]))
225 pre_table = tstack((pre_LUT[1], pre_LUT[3], pre_LUT[5]))
227 if table.shape == (2, 3):
228 table_max = table[1]
229 table_min = table[0]
230 pre_table *= table_max - table_min
231 pre_table += table_min
233 LUT = LUT3x1D(pre_table, title, pre_domain, comments=comments)
234 else:
235 pre_name = f"{title} - PreLUT"
236 table_name = f"{title} - Table"
238 LUT = LUTSequence(
239 LUT3x1D(pre_table, pre_name, pre_domain),
240 LUT3x1D(table, table_name, comments=comments),
241 )
243 return LUT
246def write_LUT_Cinespace(
247 LUT: LUT1D | LUT3x1D | LUT3D | LUTSequence, path: str | PathLike, decimals: int = 7
248) -> bool:
249 """
250 Write the specified *LUT* to the specified *Cinespace* *.csp* *LUT* file.
252 Parameters
253 ----------
254 LUT
255 :class:`LUT1D`, :class:`LUT3x1D` or :class:`LUT3D` or
256 :class:`LUTSequence` class instance to write at the specified path.
257 path
258 *LUT* file path.
259 decimals
260 Number of decimal places for formatting numeric values.
262 Returns
263 -------
264 :class:`bool`
265 Definition success.
267 References
268 ----------
269 :cite:`RisingSunResearch`
271 Examples
272 --------
273 Writing a 3x1D *Cinespace* *.csp* *LUT*:
275 >>> from colour.algebra import spow
276 >>> domain = np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]])
277 >>> LUT = LUT3x1D(
278 ... spow(LUT3x1D.linear_table(16, domain), 1 / 2.2),
279 ... "My LUT",
280 ... domain,
281 ... comments=["A first comment.", "A second comment."],
282 ... )
283 >>> write_LUT_Cinespace(LUT, "My_LUT.cube") # doctest: +SKIP
285 Writing a 3D *Cinespace* *.csp* *LUT*:
287 >>> domain = np.array([[-0.1, -0.2, -0.4], [1.5, 3.0, 6.0]])
288 >>> LUT = LUT3D(
289 ... spow(LUT3D.linear_table(16, domain), 1 / 2.2),
290 ... "My LUT",
291 ... domain,
292 ... comments=["A first comment.", "A second comment."],
293 ... )
294 >>> write_LUT_Cinespace(LUT, "My_LUT.cube") # doctest: +SKIP
295 """
297 path = str(path)
299 has_3D, has_3x1D = False, False
301 if isinstance(LUT, LUTSequence):
302 attest(
303 len(LUT) == 2
304 and isinstance(LUT[0], (LUT1D, LUT3x1D))
305 and isinstance(LUT[1], LUT3D),
306 '"LUTSequence" must be "1D + 3D" or "3x1D + 3D"!',
307 )
308 LUT[0] = LUT[0].convert(LUT3x1D) if isinstance(LUT[0], LUT1D) else LUT[0]
309 name = f"{LUT[0].name} - {LUT[1].name}"
310 has_3x1D = True
311 has_3D = True
313 elif isinstance(LUT, LUT1D):
314 name = LUT.name
315 has_3x1D = True
316 LUT = LUTSequence(LUT.convert(LUT3x1D), LUT3D())
318 elif isinstance(LUT, LUT3x1D):
319 name = LUT.name
320 has_3x1D = True
321 LUT = LUTSequence(LUT, LUT3D())
323 elif isinstance(LUT, LUT3D):
324 name = LUT.name
325 has_3D = True
326 LUT = LUTSequence(LUT3x1D(), LUT)
328 else:
329 error = "LUT must be 1D, 3x1D, 3D, 1D + 3D or 3x1D + 3D!"
331 raise TypeError(error)
333 if has_3x1D:
334 attest(
335 2 <= LUT[0].size <= 65536,
336 "Shaper size must be in domain [2, 65536]!",
337 )
338 if has_3D:
339 attest(2 <= LUT[1].size <= 256, "Cube size must be in domain [2, 256]!")
341 def _ragged_size(table: ArrayLike) -> list:
342 """Return the ragged size of the specified table."""
344 R, G, B = tsplit(table)
346 R_len = R.shape[-1] - np.sum(np.isnan(R))
347 G_len = G.shape[-1] - np.sum(np.isnan(G))
348 B_len = B.shape[-1] - np.sum(np.isnan(B))
350 return [R_len, G_len, B_len]
352 with open(path, "w") as csp_file:
353 csp_file.write("CSPLUTV100\n")
355 if has_3D:
356 csp_file.write("3D\n\n")
357 else:
358 csp_file.write("1D\n\n")
360 csp_file.write("BEGIN METADATA\n")
361 csp_file.write(f"{name}\n")
363 if LUT[0].comments:
364 csp_file.writelines(f"{comment}\n" for comment in LUT[0].comments)
366 if LUT[1].comments:
367 csp_file.writelines(f"{comment}\n" for comment in LUT[1].comments)
369 csp_file.write("END METADATA\n\n")
371 if has_3D:
372 if has_3x1D:
373 for i in range(3):
374 size = (
375 _ragged_size(LUT[0].domain)[i]
376 if LUT[0].is_domain_explicit()
377 else LUT[0].size
378 )
380 csp_file.write(f"{size}\n")
382 for j in range(size):
383 entry = (
384 LUT[0].domain[j][i]
385 if LUT[0].is_domain_explicit()
386 else (
387 LUT[0].domain[0][i]
388 + j
389 * (LUT[0].domain[1][i] - LUT[0].domain[0][i])
390 / (LUT[0].size - 1)
391 )
392 )
394 csp_file.write(f"{format_array_as_row(entry, decimals)} ")
396 csp_file.write("\n")
398 for j in range(size):
399 entry = LUT[0].table[j][i]
400 csp_file.write(f"{format_array_as_row(entry, decimals)} ")
402 csp_file.write("\n")
403 else:
404 for i in range(3):
405 csp_file.write("2\n")
406 domain = format_array_as_row(
407 [LUT[1].domain[0][i], LUT[1].domain[1][i]], decimals
408 )
409 csp_file.write(f"{domain}\n")
410 csp_file.write(f"{format_array_as_row([0, 1], decimals)}\n")
412 csp_file.write(
413 f"\n{LUT[1].table.shape[0]} "
414 f"{LUT[1].table.shape[1]} "
415 f"{LUT[1].table.shape[2]}\n"
416 )
417 table = np.reshape(LUT[1].table, (-1, 3), order="F")
419 csp_file.writelines(
420 f"{format_array_as_row(array, decimals)}\n" for array in table
421 )
422 else:
423 for i in range(3):
424 csp_file.write("2\n")
425 domain = format_array_as_row(
426 [LUT[0].domain[0][i], LUT[0].domain[1][i]], decimals
427 )
428 csp_file.write(f"{domain}\n")
429 csp_file.write("0.0 1.0\n")
430 csp_file.write(f"\n{LUT[0].size}\n")
431 table = LUT[0].table
433 csp_file.writelines(
434 f"{format_array_as_row(array, decimals)}\n" for array in table
435 )
437 return True