Skip to content

Commit 051b51b

Browse files
authored
Merge pull request #6 from colour-science/feature/python_model
PR: Implement support for Python model.
2 parents 6b5d9b3 + d130d54 commit 051b51b

File tree

2 files changed

+225
-0
lines changed

2 files changed

+225
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ The following implementations are available in the [model](model) directory:
2626
- Fuse for [Fusion Studio](https://www.blackmagicdesign.com/products/fusion/)
2727
- Matchbox GLSL for [Autodesk Flame & Smoke](https://www.autodesk.com/products/flame/)
2828
- Nuke Script for [The Foundry Nuke](https://www.foundry.com/products/nuke)
29+
- Python Script for [Numpy](https://numpy.org/)
2930

3031
## Research
3132

model/gamut_compress.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Gamut Compress
4+
==============
5+
"""
6+
7+
from __future__ import division, unicode_literals
8+
9+
import numpy as np
10+
11+
__all__ = ['compression_function', 'gamut_compression_operator']
12+
13+
# *****************************************************************************
14+
# Preserving as much as possible of the "GamutCompress.glsl" file formatting.
15+
# The bisection code could be removed entirely and replaced with "np.solve".
16+
# This is not Pythonic nor great but will make update subsequent easier.
17+
# *****************************************************************************
18+
19+
20+
# yapf: disable
21+
# compression function which gives the y=1 x in tersect at y=0
22+
def f(x, k, thr, method):
23+
if method == 0:
24+
# natural logarithm compression method
25+
return (np.exp((1-thr+thr*np.log(1-x)-x*thr*np.log(1-x))/(thr*(1-x))))*thr+x*thr-k
26+
elif method == 1 or method == 2:
27+
return k
28+
elif method == 3:
29+
# natural exponent compression method
30+
return -np.log((-x+1)/(thr-x))*(-thr+x)+thr-k
31+
elif method == 4:
32+
# arctangent compression method
33+
return (2*np.tan( (np.pi*(1-thr))/(2*(x-thr)))*(x-thr))/np.pi+thr-k
34+
elif method == 5:
35+
# hyperbolic tangent compression method
36+
return np.arctanh((1-thr)/(x-thr))*(x-thr)+thr-k
37+
38+
39+
def bisect(k, thr, method):
40+
# use a simple bisection algorithm to bruteforce the root of f
41+
# returns an approximation of the value of limit
42+
# such that the compression function intersects y=1 at desired value k
43+
# this allows us to specify the max distance we will compress to the gamut boundary
44+
45+
tol = 0.0001 # accuracy of estimate
46+
nmax = 100 # max iterations
47+
48+
# set up reasonable initial guesses for each method given output ranges of each function
49+
if method == 0:
50+
# natural logarithm needs a limit between -inf (linear), and 1 (clip)
51+
a = -15
52+
b = 0.98
53+
elif method == 5:
54+
# tanh needs more precision
55+
a = 1.000001
56+
b = 5
57+
else:
58+
a = 1.0001
59+
b = 5
60+
61+
if np.sign(f(a, k, thr, method)) == np.sign(f(b, k, thr, method)):
62+
# bad estimate. return something close to linear
63+
if method == 0 or method == 3:
64+
return -100
65+
else:
66+
return 1.999999
67+
68+
c = (a+b)/2
69+
y = f(c, k, thr, method)
70+
if abs(y) <= tol:
71+
return c # lucky guess
72+
73+
n = 1
74+
while abs(y) > tol and n <= nmax:
75+
if np.sign(y) == np.sign(f(a, k, thr, method)):
76+
a = c
77+
else:
78+
b = c
79+
80+
c = (a+b)/2
81+
y = f(c, k, thr, method)
82+
n += 1
83+
84+
return c
85+
86+
87+
# calculate compressed distance
88+
def compress(dist, lim, thr, invert, method, power):
89+
if method == 0:
90+
# natural logarithm compression method: https://www.desmos.com/calculator/hmzirlw7tj
91+
# inspired by ITU-R BT.2446 http://www.itu.int/pub/R-REP-BT.2446-2019
92+
if not invert:
93+
cdist = thr*np.log(dist/thr-lim)-lim*thr*np.log(dist/thr-lim)+thr-thr*np.log(1-lim)+lim*thr*np.log(1-lim)
94+
else:
95+
cdist = np.exp((dist-thr+thr*np.log(1-lim)-lim*thr*np.log(1-lim))/(thr*(1-lim)))*thr+lim*thr
96+
elif method == 1:
97+
# simple Reinhard type compression method: https://www.desmos.com/calculator/lkhdtjbodx
98+
if not invert:
99+
cdist = thr + 1/(1/(dist - thr) + 1/(1 - thr) - 1/(lim - thr))
100+
else:
101+
cdist = thr + 1/(1/(dist - thr) - 1/(1 - thr) + 1/(lim - thr))
102+
elif method == 2:
103+
# power(p) compression function plot https://www.desmos.com/calculator/54aytu7hek
104+
s = (lim-thr)/np.power(np.power((1-thr)/(lim-thr),-power)-1,1/power) # calc y=1 intersect
105+
if not invert:
106+
cdist = thr+s*((dist-thr)/s)/(np.power(1+np.power((dist-thr)/s,power),1/power)) # compress
107+
else:
108+
cdist = thr+s*np.power(-(np.power((dist-thr)/s,power)/(np.power((dist-thr)/s,power)-1)),1/power) # uncompress
109+
elif method == 3:
110+
# natural exponent compression method: https://www.desmos.com/calculator/s2adnicmmr
111+
if not invert:
112+
cdist = lim-(lim-thr)*np.exp(-(((dist-thr)*((1*lim)/(lim-thr))/lim)))
113+
else:
114+
cdist = -np.log((dist-lim)/(thr-lim))*(-thr+lim)/1+thr
115+
elif method == 4:
116+
# arctangent compression method: plot https://www.desmos.com/calculator/olmjgev3sl
117+
if not invert:
118+
cdist = thr + (lim - thr) * 2 / np.pi * np.arctan(np.pi/2 * (dist - thr)/(lim - thr))
119+
else:
120+
cdist = thr + (lim - thr) * 2 / np.pi * np.tan(np.pi/2 * (dist - thr)/(lim - thr))
121+
elif method == 5:
122+
# hyperbolic tangent compression method: https://www.desmos.com/calculator/xiwliws24x
123+
if not invert:
124+
cdist = thr + (lim - thr) * np.tanh( ( (dist - thr)/( lim - thr)))
125+
else:
126+
cdist = thr + (lim - thr) * np.arctanh( dist/( lim - thr) - thr/( lim - thr))
127+
128+
cdist = np.nan_to_num(cdist)
129+
130+
cdist[dist < thr] = dist[dist < thr]
131+
132+
return cdist
133+
134+
135+
def main(rgb, method=2, invert=False, hexagonal=False, threshold=0.8, cyan=0.09, magenta=0.24, yellow=0.12, power=1.2, shd_rolloff=0):
136+
rgb = np.asarray(rgb)
137+
threshold = np.asarray(threshold)
138+
if not threshold.shape:
139+
threshold = np.tile(threshold, 3)
140+
141+
# thr is the percentage of the core gamut to protect.
142+
thr = np.clip(threshold, -np.inf, 0.9999).reshape(
143+
[1] * (rgb.ndim - 1) + [3])
144+
145+
# lim is the max distance from the gamut boundary that will be compressed
146+
# 0 is a no-op, 1 will compress colors from a distance of 2 from achromatic to the gamut boundary
147+
# if method is Reinhard, use the limit as-is
148+
if method == 1 or method == 2:
149+
lim = np.array([cyan+1, magenta+1, yellow+1])
150+
else:
151+
# otherwise, we have to bruteforce the value of limit
152+
# such that lim is the value of x where y=1 - also enforce sane ranges to avoid nans
153+
154+
# Not sure of a way to pre-calculate a constant using the values from the ui parameters in GLSL...
155+
# This approach might have performance implications
156+
lim = np.array([
157+
bisect(np.clip(cyan, 0.0001, np.inf)+1, np.float(np.squeeze(thr[..., 0])), method),
158+
bisect(np.clip(magenta, 0.0001, np.inf)+1, np.float(np.squeeze(thr[..., 1])), method),
159+
bisect(np.clip(yellow, 0.0001, np.inf)+1, np.float(np.squeeze(thr[..., 2])), method)])
160+
161+
# achromatic axis
162+
ach = np.max(rgb, axis=-1)[..., np.newaxis]
163+
164+
# achromatic with shadow rolloff below shd_rolloff threshold
165+
ach_shd = 1-np.where((1-ach)<(1-shd_rolloff),1-ach,(1-shd_rolloff)+shd_rolloff*np.tanh((((1-ach)-(1-shd_rolloff))/shd_rolloff)))
166+
167+
# distance from the achromatic axis for each color component aka inverse rgb ratios
168+
dist = np.where(ach_shd == 0, 0, (ach-rgb)/ach_shd)
169+
170+
# compress distance with user controlled parameterized shaper function
171+
if hexagonal:
172+
# Based on Nick Shaw's variation on the gamut mapping algorithm
173+
# https://community.acescentral.com/t/a-variation-on-jeds-rgb-gamut-mapper/3060
174+
sat = np.concatenate([x[..., np.newaxis] for x in [np.max(dist, axis=-1)]*3], axis=-1)
175+
csat = compress(sat, lim, thr, invert, method, power)
176+
cdist = np.where(sat == 0, dist, dist* csat / sat)
177+
else:
178+
cdist = compress(dist, lim, thr, invert, method, power)
179+
180+
crgb = ach-cdist*ach_shd
181+
182+
return crgb
183+
# yapf: enable
184+
185+
compression_function = compress
186+
gamut_compression_operator = main
187+
188+
189+
def generate_test_images(samples=16):
190+
try:
191+
import colour
192+
except:
193+
print(
194+
'"colour-science" must be installed to generate the test images!')
195+
return
196+
197+
np.random.seed(4)
198+
RGB = (np.random.random([samples, samples, 3]) - 0.5) * 4
199+
name_template = 'Gamut_Compress_{0}.exr'
200+
colour.write_image(RGB, name_template.format('Reference'))
201+
for method in range(0, 6):
202+
colour.write_image(
203+
gamut_compression_operator(RGB, method=method),
204+
name_template.format('GM_Method_{0}'.format(method)))
205+
206+
colour.write_image(
207+
gamut_compression_operator(RGB, method=method, hexagonal=True),
208+
name_template.format('GM_Method_{0}_Hexagonal'.format(method)))
209+
210+
colour.write_image(
211+
gamut_compression_operator(
212+
RGB, threshold=[0.2, 0.4, 0.6], method=method),
213+
name_template.format(
214+
'GM_Method_{0}_DecoupledThreshold'.format(method)))
215+
216+
colour.write_image(
217+
gamut_compression_operator(
218+
RGB, threshold=[0.2, 0.4, 0.6], method=method, hexagonal=True),
219+
name_template.format(
220+
'GM_Method_{0}_Hexagonal_DecoupledThreshold'.format(method)))
221+
222+
223+
if __name__ == '__main__':
224+
generate_test_images()

0 commit comments

Comments
 (0)