|
| 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