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)
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)
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)
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
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
117 toe_s = -
_sigmoid_scale(1.0, 1.0 - black_display, 1.0 - toe_x, 1.0 - toe_y,
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
129 sh_powcurve = shoulder_slope_matched
or sh_concave
132 x = np.clip(x, 0.0, 1.0)
135 y = black_display +
max(0.0, toe_fb_c * x ** toe_fb_p)
137 u = contrast * (x - toe_x) / toe_s
138 y = toe_s * (u / (1.0 + u ** toe_power) ** (1.0 / toe_power)) + toe_y
141 y = white_display -
max(0.0, sh_fb_c * (1.0 - x) ** sh_fb_p)
143 u = contrast * (x - sh_x) / sh_s
144 y = sh_s * (u / (1.0 + u ** shoulder_power) ** (1.0 / shoulder_power)) + sh_y
146 y = contrast * x + icpt
147 return np.clip(y, black_display, white_display) ** output_power
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
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()
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
192 L_scene = args.scene_white * (GREY * 2.0 ** evs)
193 J_scene =
ciecam16_J(L_scene, args.scene_white, 0.2 * args.scene_white,
"average")
195 flare = args.flare * args.display_white
196 La_disp = 0.2 * args.display_white
199 return ciecam16_J(y * args.display_white + flare, args.display_white + flare,
200 La_disp, args.surround)
203 w = 0.05 + np.exp(-0.5 * ((evs - args.content_center) / args.content_sigma) ** 2)
212 tau = np.abs(np.diff(J_scene, 2)) / dev**2 + 1.0
215 if args.fix_geometry:
216 return np.array([p[0], 33.0, 0.0, p[1], p[2]])
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])
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])
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)
241 curve = curve_factory(args.black_ev, args.white_ev, *eval_params(fit.x))
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))
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)")