Skip to content

Commit 4fb8977

Browse files
switch to AgX tonemapping
1 parent d6e3a36 commit 4fb8977

File tree

1 file changed

+198
-3
lines changed

1 file changed

+198
-3
lines changed

tools/tonemap.py

+198-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ def rgb2srgb(image):
77
return np.uint8(np.round(np.clip(np.where(
88
image <= 0.00304,
99
12.92 * image,
10-
1.055 * np.power(image, 1.0 / 2.4) - 0.055
10+
1.055 * np.power(np.maximum(image, 0), 1.0 / 2.4) - 0.055
1111
) * 255, 0, 255)))
1212

1313

@@ -20,9 +20,203 @@ def tonemapping(x):
2020
return rgb2srgb(x * (a * x + b) / (x * (c * x + d) + e))
2121

2222

23+
"""
24+
The fitted AgX implementation is from https://iolite-engine.com/blog_posts/minimal_agx_implementation
25+
26+
// MIT License
27+
//
28+
// Copyright (c) 2024 Missing Deadlines (Benjamin Wrensch)
29+
//
30+
// Permission is hereby granted, free of charge, to any person obtaining a copy
31+
// of this software and associated documentation files (the "Software"), to deal
32+
// in the Software without restriction, including without limitation the rights
33+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
34+
// copies of the Software, and to permit persons to whom the Software is
35+
// furnished to do so, subject to the following conditions:
36+
//
37+
// The above copyright notice and this permission notice shall be included in
38+
// all copies or substantial portions of the Software.
39+
//
40+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
41+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
42+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
43+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
44+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
45+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
46+
// SOFTWARE.
47+
48+
// All values used to derive this implementation are sourced from Troy’s initial AgX implementation/OCIO config file available here:
49+
// https://github.com/sobotka/AgX
50+
51+
// 0: Default, 1: Golden, 2: Punchy
52+
#define AGX_LOOK 0
53+
54+
// Mean error^2: 3.6705141e-06
55+
vec3 agxDefaultContrastApprox(vec3 x) {
56+
vec3 x2 = x * x;
57+
vec3 x4 = x2 * x2;
58+
59+
return + 15.5 * x4 * x2
60+
- 40.14 * x4 * x
61+
+ 31.96 * x4
62+
- 6.868 * x2 * x
63+
+ 0.4298 * x2
64+
+ 0.1191 * x
65+
- 0.00232;
66+
}
67+
68+
vec3 agx(vec3 val) {
69+
const mat3 agx_mat = mat3(
70+
0.842479062253094, 0.0423282422610123, 0.0423756549057051,
71+
0.0784335999999992, 0.878468636469772, 0.0784336,
72+
0.0792237451477643, 0.0791661274605434, 0.879142973793104);
73+
74+
const float min_ev = -12.47393f;
75+
const float max_ev = 4.026069f;
76+
77+
// Input transform (inset)
78+
val = agx_mat * val;
79+
80+
// Log2 space encoding
81+
val = clamp(log2(val), min_ev, max_ev);
82+
val = (val - min_ev) / (max_ev - min_ev);
83+
84+
// Apply sigmoid function approximation
85+
val = agxDefaultContrastApprox(val);
86+
87+
return val;
88+
}
89+
90+
vec3 agxEotf(vec3 val) {
91+
const mat3 agx_mat_inv = mat3(
92+
1.19687900512017, -0.0528968517574562, -0.0529716355144438,
93+
-0.0980208811401368, 1.15190312990417, -0.0980434501171241,
94+
-0.0990297440797205, -0.0989611768448433, 1.15107367264116);
95+
96+
// Inverse input transform (outset)
97+
val = agx_mat_inv * val;
98+
99+
// sRGB IEC 61966-2-1 2.2 Exponent Reference EOTF Display
100+
// NOTE: We're linearizing the output here. Comment/adjust when
101+
// *not* using a sRGB render target
102+
val = pow(val, vec3(2.2));
103+
104+
return val;
105+
}
106+
107+
vec3 agxLook(vec3 val) {
108+
const vec3 lw = vec3(0.2126, 0.7152, 0.0722);
109+
float luma = dot(val, lw);
110+
111+
// Default
112+
vec3 offset = vec3(0.0);
113+
vec3 slope = vec3(1.0);
114+
vec3 power = vec3(1.0);
115+
float sat = 1.0;
116+
117+
#if AGX_LOOK == 1
118+
// Golden
119+
slope = vec3(1.0, 0.9, 0.5);
120+
power = vec3(0.8);
121+
sat = 0.8;
122+
#elif AGX_LOOK == 2
123+
// Punchy
124+
slope = vec3(1.0);
125+
power = vec3(1.35, 1.35, 1.35);
126+
sat = 1.4;
127+
#endif
128+
129+
// ASC CDL
130+
val = pow(val * slope + offset, power);
131+
return luma + sat * (val - luma);
132+
}
133+
134+
// Sample usage
135+
void main(/*...*/) {
136+
// ...
137+
value = agx(value);
138+
value = agxLook(value); // Optional
139+
value = agxEotf(value);
140+
// ...
141+
}
142+
143+
// 7th Order Polynomial Approximation: Mean error^2: 1.85907662e-06
144+
vec3 agxDefaultContrastApprox(vec3 x) {
145+
vec3 x2 = x * x;
146+
vec3 x4 = x2 * x2;
147+
vec3 x6 = x4 * x2;
148+
149+
return - 17.86 * x6 * x
150+
+ 78.01 * x6
151+
- 126.7 * x4 * x
152+
+ 92.06 * x4
153+
- 28.72 * x2 * x
154+
+ 4.361 * x2
155+
- 0.1718 * x
156+
+ 0.002857;
157+
}
158+
"""
159+
160+
161+
def agx_default_contrast_approx(x: np.ndarray):
162+
x2 = x * x
163+
x4 = x2 * x2
164+
x6 = x4 * x2
165+
return (-17.86 * x6 * x + 78.01 * x6
166+
- 126.7 * x4 * x + 92.06 * x4
167+
- 28.72 * x2 * x + 4.361 * x2
168+
- 0.1718 * x + 0.002857)
169+
170+
171+
def agx(x: np.ndarray):
172+
agx_mat = np.array([
173+
[0.842479062253094, 0.0423282422610123, 0.0423756549057051],
174+
[0.0784335999999992, 0.878468636469772, 0.0784336],
175+
[0.0792237451477643, 0.0791661274605434, 0.879142973793104]
176+
])
177+
min_ev = -12.47393
178+
max_ev = 4.026069
179+
x = np.maximum(np.matmul(x, agx_mat), np.power(2, min_ev - 1))
180+
x = np.clip(np.log2(x), min_ev, max_ev)
181+
x = (x - min_ev) / (max_ev - min_ev)
182+
return agx_default_contrast_approx(x)
183+
184+
185+
def agx_eotf(x: np.ndarray):
186+
agx_mat_inv = np.array([
187+
[1.19687900512017, -0.0528968517574562, -0.0529716355144438],
188+
[-0.0980208811401368, 1.15190312990417, -0.0980434501171241],
189+
[-0.0990297440797205, -0.0989611768448433, 1.15107367264116]
190+
])
191+
return np.power(np.maximum(np.matmul(x, agx_mat_inv), 0), 2.2)
192+
193+
194+
def agx_look(x: np.ndarray, look="default"):
195+
lw = np.array([0.2126, 0.7152, 0.0722])
196+
luma = np.dot(x, lw)[..., None]
197+
offset = np.array([0.0, 0.0, 0.0])
198+
slope = np.array([1.0, 1.0, 1.0])
199+
power = np.array([1.0, 1.0, 1.0])
200+
sat = 1.0
201+
if look == "golden":
202+
slope = np.array([1.0, 0.9, 0.5])
203+
power = np.array([0.8, 0.8, 0.8])
204+
sat = 0.8
205+
elif look == "punchy":
206+
slope = np.array([1.0, 1.0, 1.0])
207+
power = np.array([1.35, 1.35, 1.35])
208+
sat = 1.4
209+
x = np.power(x * slope + offset, power)
210+
return luma + sat * (x - luma)
211+
212+
213+
def agx_tonemap(x: np.ndarray, look="default"):
214+
return rgb2srgb(agx_eotf(agx_look(agx(x), look)))
215+
216+
23217
def imread(filename):
24218
return np.maximum(
25-
np.nan_to_num(cv.imread(filename, cv.IMREAD_UNCHANGED)[:, :, :3], nan=0.0, posinf=1e3, neginf=0), 0.0)
219+
np.nan_to_num(cv.imread(filename, cv.IMREAD_UNCHANGED)[:, :, :3], nan=0.0, posinf=1e3, neginf=0), 0.0)[..., ::-1]
26220

27221

28222
if __name__ == "__main__":
@@ -31,6 +225,7 @@ def imread(filename):
31225

32226
filename = argv[1]
33227
exp = 0 if len(argv) == 2 else float(argv[2])
228+
look = "default" if len(argv) <= 3 else argv[3].lower()
34229
assert filename.endswith(".exr")
35230
image = imread(filename) * (2 ** exp)
36-
cv.imwrite(f"{filename[:-4]}.png", tonemapping(image))
231+
cv.imwrite(f"{filename[:-4]}.png", agx_tonemap(image, look)[..., ::-1])

0 commit comments

Comments
 (0)