├── .gitignore ├── README.org ├── tonemapper.py ├── tonemapper_linear.png └── tonemapper_log.png /.gitignore: -------------------------------------------------------------------------------- 1 | *.*~ -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | * tonemapper 2 | 3 | A python script to debug tonemapping operators. 4 | It plots the following filmic tonemapper curves with a fixed set of parameters: 5 | 6 | 1. [[http://32ipi028l5q82yhj72224m8j.wpengine.netdna-cdn.com/wp-content/uploads/2016/03/GdcVdrLottes.pdf][Timothy Lottes Generic Filmic]] with [[https://bartwronski.com/2016/09/01/dynamic-range-and-evs/comment-page-1/][Fixes from Bart Wronski]] 7 | 2. [[http://filmicworlds.com/blog/filmic-tonemapping-with-piecewise-power-curves/][John Hable Piecewise Filmic]] 8 | 3. [[http://filmicgames.com/archives/75][John Hable "Uncharted" Filmic]] 9 | 4. [[https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/][Krzysztof Narkowicz ACES approximation]] 10 | 11 | ** Logarithmic scale with HDR max = 16 12 | [[file:tonemapper_log.png]] 13 | 14 | ** Linear scale with HDR max = 2 15 | [[file:tonemapper_linear.png]] 16 | 17 | The settings for the Generic tonemapper are: 18 | - Contrast = 1.2 19 | - Shoulder = 0.97 20 | - Mid in = 0.3 21 | - Mid out = 0.18 22 | 23 | The settings for the Piecewise tonemapper are: 24 | - x0, y0 = 0.25 25 | - x1, y1 = 0.6 26 | - overshoot x = HDR max * 4 27 | - overshoot y = 1.5 28 | 29 | Both the Uncharted and ACES curve have been normalized to HDR max. 30 | 31 | Linear curves are included for reference. Clamped to 1 and normalized to [0, HDR max], respectively. 32 | 33 | -------------------------------------------------------------------------------- /tonemapper.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import math 3 | import numpy 4 | 5 | # Lottes Generic like in http://32ipi028l5q82yhj72224m8j.wpengine.netdna-cdn.com/wp-content/uploads/2016/03/GdcVdrLottes.pdf 6 | # but with corrections like in https://bartwronski.com/2016/09/01/dynamic-range-and-evs/comment-page-1/#comment-2360 7 | 8 | # Setting similar to Uncharted: 9 | # contrast = 1.25 10 | # shoulder = 0.975 11 | # mid_in = 0.25 12 | # mid_out = 0.18 13 | 14 | class Generic(): 15 | def __init__(self, hdr_max): 16 | a = 1.2 # contrast 17 | d = 0.97 # shoulder 18 | 19 | mid_in = 0.3 20 | mid_out = 0.18 21 | 22 | ad = a * d 23 | 24 | midi_pow_a = pow(mid_in, a) 25 | midi_pow_ad = pow(mid_in, ad) 26 | hdrm_pow_a = pow(hdr_max, a) 27 | hdrm_pow_ad = pow(hdr_max, ad) 28 | u = hdrm_pow_ad * mid_out - midi_pow_ad * mid_out 29 | v = midi_pow_ad * mid_out 30 | 31 | self.a = a 32 | self.d = d 33 | self.b = -((-midi_pow_a + (mid_out * (hdrm_pow_ad * midi_pow_a - hdrm_pow_a * v)) / u) / v) 34 | self.c = (hdrm_pow_ad * midi_pow_a - hdrm_pow_a * v) / u 35 | self.hdr_max = hdr_max 36 | 37 | def evaluate(self, x): 38 | x = min(x, self.hdr_max) 39 | z = pow(x, self.a) 40 | y = z / (pow(z, self.d) * self.b + self.c) 41 | 42 | return y 43 | 44 | class Piecewise(): 45 | 46 | class Segment(): 47 | offset_x = 0.0 48 | scale_x = 1.0 49 | offset_y = 0.0 50 | scale_y = 1.0 51 | ln_a = 0.0 52 | b = 1.0 53 | 54 | # def __init__(self): 55 | 56 | def evaluate(self, x): 57 | x0 = (x - self.offset_x) * self.scale_x 58 | y0 = 0.0 59 | 60 | if x0 > 0.0: 61 | y0 = math.exp(self.ln_a + self.b * math.log(x0)) 62 | 63 | return y0 * self.scale_y + self.offset_y 64 | 65 | def __init__(self, hdr_max): 66 | self.segments = [self.Segment(), self.Segment(), self.Segment()] 67 | 68 | x0 = 0.25 69 | y0 = 0.25 70 | x1 = 0.6 71 | y1 = 0.6 72 | 73 | overshoot_x = hdr_max * 4.0 74 | overshoot_y = 1.5 75 | 76 | 77 | norm_x0 = x0 / hdr_max 78 | norm_x1 = x1 / hdr_max 79 | norm_overshoot_x = overshoot_x / hdr_max 80 | 81 | self.x0 = norm_x0 82 | self.x1 = norm_x1 83 | 84 | # mid segment 85 | m, b = Piecewise.as_slope_intercept(norm_x0, norm_x1, y0, y1) 86 | 87 | self.segments[1].offset_x = -(b / m) 88 | self.segments[1].offset_y = 0.0 89 | self.segments[1].scale_x = 1.0 90 | self.segments[1].scale_y = 1.0 91 | self.segments[1].ln_a = math.log(m) 92 | self.segments[1].b = 1.0 93 | 94 | # toe segment 95 | toe_m = m 96 | ln_a, b = Piecewise.solve_a_b(norm_x0, y0, toe_m) 97 | self.segments[0].offset_x = 0.0 98 | self.segments[0].offset_y = 0.0 99 | self.segments[0].scale_x = 1.0 100 | self.segments[0].scale_y = 1.0 101 | self.segments[0].ln_a = ln_a 102 | self.segments[0].b = b 103 | 104 | # shoulder segment 105 | shoulder_x0 = (1.0 + norm_overshoot_x) - norm_x1 106 | shoulder_y0 = (1.0 + overshoot_y) - y1 107 | 108 | shoulder_m = m 109 | ln_a, b = Piecewise.solve_a_b(shoulder_x0, shoulder_y0, shoulder_m) 110 | 111 | self.segments[2].offset_x = 1.0 + norm_overshoot_x 112 | self.segments[2].offset_y = 1.0 + overshoot_y 113 | self.segments[2].scale_x = -1.0 114 | self.segments[2].scale_y = -1.0 115 | self.segments[2].ln_a = ln_a 116 | self.segments[2].b = b 117 | 118 | # Normalize so that we hit 1.0 at white point 119 | scale = self.segments[2].evaluate(1.0) 120 | inv_scale = 1.0 / scale 121 | 122 | self.segments[0].offset_y *= inv_scale 123 | self.segments[0].scale_y *= inv_scale 124 | 125 | self.segments[1].offset_y *= inv_scale 126 | self.segments[1].scale_y *= inv_scale 127 | 128 | self.segments[2].offset_y *= inv_scale 129 | self.segments[2].scale_y *= inv_scale 130 | 131 | self.hdr_max = hdr_max 132 | 133 | def evaluate(self, x): 134 | norm_x = x / self.hdr_max 135 | 136 | index = 0 if norm_x < self.x0 else (1 if norm_x < self.x1 else 2) 137 | 138 | return self.segments[index].evaluate(norm_x) 139 | 140 | @staticmethod 141 | def as_slope_intercept(x0, x1, y0, y1): 142 | dy = y1 - y0 143 | dx = x1 - x0 144 | 145 | m = 1.0 146 | if 0.0 != dx: 147 | m = dy / dx 148 | 149 | b = y0 - x0 * m 150 | 151 | return m, b 152 | 153 | 154 | @staticmethod 155 | def solve_a_b(x0, y0, m): 156 | b = (m * x0) / y0 157 | ln_a = math.log(y0) - b * math.log(x0) 158 | return ln_a, b 159 | 160 | # Uncharted like in http://filmicgames.com/archives/75Jo 161 | def uncharted(x): 162 | a = 0.22 163 | b = 0.30 164 | c = 0.10 165 | d = 0.20 166 | e = 0.01 167 | f = 0.30 168 | 169 | return ((x * (a * x + c * b) + d * e) / (x * (a * x + b) + d * f)) - e / f 170 | 171 | def normalized_uncharted(x, hdr_max): 172 | return uncharted(x) / uncharted(hdr_max) 173 | 174 | # ACES like in https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/ 175 | def aces(x): 176 | a = 2.51 177 | b = 0.03 178 | c = 2.43 179 | d = 0.59 180 | e = 0.14 181 | 182 | return (x * (a * x + b)) / (x * (c * x + d) + e) 183 | 184 | def normalized_aces(x, hdr_max): 185 | return aces(x) / aces(hdr_max) 186 | 187 | def normalized_linear(x, hdr_max): 188 | return x / hdr_max 189 | 190 | def plot_linear(): 191 | color_in = [] 192 | # Plot the tonemapping curves 193 | plt.figure(figsize=(12, 6)) 194 | 195 | color_in = [] 196 | color_generic = [] 197 | color_piecewise = [] 198 | color_uncharted = [] 199 | color_aces = [] 200 | color_linear = [] 201 | 202 | hdr_max = 2.0 203 | 204 | generic = Generic(hdr_max) 205 | piecewise = Piecewise(hdr_max) 206 | 207 | for x in numpy.linspace(0, 2.1, num=256): 208 | color = x 209 | color_in.append(color) 210 | color_generic.append(generic.evaluate(color)) 211 | color_piecewise.append(piecewise.evaluate(color)) 212 | #color_uncharted.append(uncharted(color)) 213 | color_uncharted.append(normalized_uncharted(color, hdr_max)) 214 | #color_aces.append(aces(color)) 215 | color_aces.append(normalized_aces(color, hdr_max)) 216 | color_linear.append(normalized_linear(color, hdr_max)) 217 | 218 | plt.plot(color_in, color_generic, label='Generic') 219 | plt.plot(color_in, color_piecewise, label='Piecewise') 220 | plt.plot(color_in, color_uncharted, label='Uncharted (normalized)') 221 | plt.plot(color_in, color_aces, label='ACES (normalized)') 222 | plt.plot(color_in, color_in, label='Linear (clamped)') 223 | plt.plot(color_in, color_linear, label='Linear (normalized)') 224 | 225 | plt.axis([0, 2.04, 0, 1.04]) 226 | plt.legend(loc=4, bbox_to_anchor=[0.975, 0.0]) 227 | plt.xlabel('Input') 228 | plt.ylabel('Tonemapped') 229 | 230 | ax = plt.axes() 231 | ax.tick_params(which='both', # Options for both major and minor ticks 232 | direction='out', 233 | top='off', # turn off top ticks 234 | left='on', # turn off left ticks 235 | right='off', # turn off right ticks 236 | bottom='on') # turn off bottom ticks 237 | 238 | ax.axhline(1.0, linestyle='--', color='k') 239 | ax.axvline(1.0, linestyle=':', color='k') 240 | ax.axvline(2.0, linestyle='--', color='k') 241 | 242 | plt.savefig('tonemapper_linear.png', bbox_inches='tight') 243 | 244 | def plot_log(): 245 | # Plot the tonemapping curves 246 | plt.figure(figsize=(12, 6)) 247 | 248 | color_in = [] 249 | color_generic = [] 250 | color_piecewise = [] 251 | color_uncharted = [] 252 | color_aces = [] 253 | color_linear = [] 254 | 255 | hdr_max = 16.0 256 | 257 | generic = Generic(hdr_max) 258 | piecewise = Piecewise(hdr_max) 259 | 260 | for x in numpy.logspace(-1, 5, num=256, base=2): 261 | color = x - math.pow(2, -1) 262 | color_in.append(color) 263 | color_generic.append(generic.evaluate(color)) 264 | color_piecewise.append(piecewise.evaluate(color)) 265 | #color_uncharted.append(uncharted(color)) 266 | color_uncharted.append(normalized_uncharted(color, hdr_max)) 267 | #color_aces.append(aces(color)) 268 | color_aces.append(normalized_aces(color, hdr_max)) 269 | color_linear.append(normalized_linear(color, hdr_max)) 270 | 271 | plt.semilogx(color_in, color_generic, basex=2, label='Generic') 272 | plt.plot(color_in, color_piecewise, label='Piecewise') 273 | plt.plot(color_in, color_uncharted, label='Uncharted (normalized)') 274 | plt.plot(color_in, color_aces, label='ACES (normalized)') 275 | plt.plot(color_in, color_in, label='Linear (clamped)') 276 | plt.plot(color_in, color_linear, label='Linear (normalized)') 277 | 278 | plt.axis([0, 18.66, 0, 1.04]) 279 | plt.legend(loc=2, bbox_to_anchor=[0.0, 0.95]) 280 | plt.xlabel('Input') 281 | plt.ylabel('Tonemapped') 282 | 283 | ax = plt.axes() 284 | 285 | xlabels = ['', '', '0.015625', '0.03125', '0.0625', '0.125', 286 | '0.25', '0.5', '1.0', '2.0', '4.0', '8.0', '16.0'] 287 | 288 | ax.set_xticklabels(xlabels) 289 | 290 | ax.tick_params(which='both', # Options for both major and minor ticks 291 | direction='out', 292 | top='off', # turn off top ticks 293 | left='on', # turn off left ticks 294 | right='off', # turn off right ticks 295 | bottom='on') # turn off bottom ticks 296 | 297 | ax.axhline(1.0, linestyle='--', color='k') 298 | ax.axvline(1.0, linestyle=':', color='k') 299 | ax.axvline(16.0, linestyle='--', color='k') 300 | 301 | plt.savefig('tonemapper_log.png', bbox_inches='tight') 302 | 303 | plot_linear() 304 | plot_log() 305 | -------------------------------------------------------------------------------- /tonemapper_linear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Opioid/tonemapper/77bbfb2192819a536250414d43d554f6e668780e/tonemapper_linear.png -------------------------------------------------------------------------------- /tonemapper_log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Opioid/tonemapper/77bbfb2192819a536250414d43d554f6e668780e/tonemapper_log.png --------------------------------------------------------------------------------