Coverage for io/luts/resolve_cube.py: 87%

1"""

2 Resolve .cube LUT Format Input / Output Utilities

3=================================================

4

5 Define the *Resolve* *.cube* *LUT* format related input / output utilities

6 objects:

7

8- :func:`colour.io.read_LUT_ResolveCube`

9- :func:`colour.io.write_LUT_ResolveCube`

10

11 References

12----------

13- :cite:`Chamberlain2015` : Chamberlain, P. (2015). LUT documentation (to

14 create from another program). Retrieved August 23, 2018, from

15 https://forum.blackmagicdesign.com/viewtopic.php?f=21&t=40284#p232952

16"""

17

18 from __future__ import annotations

19

20 import typing

21

22 import numpy as np

23

24 if typing.TYPE_CHECKING:

25 from colour.hints import PathLike

26

27 from colour.io.luts import LUT1D, LUT3D, LUT3x1D, LUTSequence

28 from colour.io.luts.common import path_to_title

29 from colour.utilities import (

30 as_float_array,

31 as_int_scalar,

32 attest,

33 format_array_as_row,

34 tstack,

35)

36

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"

43

44 __all__ = [

45 "read_LUT_ResolveCube",

46 "write_LUT_ResolveCube",

47]

48

49

50 def read_LUT_ResolveCube(path: str | PathLike) -> LUT3x1D | LUT3D | LUTSequence:

51 """

52 Read the specified *Resolve* *.cube* *LUT* file.

53

54 Read and parse a *DaVinci Resolve* *.cube* lookup table file, which may

55 contain a 1D LUT, a 3D LUT, or a sequence of both. The *.cube* format

56 supports configurable precision and domain ranges, making it suitable for

57 colour grading and colour space transformations.

58

59 Parameters

60 ----------

61 path

62 Path to the *.cube* *LUT* file to read.

63

64 Returns

65 -------

66 :class:`colour.LUT3x1D` or :class:`colour.LUT3D` or :class:`colour.LUTSequence`

67 :class:`LUT3x1D` instance for 1D shaper LUTs, :class:`LUT3D` instance

68 for 3D colour transformation LUTs, or :class:`LUTSequence` instance

69 when the file contains both shaper and 3D LUT data.

70

71 References

72 ----------

73 :cite:`Chamberlain2015`

74

75 Examples

76 --------

77 Reading a 3x1D *Resolve* *.cube* *LUT*:

78

79 >>> import os

80 >>> path = os.path.join(

81 ... os.path.dirname(__file__),

82 ... "tests",

83 ... "resources",

84 ... "resolve_cube",

85 ... "ACES_Proxy_10_to_ACES.cube",

86 ... )

87 >>> print(read_LUT_ResolveCube(path))

88 LUT3x1D - ACES Proxy 10 to ACES

89 -------------------------------

90 <BLANKLINE>

91 Dimensions : 2

92 Domain : [[ 0. 0. 0.]

93 [ 1. 1. 1.]]

94 Size : (32, 3)

95

96 Reading a 3D *Resolve* *.cube* *LUT*:

97

98 >>> path = os.path.join(

99 ... os.path.dirname(__file__),

100 ... "tests",

101 ... "resources",

102 ... "resolve_cube",

103 ... "Colour_Correct.cube",

104 ... )

105 >>> print(read_LUT_ResolveCube(path))

106 LUT3D - Generated by Foundry::LUT

107 ---------------------------------

108 <BLANKLINE>

109 Dimensions : 3

110 Domain : [[ 0. 0. 0.]

111 [ 1. 1. 1.]]

112 Size : (4, 4, 4, 3)

113

114 Reading a 3D *Resolve* *.cube* *LUT* with comments:

115

116 >>> path = os.path.join(

117 ... os.path.dirname(__file__),

118 ... "tests",

119 ... "resources",

120 ... "resolve_cube",

121 ... "Demo.cube",

122 ... )

123 >>> print(read_LUT_ResolveCube(path))

124 LUT3x1D - Demo

125 --------------

126 <BLANKLINE>

127 Dimensions : 2

128 Domain : [[ 0. 0. 0.]

129 [ 3. 3. 3.]]

130 Size : (3, 3)

131 Comment 01 : Comments can't go anywhere

132

133 Reading a 3x1D + 3D *Resolve* *.cube* *LUT*:

134

135 >>> path = os.path.join(

136 ... os.path.dirname(__file__),

137 ... "tests",

138 ... "resources",

139 ... "resolve_cube",

140 ... "Three_Dimensional_Table_With_Shaper.cube",

141 ... )

142 >>> print(read_LUT_ResolveCube(path))

143 LUT Sequence

144 ------------

145 <BLANKLINE>

146 Overview

147 <BLANKLINE>

148 LUT3x1D --> LUT3D

149 <BLANKLINE>

150 Operations

151 <BLANKLINE>

152 LUT3x1D - LUT3D with My Shaper - Shaper

153 ---------------------------------------

154 <BLANKLINE>

155 Dimensions : 2

156 Domain : [[-0.1 -0.1 -0.1]

157 [ 3. 3. 3. ]]

158 Size : (10, 3)

159 <BLANKLINE>

160 LUT3D - LUT3D with My Shaper - Cube

161 -----------------------------------

162 <BLANKLINE>

163 Dimensions : 3

164 Domain : [[-0.1 -0.1 -0.1]

165 [ 3. 3. 3. ]]

166 Size : (3, 3, 3, 3)

167 Comment 01 : A first "Shaper" comment.

168 Comment 02 : A second "Shaper" comment.

169 Comment 03 : A first "LUT3D" comment.

170 Comment 04 : A second "LUT3D" comment.

171 """

172

173 path = str(path)

174

175 title = path_to_title(path)

176 domain_3x1D, domain_3D = None, None

177 size_3x1D: int = 2

178 size_3D: int = 2

179 data = []

180 comments = []

181 has_3x1D, has_3D = False, False

182

183 with open(path) as cube_file:

184 lines = cube_file.readlines()

185 for line in lines:

186 line = line.strip() # noqa: PLW2901

187

188 if len(line) == 0:

189 continue

190

191 if line.startswith("#"):

192 comments.append(line[1:].strip())

193 continue

194

195 tokens = line.split()

196 if tokens[0] == "TITLE":

197 title = " ".join(tokens[1:])[1:-1]

198 elif tokens[0] == "LUT_1D_INPUT_RANGE":

199 domain_3x1D = tstack([tokens[1:], tokens[1:], tokens[1:]])

200 elif tokens[0] == "LUT_3D_INPUT_RANGE":

201 domain_3D = tstack([tokens[1:], tokens[1:], tokens[1:]])

202 elif tokens[0] == "LUT_1D_SIZE":

203 has_3x1D = True

204 size_3x1D = as_int_scalar(tokens[1])

205 elif tokens[0] == "LUT_3D_SIZE":

206 has_3D = True

207 size_3D = as_int_scalar(tokens[1])

208 else:

209 data.append(tokens)

210

211 table = as_float_array(data)

212

213 LUT: LUT3x1D | LUT3D | LUTSequence

214 if has_3x1D and has_3D:

215 table_1D = table[: int(size_3x1D)]

216 # The lines of table data shall be in ascending index order,

217 # with the first component index (Red) changing most rapidly,

218 # and the last component index (Blue) changing least rapidly.

219 table_3D = np.reshape(

220 table[int(size_3x1D) :], (size_3D, size_3D, size_3D, 3), order="F"

221 )

222 LUT = LUTSequence(

223 LUT3x1D(

224 table_1D,

225 f"{title} - Shaper",

226 domain_3x1D,

227 ),

228 LUT3D(

229 table_3D,

230 f"{title} - Cube",

231 domain_3D,

232 comments=comments,

233 ),

234 )

235 elif has_3x1D:

236 LUT = LUT3x1D(table, title, domain_3x1D, comments=comments)

237 elif has_3D:

238 # The lines of table data shall be in ascending index order,

239 # with the first component index (Red) changing most rapidly,

240 # and the last component index (Blue) changing least rapidly.

241 table = np.reshape(table, (size_3D, size_3D, size_3D, 3), order="F")

242 LUT = LUT3D(table, title, domain_3D, comments=comments)

243

244 return LUT

245

246

247 def write_LUT_ResolveCube(

248 LUT: LUT1D | LUT3x1D | LUT3D | LUTSequence,

249 path: str | PathLike,

250 decimals: int = 7,

251) -> bool:

252 """

253 Write the specified *LUT* to the specified *Resolve* *.cube* *LUT* file.

254

255 Parameters

256 ----------

257 LUT

258 :class:`LUT1D`, :class:`LUT3x1D` or :class:`LUT3D` or

259 :class:`LUTSequence` class instance to write at the specified path.

260 path

261 *LUT* file path.

262 decimals

263 Number of decimal places for formatting numeric values.

264

265 Returns

266 -------

267 :class:`bool`

268 Definition success.

269

270 References

271 ----------

272 :cite:`Chamberlain2015`

273

274 Examples

275 --------

276 Writing a 3x1D *Resolve* *.cube* *LUT*:

277

278 >>> from colour.algebra import spow

279 >>> domain = np.array([[-0.1, -0.1, -0.1], [3.0, 3.0, 3.0]])

280 >>> LUT = LUT3x1D(

281 ... spow(LUT3x1D.linear_table(16, domain), 1 / 2.2),

282 ... "My LUT",

283 ... domain,

284 ... comments=["A first comment.", "A second comment."],

285 ... )

286 >>> write_LUT_ResolveCube(LUT, "My_LUT.cube") # doctest: +SKIP

287

288 Writing a 3D *Resolve* *.cube* *LUT*:

289

290 >>> domain = np.array([[-0.1, -0.1, -0.1], [3.0, 3.0, 3.0]])

291 >>> LUT = LUT3D(

292 ... spow(LUT3D.linear_table(16, domain), 1 / 2.2),

293 ... "My LUT",

294 ... domain,

295 ... comments=["A first comment.", "A second comment."],

296 ... )

297 >>> write_LUT_ResolveCube(LUT, "My_LUT.cube") # doctest: +SKIP

298

299 Writing a 3x1D + 3D *Resolve* *.cube* *LUT*:

300

301 >>> from colour.models import RGB_to_HSV, HSV_to_RGB

302 >>> from colour.utilities import tstack

303 >>> def rotate_hue(a, angle):

304 ... H, S, V = RGB_to_HSV(a)

305 ... H += angle / 360

306 ... H[H > 1] -= 1

307 ... H[H < 0] += 1

308 ... return HSV_to_RGB([H, S, V])

309 >>> domain = np.array([[-0.1, -0.1, -0.1], [3.0, 3.0, 3.0]])

310 >>> shaper = LUT3x1D(

311 ... spow(LUT3x1D.linear_table(10, domain), 1 / 2.2),

312 ... "My Shaper",

313 ... domain,

314 ... comments=[

315 ... 'A first "Shaper" comment.',

316 ... 'A second "Shaper" comment.',

317 ... ],

318 ... )

319 >>> LUT = LUT3D(

320 ... rotate_hue(LUT3D.linear_table(3, domain), 10),

321 ... "LUT3D with My Shaper",

322 ... domain,

323 ... comments=['A first "LUT3D" comment.', 'A second "LUT3D" comment.'],

324 ... )

325 >>> LUT_sequence = LUTSequence(shaper, LUT)

326 >>> write_LUT_ResolveCube(LUT_sequence, "My_LUT.cube") # doctest: +SKIP

327 """

328

329 path = str(path)

330

331 has_3D, has_3x1D = False, False

332

333 if isinstance(LUT, LUTSequence):

334 attest(

335 len(LUT) == 2

336 and isinstance(LUT[0], (LUT1D, LUT3x1D))

337 and isinstance(LUT[1], LUT3D),

338 "LUTSequence must be 1D + 3D or 3x1D + 3D!",

339 )

340

341 if isinstance(LUT[0], LUT1D):

342 LUT[0] = LUT[0].convert(LUT3x1D)

343

344 name = f"{LUT[0].name} - {LUT[1].name}"

345 has_3x1D = True

346 has_3D = True

347 elif isinstance(LUT, LUT1D):

348 name = LUT.name

349 has_3x1D = True

350 LUT = LUTSequence(LUT.convert(LUT3x1D), LUT3D())

351 elif isinstance(LUT, LUT3x1D):

352 name = LUT.name

353 has_3x1D = True

354 LUT = LUTSequence(LUT, LUT3D())

355 elif isinstance(LUT, LUT3D):

356 name = LUT.name

357 has_3D = True

358 LUT = LUTSequence(LUT3x1D(), LUT)

359 else:

360 error = "LUT must be 1D, 3x1D, 3D, 1D + 3D or 3x1D + 3D!"

361

362 raise TypeError(error)

363

364 for i in range(2):

365 attest(not LUT[i].is_domain_explicit(), '"LUT" domain must be implicit!')

366

367 attest(

368 (len(np.unique(LUT[0].domain)) == 2 and len(np.unique(LUT[1].domain)) == 2),

369 '"LUT" domain must be 1D!',

370 )

371

372 if has_3x1D:

373 attest(

374 2 <= LUT[0].size <= 65536,

375 "Shaper size must be in domain [2, 65536]!",

376 )

377 if has_3D:

378 attest(2 <= LUT[1].size <= 256, "Cube size must be in domain [2, 256]!")

379

380 with open(path, "w") as cube_file:

381 cube_file.write(f'TITLE "{name}"\n')

382

383 if LUT[0].comments:

384 cube_file.writelines(f"# {comment}\n" for comment in LUT[0].comments)

385

386 if LUT[1].comments:

387 cube_file.writelines(f"# {comment}\n" for comment in LUT[1].comments)

388

389 default_domain = np.array([[0, 0, 0], [1, 1, 1]])

390

391 if has_3x1D:

392 cube_file.write(f"LUT_1D_SIZE {LUT[0].table.shape[0]}\n")

393 if not np.array_equal(LUT[0].domain, default_domain):

394 input_range = format_array_as_row(

395 [LUT[0].domain[0][0], LUT[0].domain[1][0]], decimals

396 )

397 cube_file.write(f"LUT_1D_INPUT_RANGE {input_range}\n")

398

399 if has_3D:

400 cube_file.write(f"LUT_3D_SIZE {LUT[1].table.shape[0]}\n")

401 if not np.array_equal(LUT[1].domain, default_domain):

402 input_range = format_array_as_row(

403 [LUT[1].domain[0][0], LUT[1].domain[1][0]], decimals

404 )

405 cube_file.write(f"LUT_3D_INPUT_RANGE {input_range}\n")

406

407 if has_3x1D:

408 table = LUT[0].table

409 cube_file.writelines(

410 f"{format_array_as_row(vector, decimals)}\n" for vector in table

411 )

412 cube_file.write("\n")

413

414 if has_3D:

415 table = np.reshape(LUT[1].table, (-1, 3), order="F")

416 cube_file.writelines(

417 f"{format_array_as_row(vector, decimals)}\n" for vector in table

418 )

419

420 return True