mucolor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,318 @@
1
+ Metadata-Version: 2.5
2
+ Name: mucolor
3
+ Version: 0.1.0
4
+ Summary: Minimal pure-python colorimetry: gamuts, transfer functions and colorspace conversion with no dependencies.
5
+ Project-URL: Source, https://github.com/jedypod/mucolor
6
+ Author-email: Jed Smith <jed.coma316@passmail.net>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.7
10
+ Description-Content-Type: text/markdown
11
+
12
+ # mucolor
13
+
14
+ **μcolor**, "micro color". The "mu" is the Greek letter μ, the SI symbol for micro: one
15
+ millionth. The name is the goal: the colorimetry a pipeline needs, made as small as it can be.
16
+
17
+ Minimal pure-python colorimetry calculations, transfer functions and colorspace conversions.
18
+
19
+ Convert stimuli between camera, working and display colorspaces. Pure Python, one file,
20
+ no dependencies.
21
+
22
+ ## Install
23
+
24
+ mucolor is a single file. Either copy `mucolor.py` next to your own code, or install it
25
+ from this folder:
26
+
27
+ ```
28
+ pip install .
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ ```python
34
+ import mucolor as mc
35
+
36
+ # Convert one RGB triplet: (red, green, blue), from colorspace, to colorspace
37
+ mc.convert((0.41, 0.41, 0.41), 'sony-sgamut3-slog3', 'acescg')
38
+ # -> (0.179, 0.179, 0.179)
39
+ ```
40
+
41
+ An RGB triplet is a tuple of three numbers: `(red, green, blue)`. The result is a tuple too.
42
+
43
+ ## Colorspace names
44
+
45
+ Every colorspace has a long name, a short name and an alias. You can use any of them, and
46
+ upper or lower case does not matter. These all result in the same colorspace conversion:
47
+
48
+ ```python
49
+ rgb = (0.41, 0.41, 0.41)
50
+
51
+ mc.convert(rgb, 'Sony - S-Gamut3 - S-Log3', 'acescg')
52
+ mc.convert(rgb, 'sony-sgamut3-slog3', 'acescg')
53
+ mc.convert(rgb, 'sg3lg3', 'acescg')
54
+ ```
55
+
56
+ The [full list](#colorspaces) is at the end of this page. To list them from Python:
57
+
58
+ ```python
59
+ for cs in mc.colorspaces():
60
+ print(cs.shortname, '|', cs.name)
61
+ ```
62
+
63
+ If you misspell a name, the error suggests close matches:
64
+
65
+ ```
66
+ KeyError: "unknown colorspace 'slog3', did you mean 'smlg', 'sg3ln'?"
67
+ ```
68
+
69
+ ## Converting many stimuli
70
+
71
+ `convert` looks up both colorspaces every time you call it. To convert a lot of stimuli
72
+ between the same two colorspaces, make a converter once and reuse it:
73
+
74
+ ```python
75
+ slog3_to_acescg = mc.converter('sony-sgamut3-slog3', 'acescg')
76
+
77
+ slog3_to_acescg((0.41, 0.41, 0.41)) # -> (0.179, 0.179, 0.179)
78
+ slog3_to_acescg((0.6, 0.5, 0.4)) # -> (1.107, 0.434, 0.160)
79
+ ```
80
+
81
+ For bigger challenges, use [numpy](#numpy-arrays) which is much faster to process.
82
+
83
+ ## Transfer functions on their own
84
+
85
+ A transfer function turns linear signal into code values and back. Each one has two methods:
86
+
87
+ - `encode`: linear signal -> code value
88
+ - `decode`: code value -> linear signal
89
+
90
+ They work on one number at a time, not an RGB triplet:
91
+
92
+ ```python
93
+ mc.sony_slog3.encode(0.18) # -> 0.411 (18% grey in S-Log3)
94
+ mc.sony_slog3.decode(0.41) # -> 0.179
95
+ ```
96
+
97
+ ## Matrices
98
+
99
+ `matrix` gives the 3x3 matrix between the linear versions of two colorspaces, as a tuple of
100
+ three rows:
101
+
102
+ ```python
103
+ mc.matrix('p3d65-lin', 'rec709-lin')
104
+ # -> ((1.2249, -0.2249, 0.0), (-0.0421, 1.0421, 0.0), (-0.0196, -0.0786, 1.0983))
105
+ ```
106
+
107
+ ## numpy arrays
108
+
109
+ The functions above work on one RGB triplet at a time, which is slow for large jobs such as
110
+ baking a LUT or converting an image. If you have [numpy](https://numpy.org) installed,
111
+ add `np=True` to work on whole arrays at once:
112
+
113
+ ```python
114
+ import numpy as np
115
+ import mucolor as mc
116
+
117
+ rgb = np.array([ # two RGB triplets
118
+ [0.41, 0.41, 0.41],
119
+ [0.6, 0.5, 0.4],
120
+ ])
121
+ mc.convert(rgb, 'sony-sgamut3-slog3', 'acescg', np=True)
122
+ # -> array([[0.179, 0.179, 0.179],
123
+ # [1.107, 0.434, 0.160]])
124
+ ```
125
+
126
+ `np=True` works the same way everywhere:
127
+
128
+ ```python
129
+ f = mc.converter('sony-sgamut3-slog3', 'acescg', np=True)
130
+ f(rgb)
131
+
132
+ mc.sony_slog3.decode(np.array([0.1, 0.41, 0.6]), np=True)
133
+ # -> array([0.0011, 0.179, 1.037])
134
+ ```
135
+
136
+ Things to know:
137
+
138
+ - The array can have any shape, as long as the last axis holds the three channels: a list
139
+ of RGB triplets `(n, 3)`, an image `(height, width, 3)`, or a LUT grid `(n, n, n, 3)`.
140
+ - You always get a new array back. Your input array is never changed.
141
+ - numpy is only needed when you use `np=True`. Without numpy installed, everything else
142
+ still works.
143
+ - A value a curve cannot handle, such as a negative number into a pure log curve, gives
144
+ `nan` and a numpy warning. Without `np=True` the same value raises an error.
145
+
146
+ ### Example: bake a 3D LUT to a .cube file
147
+
148
+ ```python
149
+ import numpy as np
150
+ import mucolor as mc
151
+
152
+ size = 33
153
+
154
+ # A grid of every input RGB triplet. In a .cube file red changes fastest, then green, then blue.
155
+ steps = np.linspace(0.0, 1.0, size)
156
+ b, g, r = np.meshgrid(steps, steps, steps, indexing='ij')
157
+ grid = np.stack([r, g, b], axis=-1) # shape (33, 33, 33, 3)
158
+
159
+ lut = mc.convert(grid, 'arri-wg4-logc4', 'davinci-wg-ilog', np=True)
160
+
161
+ with open('logc4_to_davinci.cube', 'w') as file:
162
+ file.write(f'LUT_3D_SIZE {size}\n')
163
+ for red, green, blue in lut.reshape(-1, 3):
164
+ file.write(f'{red:.6f} {green:.6f} {blue:.6f}\n')
165
+ ```
166
+
167
+ ## Conventions
168
+
169
+ - **Linear signal**: scene-linear 0.18 is 18% grey. For display colorspaces, linear 1.0 is
170
+ 100 nits, including `pq` and `hlg`.
171
+ - **No "tone mapping"**: mucolor converts colorimetry exactly. Converting a camera log
172
+ image straight to a display colorspace gives values above 1.0 for bright areas, the
173
+ same as a plain colorspace conversion in other software. It is not a viewing transform.
174
+ - **Chromatic adaptation**: when two colorspaces have different white points, mucolor
175
+ adapts between them with CAT02. Pass `cat='bradford'` to use Bradford, or `cat=None`
176
+ for none: `mc.convert((0.18, 0.18, 0.18), 'aces', 'rec709-lin', cat='bradford')`.
177
+ - **Gamuts** are flat tuples of xy chromaticities: `(rx, ry, gx, gy, bx, by, wx, wy)`.
178
+ `mc.matrix` and `mc.npm` accept these as well as colorspace names.
179
+
180
+ ## Colorspaces
181
+
182
+ The short name is shown. `encoding` tells you what kind of values the colorspace holds.
183
+
184
+ | Short name | Name | Encoding |
185
+ |---|---|---|
186
+ | `xyz-d65` | XYZ D65 | scene-linear |
187
+ | `xyz-e` | XYZ E | scene-linear |
188
+ | `aces` | ACES 2065-1 | scene-linear |
189
+ | `acescg` | ACEScg | scene-linear |
190
+ | `acescct` | ACEScct | log |
191
+ | `jplog2` | JPLog2 | log |
192
+ | `filmlight-egamut-lin` | Filmlight - E-Gamut - Linear | scene-linear |
193
+ | `filmlight-egamut-tlog` | Filmlight - E-Gamut - T-Log | log |
194
+ | `filmlight-egamut2-lin` | Filmlight - E-Gamut2 - Linear | scene-linear |
195
+ | `filmlight-egamut2-tlog` | Filmlight - E-Gamut2 - T-Log | log |
196
+ | `filmlight-egamut2-tloge` | Filmlight - E-Gamut2 - T-LogE | log |
197
+ | `davinci-wg-lin` | DaVinci - WideGamut - Linear | scene-linear |
198
+ | `davinci-wg-ilog` | DaVinci - WideGamut - Intermediate Log | log |
199
+ | `arri-wg3-lin` | Arri - WideGamut3 - Linear | scene-linear |
200
+ | `arri-wg3-logc3` | Arri - WideGamut3 - LogC3 | log |
201
+ | `arri-wg4-lin` | Arri - WideGamut4 - Linear | scene-linear |
202
+ | `arri-wg4-logc4` | Arri - WideGamut4 - LogC4 | log |
203
+ | `blackmagic-wg-lin` | Blackmagic - WideGamut - Linear | scene-linear |
204
+ | `blackmagic-wg-gen5log` | Blackmagic - WideGamut - Gen5 Log | log |
205
+ | `canon-cgamut-lin` | Canon - C-Gamut - Linear | scene-linear |
206
+ | `canon-cgamut-clog2` | Canon - C-Gamut - C-Log2 | log |
207
+ | `canon-cgamut-clog3` | Canon - C-Gamut - C-Log3 | log |
208
+ | `dji-dgamut-lin` | DJI - D-Gamut - Linear | scene-linear |
209
+ | `dji-dgamut-dlog` | DJI - D-Gamut - D-Log | log |
210
+ | `fuji-fgamut-lin` | Fujifilm - F-Gamut - Linear | scene-linear |
211
+ | `fuji-fgamut-flog` | Fujifilm - F-Gamut - F-Log | log |
212
+ | `fuji-fgamut-flog2` | Fujifilm - F-Gamut - F-Log2 | log |
213
+ | `fuji-fgamutc-lin` | Fujifilm - F-GamutC - Linear | scene-linear |
214
+ | `fuji-fgamutc-flog2` | Fujifilm - F-GamutC - F-Log2 | log |
215
+ | `cineon` | Kodak Cineon | log |
216
+ | `gopro-rec709-protune` | GoPro - Rec.709 - Protune | log |
217
+ | `leica-rec2020-lin` | Leica - Rec.2020 - Linear | scene-linear |
218
+ | `leica-rec2020-llog` | Leica - Rec.2020 - L-Log | log |
219
+ | `nikon-rec2020-lin` | Nikon - Rec.2020 - Linear | scene-linear |
220
+ | `nikon-rec2020-nlog` | Nikon - Rec.2020 - N-Log | log |
221
+ | `apple-rec2020-lin` | Apple - Rec.2020 - Linear | scene-linear |
222
+ | `apple-rec2020-alog` | Apple - Rec.2020 - Apple-Log | log |
223
+ | `samsung-rec2020-lin` | Samsung - Rec.2020 - Linear | scene-linear |
224
+ | `samsung-rec2020-slog` | Samsung - Rec.2020 - Samsung-Log | log |
225
+ | `xiaomi-rec2020-lin` | Xiaomi - Rec.2020 - Linear | scene-linear |
226
+ | `xiaomi-rec2020-xlog` | Xiaomi - Rec.2020 - Xiaomi-Log | log |
227
+ | `panasonic-vgamut-lin` | Panasonic - V-Gamut - Linear | scene-linear |
228
+ | `panasonic-vgamut-vlog` | Panasonic - V-Gamut - V-Log | log |
229
+ | `red-wg-lin` | Red - WideGamutRGB - Linear | scene-linear |
230
+ | `red-wg-log3g10` | Red - WideGamutRGB - Log3G10 | log |
231
+ | `sony-sgamut3-lin` | Sony - S-Gamut3 - Linear | scene-linear |
232
+ | `sony-sgamut3-slog3` | Sony - S-Gamut3 - S-Log3 | log |
233
+ | `sony-sgamut3cine-lin` | Sony - S-Gamut3.Cine - Linear | scene-linear |
234
+ | `sony-sgamut3cine-slog3` | Sony - S-Gamut3.Cine - S-Log3 | log |
235
+ | `sony-venice-sgamut3-lin` | Sony - Venice S-Gamut3 - Linear | scene-linear |
236
+ | `sony-venice-sgamut3-slog3` | Sony - Venice S-Gamut3 - S-Log3 | log |
237
+ | `sony-venice-sgamut3cine-lin` | Sony - Venice S-Gamut3.Cine - Linear | scene-linear |
238
+ | `sony-venice-sgamut3cine-slog3` | Sony - Venice S-Gamut3.Cine - S-Log3 | log |
239
+ | `rec709-lin` | Rec.709 - Linear | scene-linear |
240
+ | `rec2020-lin` | Rec.2020 - Linear | scene-linear |
241
+ | `p3d65-lin` | P3D65 - Linear | scene-linear |
242
+ | `rec1886` | Rec.1886 | sdr-video |
243
+ | `srgb-display` | sRGB Display | sdr-video |
244
+ | `srgb-encoding` | sRGB Encoding | sdr-video |
245
+ | `dci-p3d65` | DCIP3 D65 | sdr-video |
246
+ | `display-p3` | Display P3 | sdr-video |
247
+ | `rec2100-pq` | Rec2100 PQ | hdr-video |
248
+ | `rec2100-hlg` | Rec2100 HLG | hdr-video |
249
+
250
+ ## How the curves are defined
251
+
252
+ Most camera log curves are a `Log`: a natural log curve with an optional piecewise linear toe.
253
+
254
+ ```
255
+ y = gain*ln(x + offset) + bias x > toe
256
+ y = slope*(x - toe) + y0 x <= toe
257
+ ```
258
+
259
+ The linear toe always meets the log segment at the same value (C0 continuity). Its slope
260
+ is the one the whitepaper publishes, which is not always the slope of the log segment at
261
+ the join.
262
+
263
+ **Why the slope is kept.** Several curves are designed with a kink at the join: the linear
264
+ segment is steeper than the log segment there (S-Log3 by 27%, F-Log 4.7%, F-Log2 3.7%,
265
+ L-Log 1.3%, D-Log 1.2%). Forcing matching slopes (C1 continuity) would move every code
266
+ value in the toe away from what the camera records. A kink does no harm: the curve still
267
+ rises everywhere, so it decodes exactly. If you build a `Log` without a `slope`, it uses
268
+ the slope of the log segment.
269
+
270
+ **Why the join value is not kept.** Some whitepapers publish the linear segment's offset
271
+ as a separate constant, and with the rounded published values the two segments do not quite
272
+ meet: there is a small step at the join. With a step, decode cannot exactly undo encode
273
+ near the join, because code values inside the step belong to neither segment. F-Log shows
274
+ this in its own whitepaper: the linear segment ends at 0.100639, but its decode formula
275
+ switches segments at 0.100538. mucolor derives the offset so the segments meet, and in
276
+ the toe it differs from the whitepaper by the size of the step:
277
+
278
+ | Curve | Step at the join |
279
+ |---|---|
280
+ | Leica L-Log | 9.0e-4 (about 0.9 of a 10-bit code value) |
281
+ | Fujifilm F-Log | 1.0e-4 |
282
+ | DJI D-Log | 2.0e-6 |
283
+ | Panasonic V-Log | 3.1e-7 |
284
+ | ARRI LogC3 | 2.5e-7 |
285
+ | Fujifilm F-Log2 | 3.6e-8 |
286
+
287
+ Above the join, every curve matches its whitepaper to floating point precision. Below it,
288
+ the difference is a constant offset of exactly the step. `tests/test_external.py` lists the
289
+ tolerance used for each curve.
290
+
291
+ Each curve is built from the constants and formula of its whitepaper, so it can be
292
+ checked against the source:
293
+
294
+ ```python
295
+ # ARRI LogC3 EI800
296
+ # y = c*log10(a*x + b) + d x > cut
297
+ # y = e*x + f x <= cut
298
+ cut, a, b, c, d, e, f = 0.010591, 5.555556, 0.052272, 0.247190, 0.385537, 5.367655, 0.092809
299
+ arri_logc3 = Log.from_base(10, c, a, b, d, toe=cut, slope=e)
300
+ ```
301
+
302
+ Display curves are a `Power` with the same optional toe. Curves that fit neither form are
303
+ a `Curve` of two functions.
304
+
305
+ ## Tests
306
+
307
+ ```
308
+ python -m unittest discover -s tests
309
+ ```
310
+
311
+ `tests/test_external.py` compares every colorspace it can against
312
+ [colour-science](https://www.colour-science.org) and the OCIO built-in ACES studio config.
313
+ It is skipped unless those packages are installed (OCIO 2.6 or newer):
314
+
315
+ ```
316
+ pip install colour-science opencolorio
317
+ python -m unittest discover -s tests
318
+ ```
@@ -0,0 +1,5 @@
1
+ mucolor.py,sha256=w65bHc4bgRD2dj0FPQVYDA2x9qWfQ7wYQG2eEmyG1ZY,35776
2
+ mucolor-0.1.0.dist-info/METADATA,sha256=cL7mE0KDvEDIbNiuHCPxc_CXAc1QEWPkv4b5Nh-fC_A,12439
3
+ mucolor-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ mucolor-0.1.0.dist-info/licenses/LICENSE,sha256=6pUWAfL3ibwwmlcv1c32hXOBjTkODpviMp6iFazgfQA,1066
5
+ mucolor-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jed Smith
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mucolor.py ADDED
@@ -0,0 +1,819 @@
1
+ ''' mucolor - minimal pure-python colorimetry
2
+
3
+ import mucolor as mc
4
+ mc.convert((0.4, 0.4, 0.4), 'sony-sgamut3-slog3', 'rec709-lin')
5
+ f = mc.converter('arri-wg4-logc4', 'srgb-display') # resolve once, call many times
6
+ mc.matrix('p3d65-lin', 'rec709-lin') # 3x3 tuple of tuples
7
+ mc.sony_slog3.decode(0.41) # transfer functions on their own
8
+
9
+ Conventions
10
+ encode: linear signal -> code value. decode: code value -> linear signal.
11
+ Gamuts are flat xy chromaticity tuples: (rx, ry, gx, gy, bx, by, wx, wy).
12
+ Display linear 1.0 is 100 nits for every display transfer function.
13
+
14
+ numpy
15
+ Pass np=True to any encode, decode, converter or convert to process numpy arrays.
16
+ numpy is imported only then. RGB arrays have shape (..., 3) and results are float64.
17
+ Out of domain values give nan with a numpy warning where the scalar version raises.
18
+
19
+ f = mc.converter('arri-wg4-logc4', 'srgb-display', np=True)
20
+ f(lut_grid) # array of shape (n, n, n, 3)
21
+ '''
22
+ import difflib
23
+ import math
24
+ from types import SimpleNamespace
25
+ from typing import Callable, NamedTuple
26
+
27
+ __version__ = '0.1.0'
28
+ __license__ = 'MIT License'
29
+
30
+
31
+ ###################################################################################################
32
+ # Scalar or numpy: each formula is written once against xp, which is numpy with np=True,
33
+ # or this stdlib stand-in for the numpy functions the formulas use
34
+
35
+ _math = SimpleNamespace(log=math.log, log2=math.log2, log10=math.log10, exp=math.exp, sqrt=math.sqrt,
36
+ copysign=math.copysign, maximum=max)
37
+ _np = None
38
+
39
+ def _array(x):
40
+ # x as a float64 numpy array, importing numpy on first use
41
+ global _np
42
+ if _np is None:
43
+ try:
44
+ import numpy
45
+ except ImportError:
46
+ raise ImportError('np=True requires numpy') from None
47
+ _np = numpy
48
+ return _np.asarray(x, dtype=float)
49
+
50
+ def _xp(x, np):
51
+ # x and the namespace to compute it with
52
+ return (_array(x), _np) if np else (x, _math)
53
+
54
+ def _split(rgb, np):
55
+ # RGB as three channels: scalars, or arrays of shape (...)
56
+ return (rgb[..., 0], rgb[..., 1], rgb[..., 2]) if np else tuple(rgb)
57
+
58
+ def _join(channels, np):
59
+ return _np.stack(channels, axis=-1) if np else tuple(channels)
60
+
61
+ def _select(x, *pieces):
62
+ ''' if / elif / else on a scalar or a numpy array.
63
+ pieces are (condition, value) pairs then a final value. Each value is a constant or
64
+ a function, evaluated only where its condition is the first one true, so out of
65
+ domain branches never see the wrong values.
66
+ '''
67
+ if _np is None or not isinstance(x, _np.ndarray):
68
+ for p in pieces:
69
+ cond, value = p if type(p) is tuple else (True, p)
70
+ if cond:
71
+ return value(x) if callable(value) else value
72
+ out = _np.empty_like(x)
73
+ todo = _np.ones(x.shape, dtype=bool)
74
+ for cond, value in [p if isinstance(p, tuple) else (True, p) for p in pieces]:
75
+ m = todo & cond
76
+ out[m] = value(x[m]) if callable(value) else value
77
+ todo &= ~m
78
+ return out
79
+
80
+
81
+ ###################################################################################################
82
+ # Matrix helpers: 3x3 matrices are tuples of row tuples
83
+
84
+ _IDENTITY = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))
85
+
86
+ def _matmul(m0, m1):
87
+ return tuple(tuple(sum(a*b for a, b in zip(r, c)) for c in zip(*m1)) for r in m0)
88
+
89
+ def _dot(m, v):
90
+ return tuple(sum(a*b for a, b in zip(r, v)) for r in m)
91
+
92
+ def _diag(v):
93
+ return ((v[0], 0.0, 0.0), (0.0, v[1], 0.0), (0.0, 0.0, v[2]))
94
+
95
+ def _inv(m):
96
+ (a, b, c), (d, e, f), (g, h, i) = m
97
+ det = a*(e*i - f*h) - b*(d*i - f*g) + c*(d*h - e*g)
98
+ if det == 0.0:
99
+ raise ValueError(f'matrix is not invertible: {m}')
100
+ return (
101
+ ((e*i - f*h)/det, (c*h - b*i)/det, (b*f - c*e)/det),
102
+ ((f*g - d*i)/det, (a*i - c*g)/det, (c*d - a*f)/det),
103
+ ((d*h - e*g)/det, (b*g - a*h)/det, (a*e - b*d)/det))
104
+
105
+ def _is_identity(m, tol=1e-12):
106
+ return all(abs(a - b) <= tol for r0, r1 in zip(m, _IDENTITY) for a, b in zip(r0, r1))
107
+
108
+
109
+ ###################################################################################################
110
+ # Colorimetry
111
+
112
+ def xy_to_XYZ(xy):
113
+ # xy chromaticity to XYZ tristimulus with Y=1
114
+ x, y = xy
115
+ return (x/y, 1.0, (1.0 - x - y)/y)
116
+
117
+ def npm(gamut):
118
+ ''' Normalized Primaries Matrix: RGB in gamut -> CIE XYZ
119
+ Primaries (1,0) (0,1) (0,0) denote CIE XYZ itself, which returns identity.
120
+ SMPTE RP 177 - Derivation of Basic Television Color Equations
121
+ http://doi.org/10.5594/S9781614821915
122
+ '''
123
+ rx, ry, gx, gy, bx, by, wx, wy = gamut
124
+ if (rx, ry, gx, gy, bx, by) == (1, 0, 0, 1, 0, 0):
125
+ return _IDENTITY
126
+ P = ((rx, gx, bx), (ry, gy, by), (1.0 - rx - ry, 1.0 - gx - gy, 1.0 - bx - by))
127
+ C = _dot(_inv(P), xy_to_XYZ((wx, wy)))
128
+ return _matmul(P, _diag(C))
129
+
130
+ _CAT = {
131
+ 'bradford': ((0.8951, 0.2664, -0.1614), (-0.7502, 1.7135, 0.0367), (0.0389, -0.0685, 1.0296)),
132
+ 'cat02': ((0.7328, 0.4296, -0.1624), (-0.7036, 1.6975, 0.0061), (0.003, 0.0136, 0.9834)),
133
+ }
134
+
135
+ def cat(src_white, dst_white, method='cat02'):
136
+ ''' von Kries chromatic adaptation matrix (XYZ -> XYZ) between two xy whitepoints
137
+ method is 'cat02', 'bradford', or None for no adaptation.
138
+ Mark D. Fairchild - Color Appearance Models Third Edition p. 181-186
139
+ Bruce Lindbloom - http://www.brucelindbloom.com/index.html?Eqn_ChromAdapt.html
140
+ '''
141
+ if method is None or tuple(src_white) == tuple(dst_white):
142
+ return _IDENTITY
143
+ try:
144
+ m = _CAT[method]
145
+ except KeyError:
146
+ raise ValueError(f'unknown cat method {method!r}, use one of {sorted(_CAT)} or None') from None
147
+ s = _dot(m, xy_to_XYZ(src_white))
148
+ d = _dot(m, xy_to_XYZ(dst_white))
149
+ return _matmul(_matmul(_inv(m), _diag([b/a for a, b in zip(s, d)])), m)
150
+
151
+ _cat = cat # matrix() has a parameter named cat
152
+
153
+ def matrix(src, dst, cat='cat02'):
154
+ ''' 3x3 matrix converting linear RGB in src to linear RGB in dst, with chromatic adaptation
155
+ src and dst are colorspaces, colorspace names, or gamut tuples.
156
+ '''
157
+ src, dst = _gamut(src), _gamut(dst)
158
+ return _matmul(_inv(npm(dst)), _matmul(_cat(src[6:], dst[6:], cat), npm(src)))
159
+
160
+ def _gamut(x):
161
+ if isinstance(x, (tuple, list)) and len(x) == 8:
162
+ return tuple(x)
163
+ cs = get(x)
164
+ if cs.gamut is None:
165
+ raise ValueError(f'{cs.name!r} has no gamut')
166
+ return cs.gamut
167
+
168
+
169
+ ###################################################################################################
170
+ # Transfer function types
171
+
172
+ class Log(NamedTuple):
173
+ ''' Log curve in natural log form, with an optional piecewise linear extension toe.
174
+ encode (linear -> log):
175
+ y = gain*ln(x + offset) + bias x > toe
176
+ y = slope*(x - toe) + y0 x <= toe, y0 = gain*ln(toe + offset) + bias
177
+ slope defaults to the slope of the log segment at toe.
178
+ toe=None means a pure log curve, defined only for x > -offset.
179
+ '''
180
+ gain: float
181
+ offset: float
182
+ bias: float
183
+ toe: float = None
184
+ slope: float = None
185
+
186
+ @classmethod
187
+ def from_base(cls, base, gain, scale, offset, bias, toe=None, slope=None):
188
+ ''' Build from the form most whitepapers use: y = gain*log_base(scale*x + offset) + bias '''
189
+ g = gain/math.log(base)
190
+ return cls(g, offset/scale, bias + g*math.log(scale), toe, slope)
191
+
192
+ def _toe(self):
193
+ slope = self.gain/(self.toe + self.offset) if self.slope is None else self.slope
194
+ return slope, self.gain*math.log(self.toe + self.offset) + self.bias
195
+
196
+ def encode(self, x, np=False):
197
+ x, xp = _xp(x, np)
198
+ log = lambda x: self.gain*xp.log(x + self.offset) + self.bias
199
+ if self.toe is None:
200
+ return log(x)
201
+ slope, y0 = self._toe()
202
+ return _select(x, (x <= self.toe, lambda x: slope*(x - self.toe) + y0), log)
203
+
204
+ def decode(self, y, np=False):
205
+ y, xp = _xp(y, np)
206
+ exp = lambda y: xp.exp((y - self.bias)/self.gain) - self.offset
207
+ if self.toe is None:
208
+ return exp(y)
209
+ slope, y0 = self._toe()
210
+ return _select(y, (y <= y0, lambda y: (y - y0)/slope + self.toe), exp)
211
+
212
+
213
+ class Power(NamedTuple):
214
+ ''' Power curve with an optional piecewise linear extension toe.
215
+ decode (code -> linear): x = ((y + offset)/(1 + offset))**p
216
+ encode (linear -> code):
217
+ y = (1 + offset)*x**(1/p) - offset x > toe
218
+ y = slope*(x - toe) + y0 x <= toe, y0 = the power segment at toe
219
+ slope defaults to the slope of the power segment at toe.
220
+ Without a toe, negative values clamp to 0.
221
+ '''
222
+ p: float
223
+ offset: float = 0.0
224
+ toe: float = None
225
+ slope: float = None
226
+
227
+ def _pow(self, x):
228
+ return (1.0 + self.offset)*x**(1.0/self.p) - self.offset
229
+
230
+ def _inv_pow(self, y):
231
+ return ((y + self.offset)/(1.0 + self.offset))**self.p
232
+
233
+ def _toe(self):
234
+ slope = (1.0 + self.offset)/self.p*self.toe**(1.0/self.p - 1.0) if self.slope is None else self.slope
235
+ return slope, self._pow(self.toe)
236
+
237
+ def encode(self, x, np=False):
238
+ x, _ = _xp(x, np)
239
+ if self.toe is None:
240
+ return _select(x, (x > 0.0, self._pow), 0.0)
241
+ slope, y0 = self._toe()
242
+ return _select(x, (x <= self.toe, lambda x: slope*(x - self.toe) + y0), (x > 0.0, self._pow), 0.0)
243
+
244
+ def decode(self, y, np=False):
245
+ y, _ = _xp(y, np)
246
+ if self.toe is None:
247
+ return _select(y, (y > 0.0, self._inv_pow), 0.0)
248
+ slope, y0 = self._toe()
249
+ return _select(y, (y <= y0, lambda y: (y - y0)/slope + self.toe), (y > 0.0, self._inv_pow), 0.0)
250
+
251
+
252
+ class Curve(NamedTuple):
253
+ ''' A transfer function that fits no parametric form: a pair of functions f(x, xp).
254
+ xp is numpy with np=True, or a stdlib stand-in with the same names, so each
255
+ formula is written once. Branch with _select rather than if / else.
256
+ '''
257
+ encode_fn: Callable
258
+ decode_fn: Callable
259
+
260
+ def encode(self, x, np=False):
261
+ return self.encode_fn(*_xp(x, np))
262
+
263
+ def decode(self, y, np=False):
264
+ return self.decode_fn(*_xp(y, np))
265
+
266
+
267
+ class HLG(NamedTuple):
268
+ ''' ITU-R BT.2100 Hybrid Log-Gamma, display referred: inverse OETF then OOTF.
269
+ Works on RGB triplets because the OOTF depends on luminance.
270
+ peak is the nominal display peak in nits. Negative values clamp to 0.
271
+ https://www.itu.int/rec/R-REC-BT.2100
272
+ '''
273
+ peak: float = 1000.0
274
+ rgb = True # class attribute, not a field
275
+
276
+ _a = 0.17883277
277
+ _b = 1.0 - 4.0*0.17883277
278
+ _c = 0.5 - 0.17883277*math.log(4.0*0.17883277)
279
+
280
+ def _gamma(self):
281
+ return 1.2 + 0.42*math.log10(self.peak/1000.0)
282
+
283
+ def encode(self, rgb, np=False):
284
+ rgb, xp = _xp(rgb, np)
285
+ F = [xp.maximum(v, 0.0)*100.0/self.peak for v in _split(rgb, np)]
286
+ Yd = 0.2627*F[0] + 0.6780*F[1] + 0.0593*F[2]
287
+ g = self._gamma()
288
+ s = _select(Yd, (Yd > 0.0, lambda Y: Y**((1.0 - g)/g)), 0.0)
289
+ return _join([_select(e, (e <= 1.0/12.0, lambda e: xp.sqrt(3.0*e)), lambda e: self._a*xp.log(12.0*e - self._b) + self._c)
290
+ for e in (f*s for f in F)], np)
291
+
292
+ def decode(self, rgb, np=False):
293
+ rgb, xp = _xp(rgb, np)
294
+ E = [_select(e, (e <= 0.5, lambda e: e*e/3.0), lambda e: (xp.exp((e - self._c)/self._a) + self._b)/12.0)
295
+ for e in (xp.maximum(v, 0.0) for v in _split(rgb, np))]
296
+ Ys = 0.2627*E[0] + 0.6780*E[1] + 0.0593*E[2]
297
+ s = _select(Ys, (Ys > 0.0, lambda Y: self.peak/100.0*Y**(self._gamma() - 1.0)), 0.0)
298
+ return _join([e*s for e in E], np)
299
+
300
+
301
+ ###################################################################################################
302
+ # Camera log transfer functions
303
+
304
+ # ACEScct - S-2016-001 http://j.mp/S-2016-001_
305
+ # y = (log2(x) + 9.72)/17.52 x > 0.0078125
306
+ # y = A*x + B x <= 0.0078125
307
+ acescct = Log.from_base(2, 1/17.52, 1.0, 0.0, 9.72/17.52, toe=0.0078125, slope=10.5402377416545)
308
+
309
+ # JPLog2 - https://community.acescentral.com/t/aceslog-strawman-proposal/5270
310
+ # y = (log2(x) + 10.5)/20.46 x > 0.006801176276
311
+ # y = ln_s*x + ln_o x <= 0.006801176276
312
+ jplog2 = Log.from_base(2, 1/20.46, 1.0, 0.0, 10.5/20.46, toe=0.006801176276, slope=10.36773919972907075549)
313
+
314
+ def _filmlight_tlog(w=128.0, g=16.0, o=0.075):
315
+ # FilmLight T-Log - Baselight /etc/colourspaces/FilmLight_TLog_EGamut.flspace
316
+ # y = A + B*ln(x + C) x >= 0
317
+ # y = G*x + o x < 0
318
+ b = 1.0/(0.7107 + 1.2359*math.log(w*g))
319
+ gs = g/(1.0 - o)
320
+ C = b/gs
321
+ a = 1.0 - b*math.log(w + C)
322
+ y0 = a + b*math.log(C)
323
+ s = (1.0 - o)/(1.0 - y0)
324
+ A = 1.0 + (a - 1.0)*s
325
+ B = b*s
326
+ return Log(gain=B, offset=C, bias=A, toe=0.0) # G = gs*s is the log slope at 0
327
+
328
+ filmlight_tlog = _filmlight_tlog()
329
+ filmlight_tloge = _filmlight_tlog(w=512.0) # extended scene-linear range for 3D LUT shapers
330
+
331
+ def _davinci_intermediate():
332
+ # DaVinci Intermediate
333
+ # https://documents.blackmagicdesign.com/InformationNotes/DaVinci_Resolve_17_Wide_Gamut_Intermediate.pdf
334
+ # y = (log2(x + A) + B)*C x > LIN_CUT
335
+ # y = x*M x <= LIN_CUT
336
+ A, B, C, M, LIN_CUT = 0.0075, 7.0, 0.07329248, 10.44426855, 0.00262409
337
+ return Log.from_base(2, C, 1.0, A, B*C, toe=LIN_CUT, slope=M)
338
+
339
+ davinci_intermediate = _davinci_intermediate()
340
+
341
+ def _arri_logc3():
342
+ # ARRI LogC3 EI800
343
+ # https://www.arri.com/resource/blob/31918/66f56e6abb6e5b6553929edf9aa7483e/2017-03-alexa-logc-curve-in-vfx-data.pdf
344
+ # y = c*log10(a*x + b) + d x > cut
345
+ # y = e*x + f x <= cut
346
+ cut, a, b, c, d, e, f = 0.010591, 5.555556, 0.052272, 0.247190, 0.385537, 5.367655, 0.092809
347
+ return Log.from_base(10, c, a, b, d, toe=cut, slope=e)
348
+
349
+ arri_logc3 = _arri_logc3()
350
+
351
+ def _arri_logc4():
352
+ # ARRI LogC4
353
+ # https://www.arri.com/resource/blob/278790/bea879ac0d041a925bed27a096ab3ec2/2022-05-arri-logc4-specification-data.pdf
354
+ # y = (log2(a*x + 64) - 6)/14*b + c x >= t
355
+ # y = (x - t)/s x < t
356
+ a = (2.0**18.0 - 16.0)/117.45
357
+ b = (1023.0 - 95.0)/1023.0
358
+ c = 95.0/1023.0
359
+ s = (7.0*math.log(2.0)*2.0**(7.0 - 14.0*c/b))/(a*b)
360
+ t = (2.0**(14.0*(-c/b) + 6.0) - 64.0)/a
361
+ return Log.from_base(2, b/14.0, a, 64.0, c - 6.0*b/14.0, toe=t, slope=1.0/s)
362
+
363
+ arri_logc4 = _arri_logc4()
364
+
365
+ def _blackmagic_film_gen5():
366
+ # Blackmagic Film Generation 5
367
+ # Blackmagic RAW SDK: Blackmagic Generation 5 Color Science Technical Reference.pdf
368
+ # y = A*ln(x + B) + C x >= LIN_CUT
369
+ # y = D*x + E x < LIN_CUT
370
+ A, B, C, D = 0.08692876065491224, 0.005494072432257808, 0.5300133392291939, 8.283605932402494
371
+ LIN_CUT = 0.005
372
+ return Log(gain=A, offset=B, bias=C, toe=LIN_CUT, slope=D)
373
+
374
+ blackmagic_film_gen5 = _blackmagic_film_gen5()
375
+
376
+ # DJI D-Log - https://dl.djicdn.com/downloads/zenmuse+x7/20171010/D-Log_D-Gamut_Whitepaper.pdf
377
+ # y = log10(x*0.9892 + 0.0108)*0.256663 + 0.584555 x > 0.0078
378
+ # y = 6.025*x + 0.0929 x <= 0.0078
379
+ dji_dlog = Log.from_base(10, 0.256663, 0.9892, 0.0108, 0.584555, toe=0.0078, slope=6.025)
380
+
381
+ def _fujifilm_flog(a, b, c, d, e, cut1):
382
+ # Fujifilm F-Log / F-Log2
383
+ # y = c*log10(a*x + b) + d x >= cut1
384
+ # y = e*x + f x < cut1
385
+ return Log.from_base(10, c, a, b, d, toe=cut1, slope=e)
386
+
387
+ # https://dl.fujifilm-x.com/support/lut/F-Log_DataSheet_E_Ver.1.0.pdf
388
+ fujifilm_flog = _fujifilm_flog(a=0.555556, b=0.009468, c=0.344676, d=0.790453, e=8.735631, cut1=0.00089)
389
+ # https://dl.fujifilm-x.com/support/lut/F-Log2_DataSheet_E_Ver.1.0.pdf
390
+ fujifilm_flog2 = _fujifilm_flog(a=5.555556, b=0.064829, c=0.245281, d=0.384316, e=8.799461, cut1=0.000889)
391
+
392
+ def _leica_llog():
393
+ # Leica L-Log - https://leica-camera.com/sites/default/files/pm-65977-210914__L-Log_Reference_Manual_EN.pdf
394
+ # y = c*log10(d*x + e) + f x >= c0
395
+ # y = a*x + b x < c0
396
+ a, b, c, d, e, f, c0 = 8.0, 0.09, 0.27, 1.3, 0.0115, 0.6, 0.006
397
+ return Log.from_base(10, c, d, e, f, toe=c0, slope=a)
398
+
399
+ leica_llog = _leica_llog()
400
+
401
+ def _panasonic_vlog():
402
+ # Panasonic V-Log - https://pro-av.panasonic.net/en/cinema_camera_varicam_eva/support/pdf/VARICAM_V-Log_V-Gamut.pdf
403
+ # y = c*log10(x + b) + d x >= cut1
404
+ # y = 5.6*x + 0.125 x < cut1
405
+ cut1, b, c, d = 0.01, 0.00873, 0.241514, 0.598206
406
+ return Log.from_base(10, c, 1.0, b, d, toe=cut1, slope=5.6)
407
+
408
+ panasonic_vlog = _panasonic_vlog()
409
+
410
+ def _red_log3g10():
411
+ # RED Log3G10
412
+ # https://docs.red.com/955-0187/PDF/915-0187%20Rev-C%20%20%20RED%20OPS,%20White%20Paper%20on%20REDWideGamutRGB%20and%20Log3G10.pdf
413
+ # y = a*log10((x + c)*b + 1) x >= -c
414
+ # y = (x + c)*g x < -c
415
+ a, b, c, g = 0.224282, 155.975327, 0.01, 15.1927
416
+ return Log.from_base(10, a, b, c*b + 1.0, 0.0, toe=-c, slope=g)
417
+
418
+ red_log3g10 = _red_log3g10()
419
+
420
+ def _sony_slog2():
421
+ # Sony S-Log2 - S-Log2_Technical_PaperV1_0.pdf
422
+ # x' = x/0.9
423
+ # y' = c0*log10(c1*x'/c2 + c3) + c4 + c5 x' >= 0
424
+ # y' = x'*c6 + c7 x' < 0
425
+ # y = y'*876/1023 + 64/1023
426
+ c0, c1, c2, c3, c4, c5, c6 = 0.432699, 155.0, 219.0, 0.037584, 0.616596, 0.03, 3.53881278538813
427
+ return Log.from_base(10, c0*876/1023, c1/(c2*0.9), c3, (c4 + c5)*876/1023 + 64/1023,
428
+ toe=0.0, slope=c6/0.9*876/1023)
429
+
430
+ sony_slog2 = _sony_slog2()
431
+
432
+ def _sony_slog3():
433
+ # Sony S-Log3 - https://pro.sony/s3/cms-static-content/uploadfile/06/1237494271406.pdf
434
+ # y = (420 + log10((x + 0.01)/(0.18 + 0.01))*261.5)/1023 x >= 0.01125
435
+ # y = (x*(171.2102946929 - 95)/0.01125 + 95)/1023 x < 0.01125
436
+ return Log.from_base(10, 261.5/1023, 1/(0.18 + 0.01), 0.01/(0.18 + 0.01), 420/1023,
437
+ toe=0.01125, slope=(171.2102946929 - 95)/0.01125/1023)
438
+
439
+ sony_slog3 = _sony_slog3()
440
+
441
+ def _kodak_cineon():
442
+ # Kodak Cineon - https://github.com/imageworks/OpenColorIO-Configs/blob/master/nuke-default/make.py
443
+ # y = (a + b*log10(x*(1 - off) + off))/1023, off = 10**((c - a)/b)
444
+ a, b, c = 685.0, 300.0, 95.0
445
+ off = 10.0**((c - a)/b)
446
+ return Log.from_base(10, b/1023, 1.0 - off, off, a/1023)
447
+
448
+ kodak_cineon = _kodak_cineon()
449
+
450
+ # GoPro Protune - https://github.com/hpd/OpenColorIO-Configs/blob/master/aces_1.0.3/python/aces_ocio/colorspaces/gopro.py
451
+ # y = ln(x*112 + 1)/ln(113)
452
+ gopro_protune = Log.from_base(113, 1.0, 112.0, 1.0, 0.0)
453
+
454
+
455
+ # Curves below fit no parametric form
456
+
457
+ def _acescc_encode(x, xp):
458
+ A, B = 9.72, 17.52
459
+ return _select(x,
460
+ (x <= 0.0, (-16.0 + A)/B),
461
+ (x < 2.0**-15, lambda x: (xp.log2(2.0**-16 + x/2.0) + A)/B),
462
+ lambda x: (xp.log2(x) + A)/B)
463
+
464
+ def _acescc_decode(y, xp):
465
+ A, B = 9.72, 17.52
466
+ return _select(y,
467
+ (y <= (A - 15.0)/B, lambda y: (2.0**(y*B - A) - 2.0**-16)*2.0),
468
+ (y < (math.log2(65504.0) + A)/B, lambda y: 2.0**(y*B - A)),
469
+ 65504.0)
470
+
471
+ acescc = Curve(_acescc_encode, _acescc_decode)
472
+
473
+ # Canon Log - https://downloads.canon.com/nw/learn/white-papers/cinema-eos/white-paper-canon-log-gamma-curves.pdf
474
+ # The /0.9 on scene-linear matches Canon's CTL IDTs ("Input Transform Version 202007 for EOS C500 Mark II").
475
+ # The negative side is a mirrored log rather than a straight line.
476
+
477
+ def _canon_clog2_encode(x, xp):
478
+ c0, c1, c2 = 0.092864125, 0.24136077, 87.099375
479
+ x = x/0.9
480
+ return _select(x, (x < 0.0, lambda x: -c1*xp.log10(1.0 - c2*x) + c0), lambda x: c1*xp.log10(c2*x + 1.0) + c0)
481
+
482
+ def _canon_clog2_decode(y, xp):
483
+ c0, c1, c2 = 0.092864125, 0.24136077, 87.099375
484
+ x = _select(y, (y < c0, lambda y: -(10.0**((c0 - y)/c1) - 1.0)/c2), lambda y: (10.0**((y - c0)/c1) - 1.0)/c2)
485
+ return x*0.9
486
+
487
+ canon_clog2 = Curve(_canon_clog2_encode, _canon_clog2_decode)
488
+
489
+ _CLOG3 = (0.014, 0.09746547, 0.15277891, 0.36726845, 14.98325, 0.12783901, 1.9754798, 0.12512219, 0.12240537)
490
+
491
+ def _canon_clog3_encode(x, xp):
492
+ sp0, sp1, sp2, c0, c1, c2, c3, c4, c5 = _CLOG3
493
+ x = x/0.9
494
+ return _select(x,
495
+ (x < -sp0, lambda x: -c0*xp.log10(1.0 - c1*x) + c2),
496
+ (x <= sp0, lambda x: c3*x + c4),
497
+ lambda x: c0*xp.log10(c1*x + 1.0) + c5)
498
+
499
+ def _canon_clog3_decode(y, xp):
500
+ sp0, sp1, sp2, c0, c1, c2, c3, c4, c5 = _CLOG3
501
+ x = _select(y,
502
+ (y < sp1, lambda y: -(10.0**((c2 - y)/c0) - 1.0)/c1),
503
+ (y <= sp2, lambda y: (y - c4)/c3),
504
+ lambda y: (10.0**((y - c5)/c0) - 1.0)/c1)
505
+ return x*0.9
506
+
507
+ canon_clog3 = Curve(_canon_clog3_encode, _canon_clog3_decode)
508
+
509
+ # Nikon N-Log - http://download.nikonimglib.com/archive3/hDCmK00m9JDI03RPruD74xpoU905/N-Log_Specification_(En)01.pdf
510
+ # y = b*ln(x) + a x >= 0.328
511
+ # y = c*(x + 0.0075)**(1/3) x < 0.328
512
+
513
+ def _nikon_nlog_encode(x, xp):
514
+ a, b, c, d = 619.0/1023.0, 150.0/1023.0, 650.0/1023.0, 0.0075
515
+ return _select(x, (x >= 0.328, lambda x: b*xp.log(x) + a), lambda x: c*xp.copysign(abs(x + d)**(1.0/3.0), x + d))
516
+
517
+ def _nikon_nlog_decode(y, xp):
518
+ a, b, c, d = 619.0/1023.0, 150.0/1023.0, 650.0/1023.0, 0.0075
519
+ return _select(y, (y >= 452.0/1023.0, lambda y: xp.exp((y - a)/b)), lambda y: (y/c)**3 - d)
520
+
521
+ nikon_nlog = Curve(_nikon_nlog_encode, _nikon_nlog_decode)
522
+
523
+ # Apple Log - Apple_Log_Profile_White_Paper.pdf (Apple Developer)
524
+ # y = g*log2(x + b) + d x >= R_t
525
+ # y = c*(x - R_0)**2 R_0 <= x < R_t
526
+ # y = 0 x < R_0
527
+ _APPLE = (-0.05641088, 0.01, 47.28711236, 0.00964052, 0.08550479, 0.69336945) # R_0, R_t, c, b, g, d
528
+
529
+ def _apple_log_encode(x, xp):
530
+ R_0, R_t, c, b, g, d = _APPLE
531
+ return _select(x, (x >= R_t, lambda x: g*xp.log2(x + b) + d), (x >= R_0, lambda x: c*(x - R_0)**2), 0.0)
532
+
533
+ def _apple_log_decode(y, xp):
534
+ R_0, R_t, c, b, g, d = _APPLE
535
+ return _select(y,
536
+ (y >= c*(R_t - R_0)**2, lambda y: 2.0**((y - d)/g) - b),
537
+ (y >= 0.0, lambda y: xp.sqrt(y/c) + R_0),
538
+ R_0)
539
+
540
+ apple_log = Curve(_apple_log_encode, _apple_log_decode)
541
+
542
+ # Samsung Log - Samsung Log White Paper 2025, https://developer.samsung.com/mobile/samsung-log-video.html
543
+ # y = a1*log10(x + b1) + y1 x >= xt
544
+ # y = a2*log10(b2 - x) + y2 x0 <= x < xt
545
+ # y = 0 x < x0
546
+ _SAMSUNG = (-0.05, 0.01, 0.258984868, 0.0003645, 0.720504856, -0.20942, 0.016904, -0.24597, 0.206561909)
547
+ # x0, xt, a1, b1, y1, a2, b2, y2, yt
548
+
549
+ def _samsung_log_encode(x, xp):
550
+ x0, xt, a1, b1, y1, a2, b2, y2, yt = _SAMSUNG
551
+ return _select(x,
552
+ (x < x0, 0.0),
553
+ (x < xt, lambda x: a2*xp.log10(b2 - x) + y2),
554
+ lambda x: a1*xp.log10(x + b1) + y1)
555
+
556
+ def _samsung_log_decode(y, xp):
557
+ x0, xt, a1, b1, y1, a2, b2, y2, yt = _SAMSUNG
558
+ return _select(y,
559
+ (y < 0.0, x0),
560
+ (y < yt, lambda y: -10.0**((y - y2)/a2) + b2),
561
+ lambda y: 10.0**((y - y1)/a1) - b1)
562
+
563
+ samsung_log = Curve(_samsung_log_encode, _samsung_log_decode)
564
+
565
+ # Xiaomi Log - Xiaomi Log Profile White Paper, December 2024, https://www.mi.com/global/product/aces/
566
+ # y = a*log2(x + b) + g x >= xt
567
+ # y = c*(x - x0)**2 x0 <= x < xt
568
+ # y = 0 x < x0
569
+ _XIAOMI = (-0.09023729, 0.01974185, 0.09271529, 0.01384578, 0.67291850, 18.10531998) # x0, xt, a, b, g, c
570
+
571
+ def _xiaomi_log_encode(x, xp):
572
+ x0, xt, a, b, g, c = _XIAOMI
573
+ return _select(x, (x < x0, 0.0), (x < xt, lambda x: c*(x - x0)**2), lambda x: a*xp.log2(x + b) + g)
574
+
575
+ def _xiaomi_log_decode(y, xp):
576
+ x0, xt, a, b, g, c = _XIAOMI
577
+ return _select(y,
578
+ (y < 0.0, x0),
579
+ (y < c*(xt - x0)**2, lambda y: xp.sqrt(y/c) + x0),
580
+ lambda y: 2.0**((y - g)/a) - b)
581
+
582
+ xiaomi_log = Curve(_xiaomi_log_encode, _xiaomi_log_decode)
583
+
584
+
585
+ ###################################################################################################
586
+ # Display transfer functions
587
+
588
+ gamma22 = Power(2.2) # sRGB display, Display P3
589
+ gamma24 = Power(2.4) # ITU-R BT.1886
590
+ gamma26 = Power(2.6) # DCI theatrical projection
591
+
592
+ # IEC 61966-2-1 sRGB piecewise encoding. The correct EOTF for an sRGB display is gamma22.
593
+ srgb_piecewise = Power(2.4, offset=0.055, toe=0.04045/12.92, slope=12.92)
594
+
595
+ # ITU-R BT.709 camera OETF, using the precise continuous constants from ITU-R BT.2020
596
+ # y = alpha*x**0.45 - (alpha - 1) x >= beta
597
+ # y = 4.5*x x < beta
598
+ rec709_oetf = Power(1/0.45, offset=1.09929682680944 - 1.0, toe=0.018053968510807, slope=4.5)
599
+
600
+ # SMPTE ST 2084 PQ - https://www.itu.int/rec/R-REC-BT.2100
601
+ _PQ = (2610.0/16384.0, 2523.0/32.0, 107.0/128.0, 2413.0/128.0, 2392.0/128.0) # m1, m2, c1, c2, c3
602
+
603
+ def _pq_encode(x, xp):
604
+ m1, m2, c1, c2, c3 = _PQ
605
+ def pq(x):
606
+ x = (x/100.0)**m1
607
+ return ((c1 + c2*x)/(1.0 + c3*x))**m2
608
+ return _select(x, (x <= 0.0, 0.0), pq)
609
+
610
+ def _pq_decode(y, xp):
611
+ m1, m2, c1, c2, c3 = _PQ
612
+ def pq(y):
613
+ y = y**(1.0/m2)
614
+ return (xp.maximum(y - c1, 0.0)/(c2 - c3*y))**(1.0/m1)*100.0
615
+ return _select(y, (y <= 0.0, 0.0), pq)
616
+
617
+ pq = Curve(_pq_encode, _pq_decode)
618
+
619
+ hlg = HLG(1000.0)
620
+
621
+
622
+ ###################################################################################################
623
+ # Colorspaces
624
+
625
+ class Colorspace(NamedTuple):
626
+ name: str
627
+ shortname: str
628
+ alias: str
629
+ encoding: str # 'scene-linear', 'log', 'sdr-video' or 'hdr-video'
630
+ gamut: tuple
631
+ tf: object = None # None means linear
632
+
633
+ def decode(self, rgb, np=False):
634
+ ''' code values -> linear RGB '''
635
+ if self.tf is None:
636
+ return _array(rgb).copy() if np else tuple(rgb)
637
+ if np or getattr(self.tf, 'rgb', False):
638
+ return self.tf.decode(rgb, np=np) # elementwise on the whole array, or an RGB transfer function
639
+ return tuple(self.tf.decode(v) for v in rgb)
640
+
641
+ def encode(self, rgb, np=False):
642
+ ''' linear RGB -> code values '''
643
+ if self.tf is None:
644
+ return _array(rgb).copy() if np else tuple(rgb)
645
+ if np or getattr(self.tf, 'rgb', False):
646
+ return self.tf.encode(rgb, np=np) # elementwise on the whole array, or an RGB transfer function
647
+ return tuple(self.tf.encode(v) for v in rgb)
648
+
649
+
650
+ # Whitepoints
651
+ d65 = (0.3127, 0.329)
652
+ aces_white = (0.32168, 0.33767)
653
+
654
+ # Gamuts
655
+ xyz_d65 = (1, 0, 0, 1, 0, 0) + d65
656
+ xyz_e = (1, 0, 0, 1, 0, 0, 1/3, 1/3)
657
+ # TB-2014-004 http://j.mp/TB-2014-004
658
+ aces_ap0 = (0.7347, 0.2653, 0.0, 1.0, 0.0001, -0.077) + aces_white
659
+ # S-2014-004 http://j.mp/S-2014-004
660
+ aces_ap1 = (0.713, 0.293, 0.165, 0.83, 0.128, 0.044) + aces_white
661
+ rec709 = (0.64, 0.33, 0.3, 0.6, 0.15, 0.06) + d65
662
+ rec2020 = (0.708, 0.292, 0.17, 0.797, 0.131, 0.046) + d65
663
+ p3d65 = (0.68, 0.32, 0.265, 0.69, 0.15, 0.06) + d65
664
+ # Baselight /etc/colourspaces/FilmLight_Linear_EGamut.flspace
665
+ filmlight_egamut = (0.8, 0.3177, 0.18, 0.9, 0.065, -0.0805) + d65
666
+ # Baselight /etc/colourspaces/FilmLight_Linear_EGamut2.flspace
667
+ filmlight_egamut2 = (0.83, 0.31, 0.15, 0.95, 0.065, -0.0805) + d65
668
+ # https://documents.blackmagicdesign.com/InformationNotes/DaVinci_Resolve_17_Wide_Gamut_Intermediate.pdf
669
+ davinci_wide_gamut = (0.8, 0.313, 0.1682, 0.9877, 0.079, -0.1155) + d65
670
+ # https://www.arri.com/resource/blob/31918/66f56e6abb6e5b6553929edf9aa7483e/2017-03-alexa-logc-curve-in-vfx-data.pdf
671
+ arri_wide_gamut3 = (0.684, 0.313, 0.221, 0.848, 0.0861, -0.102) + d65
672
+ # https://www.arri.com/resource/blob/278790/bea879ac0d041a925bed27a096ab3ec2/2022-05-arri-logc4-specification-data.pdf
673
+ arri_wide_gamut4 = (0.7347, 0.2653, 0.1424, 0.8576, 0.0991, -0.0308) + d65
674
+ # Blackmagic RAW SDK: Blackmagic Generation 5 Color Science Technical Reference.pdf
675
+ # The whitepoint is specified as (0.3127170, 0.3290312), not exactly D65.
676
+ blackmagic_wide_gamut = (0.7177215, 0.3171181, 0.228041, 0.861569, 0.1005841, -0.0820452, 0.3127170, 0.3290312)
677
+ # Canon Cinema Gamut, D55 daylight IDT (not tungsten)
678
+ # Input Transform Version 202007 for EOS C300 Mark III - canon-eos-c300mk3-idt-202007.zip
679
+ canon_cinema_gamut = (0.74, 0.27, 0.17, 1.14, 0.08, -0.1) + d65
680
+ # https://dl.djicdn.com/downloads/zenmuse+x7/20171010/D-Log_D-Gamut_Whitepaper.pdf
681
+ dji_dgamut = (0.71, 0.31, 0.21, 0.88, 0.09, -0.08) + d65
682
+ # F-Gamut is Rec.2020 - https://dl.fujifilm-x.com/support/lut/F-Log_DataSheet_E_Ver.1.0.pdf
683
+ fujifilm_fgamut = rec2020
684
+ # https://dl.fujifilm-x.com/support/lut/F-Log2C_DataSheet_E_Ver.1.0.pdf
685
+ fujifilm_fgamut_c = (0.7347, 0.2653, 0.0263, 0.9737, 0.1173, -0.0224) + d65
686
+ # https://pro-av.panasonic.net/en/cinema_camera_varicam_eva/support/pdf/VARICAM_V-Log_V-Gamut.pdf
687
+ panasonic_vgamut = (0.73, 0.28, 0.165, 0.84, 0.1, -0.03) + d65
688
+ # https://docs.red.com/955-0187/PDF/915-0187%20Rev-C%20%20%20RED%20OPS,%20White%20Paper%20on%20REDWideGamutRGB%20and%20Log3G10.pdf
689
+ red_wide_gamut = (0.780308, 0.304253, 0.121595, 1.493994, 0.095612, -0.084589) + d65
690
+ # S-Gamut and S-Gamut3 share primaries - https://pro.sony/ue_US/support/article/CA000001
691
+ sony_sgamut3 = (0.73, 0.28, 0.14, 0.855, 0.1, -0.05) + d65
692
+ sony_sgamut3_cine = (0.766, 0.275, 0.225, 0.8, 0.089, -0.087) + d65
693
+ # Venice gamuts: chromaticities calculated from the AP0 matrices in the Sony Venice IDTs
694
+ # https://github.com/ampas/aces-core/blob/710ecbe52c87ce9f4a1e02c8ddf7ea0d6b611cc8/transforms/ctl/idt/vendorSupplied/sony/IDT.Sony.Venice_SLog3_SGamut3.ctl
695
+ sony_venice_sgamut3 = (0.740464264304292, 0.27936437475066, 0.089241145423286, 0.893809528608105,
696
+ 0.110488236673827, -0.052579333080476) + d65
697
+ # https://github.com/ampas/aces-core/blob/710ecbe52c87ce9f4a1e02c8ddf7ea0d6b611cc8/transforms/ctl/idt/vendorSupplied/sony/IDT.Sony.Venice_SLog3_SGamut3Cine.ctl
698
+ sony_venice_sgamut3_cine = (0.775901871567345, 0.274502392854799, 0.188682902773355, 0.828684937020288,
699
+ 0.101337382499301, -0.089187517306263) + d65
700
+
701
+ _C = Colorspace
702
+ _COLORSPACES = (
703
+ _C('XYZ D65', 'xyz-d65', 'xyzd65', 'scene-linear', xyz_d65),
704
+ _C('XYZ E', 'xyz-e', 'xyze', 'scene-linear', xyz_e),
705
+ _C('ACES 2065-1', 'aces', 'ap0', 'scene-linear', aces_ap0),
706
+ _C('ACEScg', 'acescg', 'ap1', 'scene-linear', aces_ap1),
707
+ _C('ACEScct', 'acescct', 'acescct', 'log', aces_ap1, acescct),
708
+ _C('JPLog2', 'jplog2', 'jplg2', 'log', aces_ap1, jplog2),
709
+ _C('Filmlight - E-Gamut - Linear', 'filmlight-egamut-lin', 'fegln', 'scene-linear', filmlight_egamut),
710
+ _C('Filmlight - E-Gamut - T-Log', 'filmlight-egamut-tlog', 'feglg', 'log', filmlight_egamut, filmlight_tlog),
711
+ _C('Filmlight - E-Gamut2 - Linear', 'filmlight-egamut2-lin', 'feg2ln', 'scene-linear', filmlight_egamut2),
712
+ _C('Filmlight - E-Gamut2 - T-Log', 'filmlight-egamut2-tlog', 'feg2lg', 'log', filmlight_egamut2, filmlight_tlog),
713
+ _C('Filmlight - E-Gamut2 - T-LogE', 'filmlight-egamut2-tloge', 'feg2lge', 'log', filmlight_egamut2, filmlight_tloge),
714
+ _C('DaVinci - WideGamut - Linear', 'davinci-wg-lin', 'dwgln', 'scene-linear', davinci_wide_gamut),
715
+ _C('DaVinci - WideGamut - Intermediate Log', 'davinci-wg-ilog', 'dwglg', 'log', davinci_wide_gamut, davinci_intermediate),
716
+ _C('Arri - WideGamut3 - Linear', 'arri-wg3-lin', 'awgln', 'scene-linear', arri_wide_gamut3),
717
+ _C('Arri - WideGamut3 - LogC3', 'arri-wg3-logc3', 'awglg', 'log', arri_wide_gamut3, arri_logc3),
718
+ _C('Arri - WideGamut4 - Linear', 'arri-wg4-lin', 'awg4ln', 'scene-linear', arri_wide_gamut4),
719
+ _C('Arri - WideGamut4 - LogC4', 'arri-wg4-logc4', 'awg4lg', 'log', arri_wide_gamut4, arri_logc4),
720
+ _C('Blackmagic - WideGamut - Linear', 'blackmagic-wg-lin', 'bwgln', 'scene-linear', blackmagic_wide_gamut),
721
+ _C('Blackmagic - WideGamut - Gen5 Log', 'blackmagic-wg-gen5log', 'bwglg', 'log', blackmagic_wide_gamut, blackmagic_film_gen5),
722
+ _C('Canon - C-Gamut - Linear', 'canon-cgamut-lin', 'ccgln', 'scene-linear', canon_cinema_gamut),
723
+ _C('Canon - C-Gamut - C-Log2', 'canon-cgamut-clog2', 'ccglg2', 'log', canon_cinema_gamut, canon_clog2),
724
+ _C('Canon - C-Gamut - C-Log3', 'canon-cgamut-clog3', 'ccglg3', 'log', canon_cinema_gamut, canon_clog3),
725
+ _C('DJI - D-Gamut - Linear', 'dji-dgamut-lin', 'dgln', 'scene-linear', dji_dgamut),
726
+ _C('DJI - D-Gamut - D-Log', 'dji-dgamut-dlog', 'dglg', 'log', dji_dgamut, dji_dlog),
727
+ _C('Fujifilm - F-Gamut - Linear', 'fuji-fgamut-lin', 'fgln', 'scene-linear', fujifilm_fgamut),
728
+ _C('Fujifilm - F-Gamut - F-Log', 'fuji-fgamut-flog', 'fglg', 'log', fujifilm_fgamut, fujifilm_flog),
729
+ _C('Fujifilm - F-Gamut - F-Log2', 'fuji-fgamut-flog2', 'fglg2', 'log', fujifilm_fgamut, fujifilm_flog2),
730
+ _C('Fujifilm - F-GamutC - Linear', 'fuji-fgamutc-lin', 'fgcln', 'scene-linear', fujifilm_fgamut_c),
731
+ _C('Fujifilm - F-GamutC - F-Log2', 'fuji-fgamutc-flog2', 'fgclg2', 'log', fujifilm_fgamut_c, fujifilm_flog2),
732
+ _C('Kodak Cineon', 'cineon', 'cin', 'log', rec709, kodak_cineon),
733
+ _C('GoPro - Rec.709 - Protune', 'gopro-rec709-protune', 'gplg', 'log', rec709, gopro_protune),
734
+ _C('Leica - Rec.2020 - Linear', 'leica-rec2020-lin', 'llgln', 'scene-linear', rec2020),
735
+ _C('Leica - Rec.2020 - L-Log', 'leica-rec2020-llog', 'llglg', 'log', rec2020, leica_llog),
736
+ _C('Nikon - Rec.2020 - Linear', 'nikon-rec2020-lin', 'ngln', 'scene-linear', rec2020),
737
+ _C('Nikon - Rec.2020 - N-Log', 'nikon-rec2020-nlog', 'nglg', 'log', rec2020, nikon_nlog),
738
+ _C('Apple - Rec.2020 - Linear', 'apple-rec2020-lin', 'apln', 'scene-linear', rec2020),
739
+ _C('Apple - Rec.2020 - Apple-Log', 'apple-rec2020-alog', 'aplg', 'log', rec2020, apple_log),
740
+ _C('Samsung - Rec.2020 - Linear', 'samsung-rec2020-lin', 'smln', 'scene-linear', rec2020),
741
+ _C('Samsung - Rec.2020 - Samsung-Log', 'samsung-rec2020-slog', 'smlg', 'log', rec2020, samsung_log),
742
+ _C('Xiaomi - Rec.2020 - Linear', 'xiaomi-rec2020-lin', 'xmln', 'scene-linear', rec2020),
743
+ _C('Xiaomi - Rec.2020 - Xiaomi-Log', 'xiaomi-rec2020-xlog', 'xmlg', 'log', rec2020, xiaomi_log),
744
+ _C('Panasonic - V-Gamut - Linear', 'panasonic-vgamut-lin', 'vgln', 'scene-linear', panasonic_vgamut),
745
+ _C('Panasonic - V-Gamut - V-Log', 'panasonic-vgamut-vlog', 'vglg', 'log', panasonic_vgamut, panasonic_vlog),
746
+ _C('Red - WideGamutRGB - Linear', 'red-wg-lin', 'rwgln', 'scene-linear', red_wide_gamut),
747
+ _C('Red - WideGamutRGB - Log3G10', 'red-wg-log3g10', 'rwglg', 'log', red_wide_gamut, red_log3g10),
748
+ _C('Sony - S-Gamut3 - Linear', 'sony-sgamut3-lin', 'sg3ln', 'scene-linear', sony_sgamut3),
749
+ _C('Sony - S-Gamut3 - S-Log3', 'sony-sgamut3-slog3', 'sg3lg3', 'log', sony_sgamut3, sony_slog3),
750
+ _C('Sony - S-Gamut3.Cine - Linear', 'sony-sgamut3cine-lin', 'sg3cln', 'scene-linear', sony_sgamut3_cine),
751
+ _C('Sony - S-Gamut3.Cine - S-Log3', 'sony-sgamut3cine-slog3', 'sg3clg3', 'log', sony_sgamut3_cine, sony_slog3),
752
+ _C('Sony - Venice S-Gamut3 - Linear', 'sony-venice-sgamut3-lin', 'vsg3ln', 'scene-linear', sony_venice_sgamut3),
753
+ _C('Sony - Venice S-Gamut3 - S-Log3', 'sony-venice-sgamut3-slog3', 'vsg3lg3', 'log', sony_venice_sgamut3, sony_slog3),
754
+ _C('Sony - Venice S-Gamut3.Cine - Linear', 'sony-venice-sgamut3cine-lin', 'vsg3cln', 'scene-linear', sony_venice_sgamut3_cine),
755
+ _C('Sony - Venice S-Gamut3.Cine - S-Log3', 'sony-venice-sgamut3cine-slog3', 'vsg3clg3', 'log', sony_venice_sgamut3_cine, sony_slog3),
756
+ _C('Rec.709 - Linear', 'rec709-lin', 'rec709ln', 'scene-linear', rec709),
757
+ _C('Rec.2020 - Linear', 'rec2020-lin', 'rec2020ln', 'scene-linear', rec2020),
758
+ _C('P3D65 - Linear', 'p3d65-lin', 'p3d65ln', 'scene-linear', p3d65),
759
+ _C('Rec.1886', 'rec1886', 'rec1886', 'sdr-video', rec709, gamma24),
760
+ _C('sRGB Display', 'srgb-display', 'srgb-display', 'sdr-video', rec709, gamma22),
761
+ _C('sRGB Encoding', 'srgb-encoding', 'srgb-encoding', 'sdr-video', rec709, srgb_piecewise),
762
+ _C('DCIP3 D65', 'dci-p3d65', 'dci-p3d65', 'sdr-video', p3d65, gamma26),
763
+ _C('Display P3', 'display-p3', 'display-p3', 'sdr-video', p3d65, gamma22),
764
+ _C('Rec2100 PQ', 'rec2100-pq', 'pq', 'hdr-video', rec2020, pq),
765
+ _C('Rec2100 HLG', 'rec2100-hlg', 'hlg', 'hdr-video', rec2020, hlg),
766
+ )
767
+ del _C
768
+
769
+ def _build_index():
770
+ # Every name, shortname and alias must identify exactly one colorspace
771
+ index = {}
772
+ for cs in _COLORSPACES:
773
+ for key in {cs.name.lower(), cs.shortname.lower(), cs.alias.lower()}:
774
+ if key in index:
775
+ raise ValueError(f'colorspace key {key!r} is used by both {index[key].name!r} and {cs.name!r}')
776
+ index[key] = cs
777
+ return index
778
+
779
+ _INDEX = _build_index()
780
+
781
+
782
+ ###################################################################################################
783
+ # Public interface
784
+
785
+ def colorspaces():
786
+ ''' All available colorspaces '''
787
+ return _COLORSPACES
788
+
789
+ def get(name):
790
+ ''' Look up a colorspace by name, shortname or alias (case insensitive) '''
791
+ if isinstance(name, Colorspace):
792
+ return name
793
+ try:
794
+ return _INDEX[name.lower()]
795
+ except KeyError:
796
+ close = difflib.get_close_matches(name.lower(), _INDEX, n=3)
797
+ hint = f', did you mean {", ".join(map(repr, close))}?' if close else ''
798
+ raise KeyError(f'unknown colorspace {name!r}{hint}') from None
799
+
800
+ def converter(src, dst, cat='cat02', np=False):
801
+ ''' Return a function converting an RGB triplet from colorspace src to colorspace dst
802
+ With np=True it converts a numpy array of shape (..., 3) instead.
803
+ '''
804
+ src, dst = get(src), get(dst)
805
+ m = matrix(src, dst, cat=cat)
806
+ if np:
807
+ if _is_identity(m):
808
+ return lambda rgb: dst.encode(src.decode(rgb, np=True), np=True)
809
+ mt = _array(m).T
810
+ return lambda rgb: dst.encode(src.decode(rgb, np=True) @ mt, np=True)
811
+ if _is_identity(m):
812
+ return lambda rgb: dst.encode(src.decode(rgb))
813
+ return lambda rgb: dst.encode(_dot(m, src.decode(rgb)))
814
+
815
+ def convert(rgb, src, dst, cat='cat02', np=False):
816
+ ''' Convert an RGB triplet from colorspace src to colorspace dst
817
+ With np=True rgb is a numpy array of shape (..., 3).
818
+ '''
819
+ return converter(src, dst, cat=cat, np=np)(rgb)