Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
derive_filmic_default_curve.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Derive the default filmic RGB tone curve from an appearance match instead of taste.
4
5Principle: the tone curve is the luminance mapping between two *known* viewing
6states, so its default is a solvable problem, not an opinion:
7
8 scene state : diffuse white ~1000 cd/m2 outdoors, observer adapted to it,
9 average surround, no flare (the scene is the reference);
10 display state : SDR monitor ~100 cd/m2 white, dim surround, veiling flare
11 0.1% of white (demanding viewing : dim room, good display —
12 office-flare fits crush near-blacks, see --flare help).
13
14For each scene exposure (EV around mid-gray) we compute the perceived lightness
15J under the scene state with CIECAM16 (achromatic path), then ask which display
16luminance produces the same J under the display state — flare included. The
17unconstrained match cannot fit the display range, so we least-squares the
18filmic curve family onto it with two weights:
19
20 - a content-mass prior (where photographs hold detail), and
21 - a JND-visibility smoothness term: the rendering may not introduce
22 perceptual-lightness curvature (d2J/dEV2) sharper than the appearance
23 match itself contains anywhere. Without it the toe/shoulder powers rail
24 ("hold the match, clip hard") because errors at extreme EVs are cheap.
25
26The curve model below replicates the C v3 geometry EXACTLY
27(filmic_v3_compute_geometry / _nodes_from_legacy + the spline v4 sigmoid
28segments), so the fitted parameters are directly the module's user parameters.
29In particular the user 'contrast' is normalized by DR/8 and gamma-compensated,
30as in the C code.
31
32Usage:
33 python3.12 tools/derive_filmic_default_curve.py
34 ... --scene-white 5000 --display-white 200 --flare 0.01 # variants
35
36Changing shipped defaults from these numbers is a product decision:
37run, look, then decide.
38"""
39
40import argparse
41import numpy as np
42from scipy.optimize import least_squares
43
44GREY = 0.1845
45SAFETY_MARGIN = 0.01 # C: SAFETY_MARGIN
46BLACK_TARGET = 0.01517634 / 100.0 # C: black_point_target default, linear
47WHITE_TARGET = 1.0 # C: white_point_target default, linear
48
49# ------------------------------------------------------------- CIECAM16 (achromatic)
50
51def ciecam16_J(L, Lw, La, surround):
52 """Perceived lightness J of an achromatic stimulus of luminance L (cd/m2)
53 seen against a ~20% background under a white of Lw, adaptation La."""
54 c = {"average": 0.69, "dim": 0.59, "dark": 0.525}[surround]
55 k = 1.0 / (5.0 * La + 1.0)
56 F_L = 0.2 * k**4 * 5.0 * La + 0.1 * (1.0 - k**4) ** 2 * (5.0 * La) ** (1.0 / 3.0)
57 n = 0.2 # background/white ratio
58 Nbb = 0.725 * (1.0 / n) ** 0.2
59 z = 1.48 + np.sqrt(n)
60
61 def achromatic(Y_rel):
62 t = (F_L * np.maximum(Y_rel, 0.0) / 100.0) ** 0.42
63 Ra = 400.0 * t / (t + 27.13) + 0.1
64 return (3.05 * Ra - 0.305) * Nbb
65
66 return 100.0 * (achromatic(100.0 * L / Lw) / achromatic(100.0)) ** (c * z)
67
68# ------------------------------------------------------------- filmic curve model
69# exact port of filmic_v3_compute_geometry + filmic_v3_compute_nodes_from_legacy
70# + the spline v4 sigmoid solver (src/iop/filmicrgb.c)
71
72def _sigmoid_scale(limit_x, limit_y, tx, ty, slope, power):
73 projected = slope * max(1e-6, limit_x - tx)
74 actual = max(1e-6, limit_y - ty)
75 base = max(1e-6, actual ** -power - projected ** -power)
76 return min(1e9, base ** (-1.0 / power))
77
78def curve_factory(black_ev, white_ev, contrast_param, latitude_pct, balance_pct,
79 toe_power, shoulder_power, shoulder_slope_matched=False):
80 """Returns curve(x): normalized log input -> display-linear output.
81 Parameters are the module's user parameters, v3 geometry, perceptual sigmoid.
82 shoulder_slope_matched=True reproduces the shipped 'perceptual' shoulder : a
83 slope-matched power roll-off (exponent = slope*dx/dy, ignores shoulder_power),
84 matching filmic_rgb_compute_spline in the C code."""
85 dr = white_ev - black_ev
86 grey_log = abs(black_ev) / dr
87 output_power = np.log(GREY) / np.log(grey_log) # auto-hardness, as in C
88 grey_display = GREY ** (1.0 / output_power)
89 black_display = np.clip(BLACK_TARGET, 0.0, GREY) ** (1.0 / output_power)
90 white_display = max(WHITE_TARGET, GREY) ** (1.0 / output_power)
91
92 # user contrast -> spline slope : DR normalization + gamma compensation
93 slope = contrast_param * dr / 8.0
94 contrast = slope / (output_power * grey_display ** (output_power - 1.0))
95 min_contrast = max(1.0,
96 (white_display - grey_display) / (1.0 - grey_log),
97 (grey_display - black_display) / grey_log) + SAFETY_MARGIN
98 contrast = np.clip(contrast, min_contrast, 100.0)
99
100 icpt = grey_display - contrast * grey_log
101 margin = SAFETY_MARGIN * (white_display - black_display)
102 xmin = (black_display + margin - icpt) / contrast
103 xmax = (white_display - margin - icpt) / contrast
104
105 # latitude between grey and the slope-line intersections, balance = translation
106 lat = np.clip(latitude_pct, 0.0, 100.0) / 100.0
107 bal = np.clip(balance_pct, -50.0, 50.0) / 100.0
108 toe_x = (1.0 - lat) * grey_log + lat * xmin
109 sh_x = (1.0 - lat) * grey_log + lat * xmax
110 corr = 2.0 * bal * ((sh_x - grey_log) if bal > 0.0 else (grey_log - toe_x))
111 toe_x = max(toe_x - corr, xmin)
112 sh_x = min(sh_x - corr, xmax)
113 toe_y = toe_x * contrast + icpt
114 sh_y = sh_x * contrast + icpt
115
116 # sigmoid segments + degenerate fallbacks, as in the C spline v4 solver
117 toe_s = -_sigmoid_scale(1.0, 1.0 - black_display, 1.0 - toe_x, 1.0 - toe_y,
118 contrast, toe_power)
119 sh_s = _sigmoid_scale(1.0, white_display, sh_x, sh_y, contrast, shoulder_power)
120 toe_dx, toe_dy = max(1e-6, toe_x), max(1e-6, toe_y - black_display)
121 sh_dx, sh_dy = max(1e-6, 1.0 - sh_x), max(1e-6, white_display - sh_y)
122 toe_convex = toe_dy / toe_dx > contrast
123 sh_concave = sh_dy / sh_dx > contrast
124 toe_fb_p = contrast * toe_dx / toe_dy
125 toe_fb_c = toe_dy / toe_dx ** toe_fb_p
126 sh_fb_p = contrast * sh_dx / sh_dy
127 sh_fb_c = sh_dy / sh_dx ** sh_fb_p
128 # slope-matched shoulder always uses the power-curve branch (as C M5[1]=1)
129 sh_powcurve = shoulder_slope_matched or sh_concave
130
131 def curve(x):
132 x = np.clip(x, 0.0, 1.0)
133 if x < toe_x:
134 if toe_convex:
135 y = black_display + max(0.0, toe_fb_c * x ** toe_fb_p)
136 else:
137 u = contrast * (x - toe_x) / toe_s
138 y = toe_s * (u / (1.0 + u ** toe_power) ** (1.0 / toe_power)) + toe_y
139 elif x > sh_x:
140 if sh_powcurve:
141 y = white_display - max(0.0, sh_fb_c * (1.0 - x) ** sh_fb_p)
142 else:
143 u = contrast * (x - sh_x) / sh_s
144 y = sh_s * (u / (1.0 + u ** shoulder_power) ** (1.0 / shoulder_power)) + sh_y
145 else:
146 y = contrast * x + icpt
147 return np.clip(y, black_display, white_display) ** output_power
148
149 # metadata for other harnesses (anchor fit needs the compression zone)
150 curve.grey_log, curve.toe_x, curve.sh_x = grey_log, toe_x, sh_x
151 curve.spline_contrast, curve.output_power = contrast, output_power
152 return curve
153
154# ------------------------------------------------------------- the match
155
156def main():
157 ap = argparse.ArgumentParser()
158 ap.add_argument("--scene-white", type=float, default=1000.0,
159 help="scene diffuse white, cd/m2 (outdoor overcast ~1000, sunny ~5000)")
160 ap.add_argument("--display-white", type=float, default=100.0,
161 help="display white, cd/m2 (SDR reference 100)")
162 ap.add_argument("--flare", type=float, default=0.001,
163 help="display veiling flare as a fraction of display white. Default 0.1%% : fit "
164 "for demanding viewing (dim room, good display) so the default toe "
165 "does not crush for anyone ; 0.5%%-flare fits were reported as "
166 "black-crushing in visual testing.")
167 ap.add_argument("--surround", default="dim", choices=["average", "dim", "dark"])
168 ap.add_argument("--black-ev", type=float, default=-8.0)
169 ap.add_argument("--white-ev", type=float, default=4.0)
170 ap.add_argument("--content-center", type=float, default=-0.5,
171 help="EV center of the content-mass weighting")
172 ap.add_argument("--content-sigma", type=float, default=2.5)
173 ap.add_argument("--jnd-weight", type=float, default=1.0,
174 help="weight of the excess perceptual-curvature (JND) term")
175 ap.add_argument("--fix-geometry", action="store_true",
176 help="keep latitude/balance at the legacy shipped values (33 %%, 0) and "
177 "fit [contrast, powers] only. NOT the default : under spline v4 the "
178 "latitude is a *tension* control — small latitude hands the range to "
179 "the sigmoids (soft transitions), large latitude forces short, hard "
180 "turns — so it participates in the transition strength exactly like "
181 "the sigmoid powers and belongs in the fit. The CIECAM16 match has "
182 "no linear segment (cone compression is smooth everywhere), hence "
183 "the fit is started from a near-zero latitude.")
184 args = ap.parse_args()
185
186 dr = args.white_ev - args.black_ev
187 evs = np.linspace(args.black_ev, args.white_ev, 121)
188 dev = evs[1] - evs[0]
189 xs = (evs - args.black_ev) / dr
190
191 # scene appearance : the reference
192 L_scene = args.scene_white * (GREY * 2.0 ** evs) # diffuse white = 1.0
193 J_scene = ciecam16_J(L_scene, args.scene_white, 0.2 * args.scene_white, "average")
194
195 flare = args.flare * args.display_white
196 La_disp = 0.2 * args.display_white
197
198 def J_display(y):
199 return ciecam16_J(y * args.display_white + flare, args.display_white + flare,
200 La_disp, args.surround)
201
202 # content-mass weighting : where photographs actually hold detail
203 w = 0.05 + np.exp(-0.5 * ((evs - args.content_center) / args.content_sigma) ** 2)
204
205 # JND-visibility tolerance : LOCAL, not global. The rendering may not introduce
206 # perceptual curvature sharper than the appearance match itself has AT THE SAME
207 # exposure (plus a 1 J/EV^2 margin). A global max would license toe turns as
208 # sharp as the scene's own deep-shadow compression — the fit then holds the
209 # slope deep into the shadows and crushes near-blacks abruptly, unpenalized
210 # (observed : toe-side curvature 5 vs global tau 22, the term never bound —
211 # reported as crushed near-blacks in visual testing).
212 tau = np.abs(np.diff(J_scene, 2)) / dev**2 + 1.0
213
214 def eval_params(p):
215 if args.fix_geometry:
216 return np.array([p[0], 33.0, 0.0, p[1], p[2]])
217 return p # contrast, latitude, balance, toe_p, sh_p
218
219 def residuals(p):
220 curve = curve_factory(args.black_ev, args.white_ev, *eval_params(p))
221 J_out = J_display(np.array([curve(x) for x in xs]))
222 match = np.sqrt(w) * (J_out - J_scene)
223 curvature = np.abs(np.diff(J_out, 2)) / dev**2
224 jnd = args.jnd_weight * np.maximum(0.0, curvature - tau)
225 return np.concatenate([match, jnd])
226
227 if args.fix_geometry:
228 p0 = np.array([1.18, 1.5, 3.3])
229 bounds = ([0.5, 1.05, 1.05], [3.0, 16.0, 16.0])
230 else:
231 # latitude-as-tension : start the sweep with the latitude very close to zero
232 # (the sigmoids own the whole transition) and let the fit pull it up only if
233 # the appearance match asks for it. NB : the balance loses leverage as the
234 # latitude shrinks (its translation is proportional to the latitude span),
235 # so check the identifiability of the fitted balance before shipping it.
236 p0 = np.array([1.18, 1.0, 0.0, 1.5, 3.3])
237 bounds = ([0.5, 0.5, -50.0, 1.05, 1.05], [3.0, 99.0, 50.0, 16.0, 16.0])
238 fit = least_squares(residuals, p0, bounds=bounds, verbose=1)
239 contrast, latitude, balance, toe_p, sh_p = eval_params(fit.x)
240
241 curve = curve_factory(args.black_ev, args.white_ev, *eval_params(fit.x))
242 eps = 0.05
243 x_g = abs(args.black_ev) / dr
244 slope_loglog = (np.log10(curve(x_g + eps)) - np.log10(curve(x_g - eps))) \
245 / (2 * eps * dr * np.log10(2.0))
246
247 print(f"\n// fitted by tools/derive_filmic_default_curve.py")
248 print(f"// scene {args.scene_white:.0f} cd/m2 avg surround -> display "
249 f"{args.display_white:.0f} cd/m2 {args.surround} surround, flare {args.flare*100:.1f}%")
250 print(f"// DR [{args.black_ev:+.1f}, {args.white_ev:+.1f}] EV, "
251 f"JND weight {args.jnd_weight}, residual RMS {np.sqrt(2*fit.cost/len(evs)):.2f} J units")
252 print(f"contrast = {contrast:.3f} (shipped default 1.180)")
253 print(f"latitude = {latitude:.1f} % (shipped default 33.0 %)")
254 print(f"balance = {balance:+.1f} % (shipped default +0.0 %)")
255 print(f"toe power = {toe_p:.2f} (spline v4 'safe' 1.50)")
256 print(f"shoulder power = {sh_p:.2f} (spline v4 'safe' 3.30)")
257 print(f"spline slope = {curve.spline_contrast:.3f}, hardness = {curve.output_power:.3f}")
258 print(f"end-to-end log-log midtone slope = {slope_loglog:.3f} "
259 f"(Rec.709 ~1.2, cinema ~1.5)")
260
261if __name__ == "__main__":
262 main()
static const float const float const float min
const float max
_sigmoid_scale(limit_x, limit_y, tx, ty, slope, power)