Tone mapping¶
A radiance to a display's colour.
The radiance scaled by the exposure, x = L 2^EV; ACES's fitted filmic curve y = x (2.51 x + 0.03) / (x (2.43 x + 0.59) + 0.14), held to 0..1; then the sRGB transfer, 12.92 y at and below 0.0031308 and 1.055 y^(1/2.4) - 0.055 above, each channel. The radiance is in W/(m2 sr), as lusine.render.pbr gives it, and the colour is the display's, sRGB-encoded, 0..1, before any quantisation. A display kind of the specification, section 11: the frame loop's table view draws tonemap(pbr(...), exposure). Source: Narkowicz, ACES Filmic Tone Mapping Curve (2016); IEC 61966-2-1:1999 (sRGB). Checked against a reference in numpy, which has its own tests.
- Source
- Narkowicz, ACES Filmic Tone Mapping Curve (2016); IEC 61966-2-1:1999 (sRGB).
- Proof
- curve, invariant, limit.
tonemap.trait
namespace lusine.render;
vector<float, 3> tonemap(vector<float<W/m2/sr>, 3> radiance, float exposure)
{
let x = radiance * (pow(2.0, exposure) / 1.0 W/m2/sr);
let r = clamp(x.x * (2.51 * x.x + 0.03) / (x.x * (2.43 * x.x + 0.59) + 0.14), 0.0, 1.0);
let g = clamp(x.y * (2.51 * x.y + 0.03) / (x.y * (2.43 * x.y + 0.59) + 0.14), 0.0, 1.0);
let b = clamp(x.z * (2.51 * x.z + 0.03) / (x.z * (2.43 * x.z + 0.59) + 0.14), 0.0, 1.0);
let gamma = 1.0 / 2.4;
return vector<float, 3>(r <= 0.0031308 ? 12.92 * r : clamp(1.055 * pow(r, gamma) - 0.055, 0.0, 1.0),
g <= 0.0031308 ? 12.92 * g : clamp(1.055 * pow(g, gamma) - 0.055, 0.0, 1.0),
b <= 0.0031308 ? 12.92 * b : clamp(1.055 * pow(b, gamma) - 0.055, 0.0, 1.0));
}
A radiance to a display's colour, by the exposure 2^EV, ACES's fitted filmic curve and the sRGB transfer. A display kind: lusine.render.table draws a table's shapes through it, tonemap(pbr(...), exposure).
import os, sys
root = os.getcwd()
while not os.path.isfile(os.path.join(root, 'docsite.json')):
root = os.path.dirname(root)
sys.path.insert(0, os.path.join(root, 'tools'))
import numpy as np
import matplotlib.pyplot as plt
from lusine import trait_run
EPS = float(np.finfo(np.float32).eps) # Trait's float is binary32
The kernel¶
The law as the library states it.
print(trait_run.source('render/tonemap.trait'))
// lusine.render.tonemap: a radiance to a display's colour. The radiance scaled by the exposure, x = L 2^EV; ACES's
// fitted filmic curve y = x (2.51 x + 0.03) / (x (2.43 x + 0.59) + 0.14), held to 0..1; then the sRGB transfer,
// 12.92 y at and below 0.0031308 and 1.055 y^(1/2.4) - 0.055 above, each channel. The radiance is in W/(m2 sr), as
// lusine.render.pbr gives it, and the colour is the display's, sRGB-encoded, 0..1, before any quantisation.
// A display kind of the specification, section 11: the frame loop's table view draws `tonemap(pbr(...), exposure)`.
// Source: Narkowicz, ACES Filmic Tone Mapping Curve (2016); IEC 61966-2-1:1999 (sRGB).
// Checked against a reference in numpy, which has its own tests.
namespace lusine.render;
vector<float, 3> tonemap(vector<float<W/m2/sr>, 3> radiance, float exposure)
{
let x = radiance * (pow(2.0, exposure) / 1.0 W/m2/sr);
let r = clamp(x.x * (2.51 * x.x + 0.03) / (x.x * (2.43 * x.x + 0.59) + 0.14), 0.0, 1.0);
let g = clamp(x.y * (2.51 * x.y + 0.03) / (x.y * (2.43 * x.y + 0.59) + 0.14), 0.0, 1.0);
let b = clamp(x.z * (2.51 * x.z + 0.03) / (x.z * (2.43 * x.z + 0.59) + 0.14), 0.0, 1.0);
let gamma = 1.0 / 2.4;
return vector<float, 3>(r <= 0.0031308 ? 12.92 * r : clamp(1.055 * pow(r, gamma) - 0.055, 0.0, 1.0),
g <= 0.0031308 ? 12.92 * g : clamp(1.055 * pow(g, gamma) - 0.055, 0.0, 1.0),
b <= 0.0031308 ? 12.92 * b : clamp(1.055 * pow(b, gamma) - 0.055, 0.0, 1.0));
}
The program¶
Each section runs the kernel over a lattice of items, one a case: the host sets every parameter, and main() binds the kernel over the items.
print(trait_run.law_program('render/tonemap.trait', 'tonemap'))
def tonemap(radiance, exposure=0.0):
x = np.atleast_1d(np.asarray(radiance, dtype=float))
out = trait_run.law('render/tonemap.trait', 'tonemap', radiance=np.stack([x, x, x], axis=1), exposure=exposure)
assert np.array_equal(out[:, 0], out[:, 1]) and np.array_equal(out[:, 0], out[:, 2]) # each channel alike
return out[:, 0]
namespace probe;
lusine.version version = { { number = 1 } };
struct item {
host vector<float<W/m2/sr>, 3> radiance;
host float exposure;
vector<float, 3> result_0;
};
lattice<item, 1> items(spacing = 1 m, boundary = wall);
void main() { items.result_0 = lusine.render.tonemap(items.radiance, items.exposure); }
Curve: ACES then sRGB¶
The display colour against the radiance: black at zero, never darker for more light, middle grey (18%) near the middle, approaching white; continuous where the sRGB transfer turns from its line to its power.
x = np.linspace(0.0, 8.0, 801)
c = tonemap(x)
grey = float(tonemap(0.18)[0])
assert np.all(np.diff(c) >= 0) and c[0] == 0.0 and c[-1] > 0.98 and 0.45 < grey < 0.6, grey
knee = np.linspace(0.0004, 0.0012, 401) # the radiances whose curve crosses 0.0031308
k = tonemap(knee)
assert np.all(np.diff(k) >= 0) and np.max(np.diff(k)) < 1e-3
fig, ax = plt.subplots(figsize=(7, 3.2))
ax.plot(x, c)
ax.set_xlabel('radiance (W/(m² sr))')
ax.set_ylabel('display (sRGB)')
plt.show()
print(f'middle grey shows at {grey:.3f}')
middle grey shows at 0.553
Invariant: a stop is a doubling¶
An EV of exposure doubles the radiance: a stop up on half the light is the same colour, bit for bit (2^EV is exact at a whole stop, and halving a binary32 is exact), over the whole range.
x = np.float32(np.linspace(0.001, 5.0, 500)).astype(float)
assert np.array_equal(tonemap(x / 2, 1.0), tonemap(x))
assert np.array_equal(tonemap(x * 4, -2.0), tonemap(x))
print('stops hold, bit for bit')
stops hold, bit for bit
Limit: black, and white¶
No light is black at any exposure; a very bright radiance is white.
assert all(float(tonemap(0.0, ev)[0]) == 0.0 for ev in (-4.0, 0.0, 4.0))
assert float(tonemap(1e6)[0]) >= 1.0 - EPS
print('black and white')
black and white