Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
derive_filmic_agx_primaries.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Fit the inset/rotation anchor constants of filmic RGB's "AgX" color science
4(v8) — see doc/filmic-agx.md and filmic_agx_prepare_bracket() in
5src/iop/filmicrgb.c, whose PROVISIONAL constants this script is meant to replace.
6
7Model (mirrors the C pixel path, working profile = linear Rec2020):
8 work RGB -> inset matrix -> per-channel [log2 encoding -> sigmoid spline ->
9 hardness power] -> exact-inverse outset -> measure chroma / hue in Kirk Yrg.
10
11Objectives:
12 1. purity-vs-exposure: colors on the working-gamut boundary must reach
13 achromatic (chroma ratio ~ 0) at the white end of the curve, monotonically;
14 2. hue drift: dh/dEV in Yrg matches a chosen uniform target across the wheel
15 (0 for a neutral character, or a uniform warm bias);
16 3. hard constraints as penalties: displaced primaries stay inside the working
17 gamut triangle with a margin (positivity of the bracket) and away from
18 degeneracy (conditioning).
19
20Usage:
21 python3 tools/derive_filmic_agx_primaries.py --max-desat FRAC (recommended)
22 python3 tools/derive_filmic_agx_primaries.py --fit-priority
23 python3 tools/derive_filmic_agx_primaries.py [--drift-target DEG_PER_EV]
24
25The recommended mode is --max-desat : you state the maximum average chroma loss
26over the priority set (skin + reflective) you accept, and the solver returns the
27bracket with the best hue match at or below that budget. Sweep it to trace the
28hue/chroma frontier. See its --help for the full method.
29
30Prints the C arrays to paste into filmic_agx_prepare_bracket(). Requires
31numpy + scipy (run with python3.12). Constants below are copied verbatim from the
32C code so the model matches the pipeline bit-for-bit in spirit (float64 here,
33float32 there).
34"""
35
36import argparse
37import numpy as np
38from scipy.optimize import least_squares
39
40# ---------------------------------------------------------------- constants
41# copied from src/common/chromatic_adaptation.h and colorspaces_inline_conversions.h
42
43XYZ_D50_to_D65_CAT16 = np.array([
44 [9.89466254e-01, -4.00304626e-02, 4.40530317e-02],
45 [-5.40518733e-03, 1.00666069e+00, -1.75551955e-03],
46 [-4.03920992e-04, 1.50768030e-02, 1.30210211e+00]])
47
48XYZ_D65_to_LMS_2006 = np.array([
49 [0.257085, 0.859943, -0.031061],
50 [-0.394427, 1.175800, 0.106423],
51 [0.064856, -0.076250, 0.559067]])
52
53filmlightRGB_to_LMS = np.array([
54 [0.95, 0.38, 0.00],
55 [0.05, 0.62, 0.03],
56 [0.00, 0.00, 0.97]])
57LMS_to_filmlightRGB = np.linalg.inv(filmlightRGB_to_LMS)
58
59Y_2006_COEFFS = np.array([0.68990272, 0.34832189, 0.0])
60
61# linear Rec2020, D50-adapted (Bradford), as computed by the LCMS path for the
62# working profile. Close enough for anchor fitting; regenerate from the pipeline
63# if exactness matters.
64REC2020_TO_XYZ_D50 = np.array([
65 [0.6734241, 0.1656411, 0.1251286],
66 [0.2790177, 0.6753402, 0.0456377],
67 [-0.0019300, 0.0299784, 0.7973330]])
68
69# filmic defaults the anchors are fitted against — keep in sync with the C
70# $DEFAULT values. The curve model is shared with the appearance-match harness
71# (C-exact v3 node geometry + perceptual-sigmoid segments).
72from derive_filmic_default_curve import curve_factory, GREY # noqa: E402
73
74BLACK_EV, WHITE_EV = -8.0, 4.0
75CURVE_DEFAULTS = (1.18, 10.0, 0.0, 1.5, 1.5) # contrast, latitude %, balance %, toe power, (shoulder is slope-matched)
76CURVE = curve_factory(BLACK_EV, WHITE_EV, *CURVE_DEFAULTS, shoulder_slope_matched=True)
77
78# ---------------------------------------------------------------- color helpers
79
81 lms = XYZ_D65_to_LMS_2006 @ (XYZ_D50_to_D65_CAT16 @ xyz)
82 Y = Y_2006_COEFFS @ lms
83 a = lms.sum()
84 rgb = LMS_to_filmlightRGB @ (lms / a if a != 0 else lms)
85 return np.array([Y, rgb[0], rgb[1]])
86
88 return xyz_D50_to_Yrg(REC2020_TO_XYZ_D50 @ rgb)
89
90WHITE_YRG = rgb_work_to_Yrg(np.ones(3))
91
92def chroma_hue(Yrg):
93 r, g = Yrg[1] - WHITE_YRG[1], Yrg[2] - WHITE_YRG[2]
94 return np.hypot(r, g), np.arctan2(g, r)
95
96# ---------------------------------------------------------------- curve model
97
98def tone_map(rgb):
99 dr = WHITE_EV - BLACK_EV
100 out = np.empty(3)
101 for c in range(3):
102 x = (np.log2(max(rgb[c], 1e-10) / GREY) - BLACK_EV) / dr
103 out[c] = CURVE(x)
104 return out
105
106# ---------------------------------------------------------------- bracket model
107
108def bracket_matrices(insets, rotations):
109 """inset matrix M (work->rendering) and its exact inverse, exactly as the C code."""
110 white_xyz = REC2020_TO_XYZ_D50 @ np.ones(3)
111 P_prime = np.empty((3, 3))
112 for i in range(3):
113 p_yrg = xyz_D50_to_Yrg(REC2020_TO_XYZ_D50[:, i])
114 d = p_yrg[1:] - WHITE_YRG[1:]
115 s = 1.0 - insets[i]
116 ca, sa = np.cos(rotations[i]), np.sin(rotations[i])
117 rot = np.array([[ca, -sa], [sa, ca]])
118 rg = WHITE_YRG[1:] + s * (rot @ d)
119 # Yrg -> XYZ D50 (inverse path of xyz_D50_to_Yrg)
120 rgbn = np.array([rg[0], rg[1], 1.0 - rg[0] - rg[1]])
121 lms = filmlightRGB_to_LMS @ rgbn
122 lms *= p_yrg[0] / (Y_2006_COEFFS @ lms)
123 P_prime[:, i] = np.linalg.inv(XYZ_D50_to_D65_CAT16) @ \
124 np.linalg.inv(XYZ_D65_to_LMS_2006) @ lms
125 scale = np.linalg.solve(P_prime, white_xyz)
126 P_inset = P_prime * scale[None, :]
127 M = np.linalg.inv(REC2020_TO_XYZ_D50) @ P_inset
128 return M, np.linalg.inv(M)
129
130# ---------------------------------------------------------------- objectives
131
132EVS = np.arange(-2.0, WHITE_EV + 0.51, 0.5)
133HUE_STEPS = 24
134
136 """saturated colors on the working-gamut boundary, one channel at zero"""
137 out = []
138 for k in range(HUE_STEPS):
139 h = 2 * np.pi * k / HUE_STEPS
140 rgb = np.array([np.cos(h), np.cos(h - 2 * np.pi / 3), np.cos(h + 2 * np.pi / 3)])
141 rgb = (rgb - rgb.min()) / (rgb.max() - rgb.min()) # in [0,1], min channel 0
142 out.append(rgb)
143 return out
144
145SHOULDER_EV = BLACK_EV + CURVE.sh_x * (WHITE_EV - BLACK_EV) # where compression starts
146
147# ---------------------------------------------------------------- priority colors
148# The colors whose fidelity is non-negotiable : human skin tones (database from
149# src/common/color_vocabulary.c, CIE Lab under D65, avg ± 1.5 std corners) and
150# in-gamut diffuse reflectances, both swept over the tonal placements a
151# photographer may give them. Used to fit the outset recovery (see --fit-outset).
152
153_SKIN_LAB = [ # L avg,std, a avg,std, b avg,std — see color_vocabulary.c for sources
154 (60.9, 3.4, 7.0, 1.7, 15.0, 1.8), (61.9, 3.7, 7.1, 1.7, 17.4, 2.0),
155 (60.6, 4.8, 6.5, 1.6, 16.4, 2.3), (63.0, 5.5, 5.6, 1.9, 14.0, 2.9),
156 (56.4, 3.2, 11.7, 2.1, 16.3, 1.4), (56.8, 4.1, 11.6, 2.2, 17.7, 1.8),
157 (56.1, 4.5, 11.3, 2.1, 16.4, 2.2), (59.2, 5.1, 11.6, 2.8, 15.1, 2.3),
158 (44.0, 2.0, 14.0, 1.0, 19.0, 1.0), (58.0, 1.0, 15.0, 1.0, 21.0, 1.0),
159 (58.9, 3.1, 11.4, 2.1, 14.2, 1.5), (60.7, 4.0, 10.5, 2.3, 17.2, 2.1),
160 (58.0, 4.4, 11.7, 2.3, 15.8, 2.1), (59.6, 5.5, 11.8, 3.1, 14.6, 2.6),
161 (48.0, 1.0, 15.0, 1.0, 20.0, 1.0), (63.0, 1.0, 16.0, 1.0, 21.0, 1.0)]
162
163_D65_WHITE = np.array([0.95047, 1.0, 1.08883])
164
165def _lab_d65_to_work(L, a, b):
166 fy = (L + 16.0) / 116.0
167 fx, fz = fy + a / 500.0, fy - b / 200.0
168 f = lambda t: t**3 if t**3 > 0.008856 else (t - 16.0 / 116.0) / 7.787
169 xyz_d65 = _D65_WHITE * np.array([f(fx), f(fy), f(fz)])
170 xyz_d50 = np.linalg.inv(XYZ_D50_to_D65_CAT16) @ xyz_d65
171 return np.linalg.inv(REC2020_TO_XYZ_D50) @ xyz_d50
172
173def chroma_trajectory(sample, M, M_inv):
174 """chroma ratio and hue drift vs exposure, through the bracketed curve"""
175 c_in, h_in = chroma_hue(rgb_work_to_Yrg(sample))
176 ratios, drifts = [], []
177 for ev in EVS:
178 rgb = sample * GREY * 2.0 ** ev
179 mapped = M_inv @ tone_map(M @ rgb)
180 c_out, h_out = chroma_hue(rgb_work_to_Yrg(np.maximum(mapped, 1e-10)))
181 ratios.append(c_out / max(c_in, 1e-6))
182 drifts.append(np.remainder(h_out - h_in + np.pi, 2 * np.pi) - np.pi)
183 return np.array(ratios), np.array(drifts)
184
185IDENTITY = np.eye(3)
186BASELINES = {tuple(s): chroma_trajectory(s, IDENTITY, IDENTITY)[0] for s in boundary_colors()}
187
188def target_profile(baseline):
189 """Design target for the bleaching : transparent (baseline) below the shoulder,
190 then a smooth ramp to full achromatic exactly at the white endpoint. This pins
191 the bleach *rate* through the shoulder — without it the fit has no optimum and
192 rails the insets (endpoint-only objectives are monotone in the inset amount)."""
193 t = np.clip((EVS - SHOULDER_EV) / (WHITE_EV - SHOULDER_EV), 0.0, 1.0)
194 ramp = 1.0 - t * t * (3.0 - 2.0 * t) # smoothstep, 1 -> 0
195 return baseline * ramp
196
197def residuals(params, drift_target_rad_per_ev):
198 insets, rotations = params[:3], params[3:]
199 M, M_inv = bracket_matrices(insets, rotations)
200
201 res = []
202 # hard constraints as strong penalties : positivity with margin, over the whole
203 # user ray t in [1, 2] (the runtime slider scales insets and rotations together)
204 M2, _ = bracket_matrices(np.clip(2.0 * insets, 0.0, 0.9), 2.0 * rotations)
205 res.append(500.0 * np.maximum(0.0, 0.005 - M).sum())
206 res.append(500.0 * np.maximum(0.0, 0.005 - M2).sum())
207 res.append(10.0 * max(0.0, np.linalg.cond(M) - 8.0)) # conditioning
208 for sample in boundary_colors():
209 ratios, drifts = chroma_trajectory(sample, M, M_inv)
210 target = target_profile(BASELINES[tuple(sample)])
211 res.extend(3.0 * (ratios - target)) # purity-vs-exposure profile
212 res.extend(2.0 * np.maximum(0.0, np.diff(ratios))) # monotone bleaching
213 d = np.gradient(drifts, EVS)
214 res.extend(0.5 * (d - drift_target_rad_per_ev)) # uniform drift
215 return np.array(res, dtype=float)
216
217# ---------------------------------------------------------------- shipped variants + vectorized report
218# The three v8 "AgX" colorscience variants (see filmic_agx_prepare_bracket in
219# src/iop/filmicrgb.c). SINGLE SOURCE OF TRUTH — these must equal the C constants.
220# Regenerate each with the fit mode noted; --report measures them for the doc tables.
221SHIPPED_VARIANTS = { # inset is uniform ; outset is per-primary (over-expanding)
222 "no-bleach": dict(
223 fit="--min-bleach --ab-pull 200",
224 inset=[0.5991055, 0.6000000, 0.3300009],
225 irot=[0.0571015, 0.1999891, 0.0886110],
226 outset=[0.761433, 0.752267, 0.465293],
227 orot=[-0.0034297, 0.1952448, -0.0480109]
228 ),
229 "low-bleach": dict(
230 fit="--fit-bisect no-bleach medium-bleach",
231 inset=[0.6410825, 0.6898110, 0.3194529],
232 irot=[0.0405734, 0.1631286, 0.0350584],
233 outset=[0.784757, 0.789387, 0.445403],
234 orot=[-0.0057845, 0.1593207, -0.0592955]
235 ),
236 "medium-bleach": dict(
237 fit="--fit-bisect no-bleach extra-bleach",
238 inset=[0.6509540, 0.7488775, 0.3517703],
239 irot=[0.0278602, 0.1214671, -0.0228829],
240 outset=[0.793082, 0.815169, 0.460318],
241 orot=[-0.0053781, 0.1187604, -0.0794801]
242 ),
243 "high-bleach": dict(
244 fit="--fit-bisect medium-bleach extra-bleach",
245 inset=[0.6379749, 0.7878689, 0.3753822],
246 irot=[0.0106096, 0.0582598, -0.0696729],
247 outset=[0.790237, 0.831376, 0.465406],
248 orot=[-0.0080070, 0.0571100, -0.0912220]
249 ),
250 "extra-bleach": dict(
251 fit="--fit-extra-bleach --ab-stabilize 70 --ab-level 10 --bleach-nudge 0.5",
252 inset=[0.5770235, 0.8102094, 0.4000390],
253 irot=[-0.0081060, -0.0034008, -0.1035236],
254 outset=[0.766420, 0.838020, 0.465130],
255 orot=[-0.0122011, -0.0021732, -0.0971215]
256 ),
257}
258
259# Vectorized pipeline : NO subsampling. Every color in the priority set is
260# evaluated on every objective call, so the optimizer can never game the
261# average by wrecking a hue it cannot see. The tone curve branches on scalars,
262# so bake it into a monotone lookup table and apply it with np.interp.
263LUT_X = np.linspace(0.0, 1.0, 8193)
264LUT_Y = np.array([CURVE(v) for v in LUT_X])
265
266# Parameterization : the inset is a single UNIFORM scalar (p[0]), and the
267# per-primary action lives entirely in the outset — which is exactly the
268# structure the good fits converge to anyway (the shipped inset is 0.35 on all
269# three). A per-primary inset is the lever the optimizer abused to game the
270# gated metrics (green railed to 0.7, wrecking an unseen blue) ; removing that
271# DoF structurally forbids the pathology. 10 params :
272# p[0:3] inset chroma (R, G, B)
273# p[3:6] inset rotations (R, G, B)
274# p[6:9] outset chroma (R, G, B)
275# p[9:12] outset rotations (R, G, B)
276def brk(p):
277 M, _ = bracket_matrices(p[0:3], p[3:6])
278 Mo, _ = bracket_matrices(p[6:9], p[9:12])
279 return M, np.linalg.inv(Mo)
280
282 """(N,3) working-linear-Rec2020 -> (chroma, hue) in Kirk Yrg, relative to white."""
283 LMS = ((RGB @ REC2020_TO_XYZ_D50.T) @ XYZ_D50_to_D65_CAT16.T) @ XYZ_D65_to_LMS_2006.T
284 a = LMS.sum(axis=1, keepdims=True)
285 rg = (LMS / np.where(a == 0.0, 1.0, a)) @ LMS_to_filmlightRGB.T
286 dr, dg = rg[:, 0] - WHITE_YRG[1], rg[:, 1] - WHITE_YRG[2]
287 return np.hypot(dr, dg), np.arctan2(dg, dr)
288
290 """(inset matrix M, applied outset matrix) for a SHIPPED_VARIANTS entry."""
291 M, _ = bracket_matrices(v["inset"], v["irot"])
292 Mo, _ = bracket_matrices(v["outset"], v["orot"])
293 return M, np.linalg.inv(Mo)
294
295def measure_batch(S, C_in, H_in, M, Mo):
296 """(N,3) samples through inset -> per-channel curve -> outset ; returns
297 (chroma_ratio post output<=input clamp, hue_drift_deg), both (N,)."""
298 x = (np.log2(np.maximum(S @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
299 y = np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape)
300 O = np.maximum(y @ Mo.T, 1e-10)
301 c_f, h_f = chroma_hue_batch(O)
302 cr = np.minimum(c_f / np.maximum(C_in, 1e-9), 1.0)
303 dh = np.rad2deg(np.remainder(h_f - H_in + np.pi, 2 * np.pi) - np.pi)
304 return cr, dh
305
306def hk_drift_batch(S, M, Mo):
307 """Helmholtz-Kohlrausch apparent-brightness drift : excess(output) - excess(input),
308 per sample, through the same inset -> curve -> outset path as measure_batch. Positive
309 = the bracket made the colour read brighter-for-its-luminance than the original (H-K
310 inflation) ; negative = deflation. The look wants this as close to 0 as it can, so the
311 rendered colours keep the SAME apparent-brightness balance as the scene."""
312 x = (np.log2(np.maximum(S @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
313 y = np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape)
314 O = np.maximum(y @ Mo.T, 1e-10)
316
317def delta_e_yrg(cr, dh_deg):
318 """Perceptual color-shift distance in the chroma-NORMALIZED Yrg plane : the input
319 sits at (1, 0) — chroma ratio 1, zero hue drift — and the output at (cr*cos, cr*sin),
320 so the distance between them folds chroma loss AND hue drift into one number.
321 0 = colour unchanged ; ~1 = fully bleached ; up to 2 = hue-flipped at full chroma.
322
323 NOTE: subtracting the (1, 0) reference is the whole point. An earlier version
324 computed hypot(cr*cos, cr*sin), which reduces algebraically to just cr — the output
325 vector's LENGTH, not its distance from the input — so it was not a real delta-E and
326 cancelled the desaturation term to a constant. Both --min-bleach and --max-desat now
327 use THIS function."""
328 r = np.deg2rad(dh_deg)
329 return np.hypot(cr * np.cos(r) - 1.0, cr * np.sin(r))
330
332 """Helmholtz-Kohlrausch apparent-brightness excess (Gamma - 1), Nayatani (1997) VAC
333 model, for a batch of working-linear-Rec2020 colours. This is the FRACTIONAL amount
334 by which a chromatic colour looks brighter than an equally-luminous grey — a real
335 perceptual effect that per-channel tone mapping amplifies unevenly by hue. It is
336 ~0 for neutrals and yellow-greens and largest for saturated blue / red / magenta
337 (measured : gray 0.00, red 0.32, green 0.12, blue 0.43 at equal luminance), which is
338 exactly why an over-saturated red reads brighter than an equally-bright green.
339 Luminance-independent (a fractional boost), so penalizing it targets chroma+hue only."""
340 XYZ = RGB @ REC2020_TO_XYZ_D50.T
341 X, Y, Z = XYZ[:, 0], XYZ[:, 1], XYZ[:, 2]
342 denom = np.maximum(X + 15.0 * Y + 3.0 * Z, 1e-12)
343 u, v = 4.0 * X / denom, 9.0 * Y / denom
344 Xw, Yw, Zw = REC2020_TO_XYZ_D50 @ np.ones(3) # working white (D50)
345 dw = Xw + 15.0 * Yw + 3.0 * Zw
346 un, vn = 4.0 * Xw / dw, 9.0 * Yw / dw
347 du, dv = u - un, v - vn
348 s_uv = 13.0 * np.hypot(du, dv) # CIELUV saturation
349 th = np.arctan2(dv, du) # CIELUV hue angle
350 q = (-0.01585 - 0.03017 * np.cos(th) - 0.04556 * np.cos(2 * th)
351 - 0.02667 * np.cos(3 * th) - 0.00295 * np.cos(4 * th)
352 + 0.14592 * np.sin(th) + 0.05084 * np.sin(2 * th)
353 - 0.01944 * np.sin(3 * th) - 0.00776 * np.sin(4 * th))
354 L_a = 63.66 # adapting luminance -> K_Br ~= 1
355 K_Br = 0.2717 * (6.469 + 6.362 * L_a ** 0.4495) / (6.469 + L_a ** 0.4495)
356 return (0.0872 * K_Br - 0.1340 * q) * s_uv # Gamma - 1
357
358def scene_ab_target(S_refl, hk_retention=1.0):
359 """PRINCIPLED uniform apparent-brightness target for the apparent-brightness stabilizers
360 (--ab-pull on min-bleach, and the reference level for --ab-stabilize on extra), replacing a
361 hand-tuned constant. = mean over the reflective set of
362 curve(L_in) * (1 + hk_retention * H-K_excess(input))
363 i.e. the apparent brightness a colour gets if the ACHROMATIC tone curve maps its luminance and
364 it keeps `hk_retention` of its OWN natural (input) Helmholtz-Kohlrausch excess :
365 hk_retention = 1 -> SCENE-PRESERVING ceiling (full natural H-K pop, ~0.379)
366 hk_retention = 0 -> GRAY-EQUIVALENT floor (H-K fully neutralized, ~0.336)
367 hk_retention = 0.5 -> midway (~0.357) ; LINEAR in hk_retention, so 0.5 IS the average of the two.
368 Holding every hue at this value preserves the scene's apparent-brightness STRUCTURE (no hue
369 over/under-brightened relative to the others) at the chosen H-K-retention level. Hue-independent
370 (the set places every hue at luminance GREY*2^EV, matched), curve-relative (recomputes if the
371 default curve or the set changes). A chroma-preserving end (min-bleach) wants retention 1 ; a
372 bleaching end (extra), whose colours lose chroma/H-K, sits lower in the [floor, ceiling] band."""
373 y_row = REC2020_TO_XYZ_D50[1]
374 Lin = S_refl @ y_row
375 x = (np.log2(np.maximum(Lin, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
376 ach = np.interp(np.clip(x, 0.0, 1.0), LUT_X, LUT_Y) # achromatic (gray) tone response
377 return float((ach * (1.0 + hk_retention * nayatani_hk_excess(S_refl))).mean())
378
380 """The SINGLE canonical colour-constancy evaluation set — shared by --report AND every
381 fit mode, so a "desaturation %" (or hue / delta-E / H-K drift) means the same thing
382 everywhere. Two disjoint arrays, each swept over tonal placements : the skin-tone
383 database, and the reflective hue circle. The reflective circle spans a purity sweep
384 from LOW (0.3 : diffuse matte reflectances) to HIGH (0.9 : high-chroma memory colours) —
385 these used to live in two separate builders (this one and priority_samples), which is
386 what let the modes disagree on what "reflective" meant ; they are merged here."""
387 y_row = REC2020_TO_XYZ_D50[1]
388 def place(rgb, evs):
389 lum = y_row @ rgb
390 return [rgb * (GREY * 2.0 ** e / lum) for e in evs]
391 skin = []
392 for (L, Ls, a, As, b, Bs) in _SKIN_LAB:
393 for dL in (-1.5, 0.0, 1.5):
394 for da in (-1.5, 1.5):
395 for db in (-1.5, 1.5):
396 rgb = _lab_d65_to_work(L + dL * Ls, a + da * As, b + db * Bs)
397 if rgb.min() > 0:
398 skin += place(rgb, (-1.5, -0.75, 0.0, 0.75, 1.5))
399 refl = []
400 for k in range(12):
401 h = 2 * np.pi * k / 12
402 base = np.array([np.cos(h), np.cos(h - 2 * np.pi / 3), np.cos(h + 2 * np.pi / 3)])
403 base = (base - base.min()) / (base.max() - base.min())
404 for p in (0.3, 0.5, 0.7, 0.9): # 0.3 diffuse reflectance -> 0.9 high-chroma memory colour
405 refl += place(p * base + (1 - p) * 0.5, (-2, -1, 0, 1, 2, 2.5))
406 return np.array(skin), np.array(refl)
407
409 """Worst output luminance over the Rec2020 primaries and secondaries across the
410 tonal range. Rec2020 IS the working space, so its primaries are the most saturated
411 colors that can occur — the worst case. A strongly over-expanding outset can push
412 them to NEGATIVE luminance (the pixel renders BLACK); this must stay > 0. This is
413 the gamut-safety check that caught the no-bleach blue-goes-black bug."""
414 y_row = REC2020_TO_XYZ_D50[1]
415 bnd = [np.maximum(np.array(c, float), 1e-6) for c in
416 ([1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 1], [1, 0, 1], [1, 1, 0])]
417 worst = 1e9
418 for s in bnd:
419 lum0 = y_row @ s
420 for ev in np.arange(-12.0, 8.01, 0.5):
421 mr = M @ (s * GREY * 2.0 ** ev / lum0)
422 x = (np.log2(np.maximum(mr, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
423 cv = np.interp(np.clip(x, 0.0, 1.0), LUT_X, LUT_Y)
424 worst = min(worst, float(y_row @ (Mo @ cv)))
425 return worst
426
427# Rec2020 GAMUT SAFETY. The working space IS linear Rec2020, so its primaries
428# and secondaries are the most saturated colors representable — the true worst
429# case (more so than sRGB's). A strong outset over-expansion, which minimum-
430# desaturation wants, pushes these to NEGATIVE luminance -> black pixels : the
431# no-bleach blue-ramp-goes-black failure. Require the outset to retain at least
432# 25% of the pre-outset luminance for every primary/secondary across the tonal
433# range. (Deep shadows keep near-zero luminance either way — the ratio scales.)
434_BND = [np.maximum(np.array(c, float), 1e-6) for c in
435 ([1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 1], [1, 0, 1], [1, 1, 0])]
436_BND_EV = (-8, -6, -4, -2, -1, 0, 1, 2, 3)
437
439 worst = 0.0
440 y_row = REC2020_TO_XYZ_D50[1]
441 for s in _BND:
442 lum0 = y_row @ s
443 for ev in _BND_EV:
444 mr = M @ (s * GREY * 2.0 ** ev / lum0)
445 x = (np.log2(np.maximum(mr, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
446 cv = np.interp(np.clip(x, 0.0, 1.0), LUT_X, LUT_Y)
447 worst = max(worst, 0.25 * (y_row @ cv) - (y_row @ (Mo @ cv)))
448 return worst
449
450def fit_midpoint(lo_key, hi_key, inset_lo, inset_hi, seed_insets):
451 """Solve for the bracket that best reproduces the AVERAGE of two variants' processed
452 outputs — the perceptual midpoint. Target = 0.5*(post_bracket(lo) + post_bracket(hi))
453 over a skin + reflective + Rec2020-boundary sample set (skin weighted x2), fit in
454 least squares under Rec2020 gamut safety, positivity and conditioning. Returns the
455 10-parameter bracket (uniform inset, inset rot[3], outset[3], outset rot[3]) or None."""
456 from scipy.optimize import minimize
457 y_row = REC2020_TO_XYZ_D50[1]
458
459 def post_bracket(S, M, Mo):
460 x = (np.log2(np.maximum(S @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
461 y = np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape)
462 return y @ Mo.T
463
464 S_sk, S_rf = skin_and_reflective_sets()
465
466 def place(rgb, evs):
467 lum = y_row @ rgb
468 return [rgb * (GREY * 2.0 ** e / lum) for e in evs]
469 bnd = []
470 for c in ([1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 1], [1, 0, 1], [1, 1, 0]):
471 bnd += place(np.maximum(np.array(c, float), 1e-6), (-4, -2, 0, 2))
472 S = np.vstack([S_sk, S_rf, np.array(bnd)])
473 wt = np.ones(len(S)); wt[:len(S_sk)] = 2.0 # weight skin (portraits)
474
475 def input_chroma(RGB): # module chroma_hue_batch is shadowed inside main()
476 LMS = ((RGB @ REC2020_TO_XYZ_D50.T) @ XYZ_D50_to_D65_CAT16.T) @ XYZ_D65_to_LMS_2006.T
477 a = LMS.sum(axis=1, keepdims=True)
478 rg = (LMS / np.where(a == 0.0, 1.0, a)) @ LMS_to_filmlightRGB.T
479 return np.hypot(rg[:, 0] - WHITE_YRG[1], rg[:, 1] - WHITE_YRG[2])
480 keep = input_chroma(S) > 0.03
481 S, wt = S[keep], wt[keep]
482
483 Mlo, Molo = variant_bracket(SHIPPED_VARIANTS[lo_key])
484 Mhi, Mohi = variant_bracket(SHIPPED_VARIANTS[hi_key])
485 TARGET = 0.5 * (post_bracket(S, Mlo, Molo) + post_bracket(S, Mhi, Mohi))
486
487 BIG = 1e6
488
489 def objective(p):
490 if not (inset_lo <= p[0] <= inset_hi):
491 return BIG
492 if p[4:7].min() < 0.02 or p[4:7].max() > 0.98:
493 return BIG
494 if np.abs(np.concatenate([p[1:4], p[7:10]])).max() > np.deg2rad(25):
495 return BIG
496 M, Mo = brkp(p)
497 if M.min() < 0.004 or max(np.linalg.cond(M), np.linalg.cond(Mo)) > 6.5:
498 return BIG
499 if rec2020_worst_boundary_luminance(M, Mo) <= 0.0: # gamut safety : no black
500 return BIG
501 O = post_bracket(S, M, Mo)
502 return float(np.mean(wt[:, None] * (O - TARGET) ** 2))
503
504 best = None
505 for i0 in seed_insets:
506 x0 = [i0, -0.03, 0.16, 0.01, min(0.9, i0), min(0.9, i0 * 1.25), min(0.9, i0 * 0.9), -0.02, 0.17, 0.0]
507 r = minimize(objective, x0, method="Nelder-Mead",
508 options={"xatol": 1e-6, "fatol": 1e-9, "maxiter": 9000, "maxfev": 9000})
509 if r.fun < BIG and (best is None or r.fun < best.fun):
510 best = r
511 return best.x if best is not None else None
512
513def print_midpoint_constants(p, mode, endpoints):
514 """Print the C constant block for a fitted midpoint bracket (or a failure note)."""
515 if p is None:
516 print("// no feasible %s under the constraints." % mode)
517 return
518 M, _ = bracket_matrices(np.full(3, p[0]), p[1:4])
519 Mo, _ = bracket_matrices(p[4:7], p[7:10])
520 Mo = np.linalg.inv(Mo)
521 print("// fitted by tools/derive_filmic_agx_primaries.py %s" % mode)
522 print("// perceptual midpoint of %s (average of processed outputs)" % endpoints)
523 print("// Rec2020 gamut safety : worst boundary luminance %+.4f ; cond %.1f"
524 % (rec2020_worst_boundary_luminance(M, Mo), max(np.linalg.cond(M), np.linalg.cond(Mo))))
525 print("static const float inset_anchor[3] = { %.6ff, %.6ff, %.6ff };" % (p[0], p[0], p[0]))
526 print("static const float rotation_anchor[3] = { %+.7ff, %+.7ff, %+.7ff }; // %+.2f°, %+.2f°, %+.2f°"
527 % (p[1], p[2], p[3], *np.rad2deg(p[1:4])))
528 print("static const float outset_anchor[3] = { %.6ff, %.6ff, %.6ff };" % (p[4], p[5], p[6]))
529 print("static const float outset_rotation[3] = { %+.7ff, %+.7ff, %+.7ff }; // %+.2f°, %+.2f°, %+.2f°"
530 % (p[7], p[8], p[9], *np.rad2deg(p[7:10])))
531
532def print_variant_entry(name, fit, inset, irot, outset, orot):
533 """Print a SHIPPED_VARIANTS entry that can be pasted directly into the script."""
534 print(' "%s": dict(' % name)
535 print(' fit="%s",' % fit)
536 print(' inset=[%.7f, %.7f, %.7f],' % tuple(inset))
537 print(' irot=[%.7f, %.7f, %.7f],' % tuple(irot))
538 print(' outset=[%.6f, %.6f, %.6f],' % tuple(outset))
539 print(' orot=[%.7f, %.7f, %.7f]' % tuple(orot))
540 print(' ),')
541
542def print_c_case(case_name, fit, inset, irot, outset, orot):
543 """Print a C switch-case block for the fitted bracket constants."""
544 print(' case %s: // %s : %s' % (case_name, case_name, fit))
545 print(' // fitted by tools/derive_filmic_agx_primaries.py %s' % fit)
546 print(' inset_anchor[0] = %+.7ff; inset_anchor[1] = %+.7ff; inset_anchor[2] = %+.7ff;' % tuple(inset))
547 print(' rotation_anchor[0] = %+.7ff; rotation_anchor[1] = %+.7ff; rotation_anchor[2] = %+.7ff;' % tuple(irot))
548 print(' outset_anchor[0] = %.6ff; outset_anchor[1] = %.6ff; outset_anchor[2] = %.6ff;' % tuple(outset))
549 print(' outset_rotation[0] = %+.7ff; outset_rotation[1] = %+.7ff; outset_rotation[2] = %+.7ff;' % tuple(orot))
550 print(' break;')
551
553 """Print the {avg,max} x {desaturation, hue drift, delta-E} x {skin,reflective} table
554 for the shipped variants, plus a Rec2020 gamut-safety check. Desaturation is over all
555 colors that carry chroma ; hue drift is measured where chroma survives (ratio > 0.2 —
556 a bleached color has no meaningful hue) ; delta-E is the combined chroma+hue fidelity
557 (delta_e_yrg) over ALL samples, the single tie-breaker metric. Source of the tables in
558 doc/filmic-agx.md and the user docs."""
559 S_sk, S_rf = skin_and_reflective_sets()
560 Csk, Hsk = chroma_hue_batch(S_sk)
561 Crf, Hrf = chroma_hue_batch(S_rf)
562 mk = Csk > 0.04 # drop near-neutral inputs (ratio is noise)
563 S_sk, Csk, Hsk = S_sk[mk], Csk[mk], Hsk[mk]
564 mr = Crf > 0.05
565 S_rf, Crf, Hrf = S_rf[mr], Crf[mr], Hrf[mr]
566
567 def stat(S, C, H, M, Mo):
568 cr, dh = measure_batch(S, C, H, M, Mo)
569 d = (1.0 - cr) * 100.0
570 g = np.abs(dh[cr > 0.2])
571 de = delta_e_yrg(cr, dh) # combined chroma+hue fidelity, ALL samples
572 hk = hk_drift_batch(S, M, Mo) # H-K excess(out) - excess(in), signed
573 hk_ext = hk[np.argmax(np.abs(hk))] # signed drift of largest magnitude
574 return (d.mean(), d.max(), g.mean(), g.max(),
575 de.mean(), de.max(), hk.mean(), hk_ext)
576
577 def row(name, s):
578 # one Markdown row : "desat avg / max | hue avg / max | ΔE avg / max | H-K avg / max"
579 return ("| %-13s | %4.1f / %4.1f | %4.1f / %4.1f | %.2f / %.2f | %+.3f / %+.3f |"
580 % (name, s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]))
581
582 hdr = ("| Variant | Desat. % (avg / max) | Hue drift ° (avg / max) "
583 "| ΔE (avg / max) | H-K drift (avg / max) |")
584 sep = "|---|---|---|---|---|"
585
586 print("<!-- Auto-generated by tools/derive_filmic_agx_primaries.py --report. Do not edit by hand. -->")
587 print("<!-- Metrics over the skin database (De Rigal/Xiao) and the diffuse memory-colour hue")
588 print(" circle, across tonal placements. Desaturation = 1 - output/input chroma (%). Hue")
589 print(" drift = |output - input| hue in Kirk Yrg, where chroma survives. ΔE = combined")
590 print(" chroma+hue move in the chroma-normalized Yrg plane. H-K drift = signed change in")
591 print(" Nayatani Helmholtz-Kohlrausch apparent-brightness excess, output vs input. -->\n")
592
593 print("### Skin tones\n")
594 print(hdr); print(sep)
595 for name, v in SHIPPED_VARIANTS.items():
596 M, Mo = variant_bracket(v)
597 print(row(name, stat(S_sk, Csk, Hsk, M, Mo)))
598
599 print("\n### Reflective colours\n")
600 print(hdr); print(sep)
601 for name, v in SHIPPED_VARIANTS.items():
602 M, Mo = variant_bracket(v)
603 print(row(name, stat(S_rf, Crf, Hrf, M, Mo)))
604
605 print("\n### Rec2020 gamut safety\n")
606 print("<!-- Worst output luminance over the Rec2020 primaries and secondaries, EV -12..+8.")
607 print(" Must stay > 0 — a negative value renders BLACK. -->\n")
608 print("| Variant | Worst boundary luminance | Status |")
609 print("|---|---|---|")
610 for name, v in SHIPPED_VARIANTS.items():
611 M, Mo = variant_bracket(v)
613 print("| %-13s | %+.4f | %s |" % (name, wl, "OK" if wl > 0 else "**BLACK**"))
614
615def per_hue_ab_and_drift(v, S, C, H, bin_idx, nbins):
616 """For one variant, return (apparent_brightness[nbins], signed_hue_drift_deg[nbins],
617 output_chroma[nbins]) binned over the reflective hue circle. APPARENT BRIGHTNESS = output
618 luminance x (1 + Nayatani H-K excess) — how bright a colour READS, not just its luminance.
619 OUTPUT CHROMA = Yrg saturation — must DECREASE monotonically no->extra (the non-monotone-
620 saturation bug lived here). Hue drift is signed, only where output chroma survives (ratio > 0.2)."""
621 y_row = REC2020_TO_XYZ_D50[1]
622 M, Mo = variant_bracket(v)
623 x = (np.log2(np.maximum(S @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
624 O = np.maximum(np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape) @ Mo.T, 1e-10)
625 ab = (O @ y_row) * (1.0 + nayatani_hk_excess(O))
626 c_f, h_f = chroma_hue_batch(O)
627 cr = np.minimum(c_f / np.maximum(C, 1e-9), 1.0)
628 dh = np.rad2deg(np.remainder(h_f - H + np.pi, 2 * np.pi) - np.pi)
629 AB = np.array([ab[bin_idx == b].mean() if (bin_idx == b).any() else np.nan for b in range(nbins)])
630 HD = np.array([dh[(bin_idx == b) & (cr > 0.2)].mean() if ((bin_idx == b) & (cr > 0.2)).any()
631 else np.nan for b in range(nbins)])
632 CH = np.array([c_f[bin_idx == b].mean() if (bin_idx == b).any() else np.nan for b in range(nbins)])
633 return AB, HD, CH
634
636 """Per-hue colour-CONTINUITY diagnostic across the bleach ladder (reflective set). For each
637 of 12 hue bins it prints, for every shipped variant, the APPARENT BRIGHTNESS (output luminance
638 x (1 + H-K excess)) and the SIGNED hue drift, with the per-step deltas. A well-behaved ladder
639 is MONOTONE with EVEN steps per hue ; a big jump (e.g. reds/magentas over-brightening at one
640 step) or a sign reversal (a hue rotating against the ramp) marks a variant that left the ramp.
641 This is the tool behind the no-bleach red-darkening + low-bleach hue-divergence fixes."""
642 S_sk, S_rf = skin_and_reflective_sets()
643 Crf, Hrf = chroma_hue_batch(S_rf)
644 mr = Crf > 0.05
645 S, C, H = S_rf[mr], Crf[mr], Hrf[mr]
646 nbins = 12
647 bin_idx = (np.floor(np.remainder(H, 2 * np.pi) / (2 * np.pi) * nbins).astype(int)) % nbins
648 labels = ["red", "red-orange", "orange", "yellow-green", "green", "green-cyan",
649 "cyan", "cyan-blue", "blue", "blue-magenta", "magenta", "magenta-red"]
650 names = list(SHIPPED_VARIANTS.keys())
651 data = {n: per_hue_ab_and_drift(SHIPPED_VARIANTS[n], S, C, H, bin_idx, nbins) for n in names}
652 hdr = "%-13s" % "hue" + "".join("%9s" % n[:8] for n in names) + " | per-step Δ"
653
654 print("# PER-HUE COLOUR CONTINUITY across the bleach ladder (reflective set ; --diagnose).")
655 print("# Smooth ladder = MONOTONE with EVEN per-step Δ. A big jump or a sign reversal marks")
656 print("# a variant off the ramp (over-brightened reds/magentas, or a hue rotating the wrong way).\n")
657 for title, idx, fmt, dfmt in [
658 ("APPARENT BRIGHTNESS (output luminance x (1 + Nayatani H-K excess))", 0, "%9.3f", "%+.3f"),
659 ("OUTPUT CHROMA (Yrg saturation ; must DECREASE monotonically no->extra)", 2, "%9.4f", "%+.4f"),
660 ("SIGNED HUE DRIFT deg (where output chroma survives, ratio > 0.2)", 1, "%9.1f", "%+.1f")]:
661 print("## " + title)
662 print(hdr)
663 for b in range(nbins):
664 vals = [data[n][idx][b] for n in names]
665 if any(np.isnan(vals)):
666 continue
667 steps = " ".join(dfmt % (vals[i + 1] - vals[i]) for i in range(len(vals) - 1))
668 print("%-13s" % labels[b] + "".join(fmt % v for v in vals) + " | " + steps)
669 print()
670
671def print_diagnostics(p, message):
672 inset = p[0:3]
673 irot = p[3:6]
674 outset = p[6:9]
675 orot = p[9:12]
676 SHIPPED_VARIANTS["new fitting"] = dict(
677 fit=message,
678 inset=inset,
679 irot=irot,
680 outset=outset,
681 orot=orot
682 )
684 print("// paste this into SHIPPED_VARIANTS:")
685 print_variant_entry("new fitting", message, inset, irot, outset, orot)
686 print("// paste this into C code:")
687 print_c_case("new fitting", message, inset, irot, outset, orot)
688
689
690# ---------------------------------------------------------------- main
691
692def main():
693 ap = argparse.ArgumentParser()
694 ap.add_argument("--drift-target", type=float, default=0.0,
695 help="uniform hue drift target in degrees per EV of compression "
696 "(0 = neutral/hue-stable character, e.g. -0.5 for a warm bias)")
697 ap.add_argument("--inset", type=float, default=0.25,
698 help="uniform inset anchor. NOT fitted: bleach depth saturates with the "
699 "inset (the exact-inverse outset re-expands what the curve did not "
700 "equalize, and the Yrg gamut mapper owns the very endpoint), so "
701 "endpoint objectives rail it against any bound. 0.25 sits at the "
702 "knee of diminishing returns with ~0.96 midtone transparency at "
703 "+1 EV and leaves conditioning headroom for the user ray t <= 2.")
704 ap.add_argument("--fit-insets", action="store_true",
705 help="fit all 6 parameters anyway (diagnostic; expect railed insets)")
706 ap.add_argument("--fit-outset", action="store_true",
707 help="fit the outset recovery factor kappa. The outset is the inverse of "
708 "the bracket built with kappa-scaled insets (kappa = 1 : exact "
709 "inverse). An exact inverse mandatorily bleaches every color the "
710 "curve touches — with the low-latitude sigmoid default that is the "
711 "whole tonal range, washing out valid midtone colors (skin tones). "
712 "kappa > 1 over-expands so that priority colors (skin database + "
713 "diffuse reflectances, over their tonal placements) reach the "
714 "output-chroma-equals-input clamp : the clamp then trims recovery "
715 "to exactly 1.0 per pixel, tone-adaptively, which is what makes one "
716 "fixed kappa portable across curves and dynamic ranges (verified "
717 "6.5-16 EV). Then re-fits the inset-blue and outset rotations for the "
718 "RECOVERED-chroma regime (the kappa recovery re-exposes drift the "
719 "bleaching used to hide), under the maintainer's priority ordering. "
720 "This is the CURRENT production fit ; it supersedes --minimax, which "
721 "was correct only for the earlier bleached (exact-inverse) regime.")
722 ap.add_argument("--minimax", action="store_true",
723 help="fit rotations for the WORST-CASE hue drift (Chebyshev) over "
724 "EV <= +3.5 instead of zero-mean L2. This is the right objective "
725 "when the hue is NOT anchored in Ych afterwards (see "
726 "FILMIC_AGX_UNANCHORED_HUE_TEST in filmicrgb.c) : without a "
727 "backstop, only the worst case matters. Floor is ~28.5° — the "
728 "drift field reverses direction between toe and shoulder, which "
729 "no constant matrix pair can serve on both sides. SUPERSEDED by "
730 "--fit-outset : minimax was fit for the bleached exact-inverse regime "
731 "and its blue rotation now CAUSES purple on recovered blues.")
732 ap.add_argument("--fit-priority", action="store_true",
733 help="CURRENT production fit. Joint Nelder-Mead fine-tune of the whole "
734 "bracket (per-primary inset chroma + rotation, per-primary outset "
735 "chroma + rotation = 12 params) started from the uniform-0.25 / "
736 "kappa=2 config, minimizing hue drift over the PRIORITY set (skin "
737 "database + diffuse reflectances) at preserved chroma. Supersedes "
738 "--fit-outset : the outset is now a per-primary expansion, not a "
739 "scalar kappa. Hard constraints (barriers the optimizer cannot "
740 "cross) : skin red-ward drift <= -1.5 deg, skin chroma >= 92%%, "
741 "diffuse recovery p5 >= 0.97 (bleaching cannot game accuracy), inset "
742 "positivity >= 0.004, conditioning <= 6.5. Inset capped at 0.35 to "
743 "keep rendering character (the free optimum runs 0.55 for a sub-JND "
744 "gain at a visible highlight-desaturation cost). sRGB blue at high EV "
745 "is not targeted — a structural DoF limit, left to the Ych recovery.")
746 ap.add_argument("--max-desat", type=float, default=None, metavar="FRAC",
747 help="Desaturation-budgeted fit — the recommended way to set the bracket. "
748 "FRAC is the MAXIMUM average chroma loss (1 - output/input chroma, post "
749 "output<=input clamp) over the priority set (skin database + diffuse "
750 "reflectances) you are willing to trade for hue accuracy — e.g. 0.05 = "
751 "at most 5%% average desaturation. The solver minimizes hue drift and "
752 "spends desaturation on hue up to FRAC : a hard barrier caps the "
753 "average, and a tiny chroma-preference term keeps chroma as high as the "
754 "hue optimum allows, so a slack budget is not wasted (the 'best chroma "
755 "match' half). The inset is a single UNIFORM scalar (10 params : inset "
756 "chroma + 3 inset rotations + 3 outset chroma + 3 outset rotations) — a "
757 "per-primary inset is the lever the optimizer abuses to game the metric "
758 "(green rails to 0.7, wrecking an unseen blue), and the good fits use a "
759 "uniform inset with the per-primary action in the outset anyway. The "
760 "whole priority set is evaluated every step (vectorized, no subsample), "
761 "so no color can hide ; the worst single hue is hard-capped at 16 deg ; "
762 "skin red-ward drift stays vetoed (<= -1.5 deg) regardless of budget. "
763 "The desaturation is mostly the bright-color / highlight bleach (the AgX "
764 "wash-out look), so individual highlights bleaching hard is expected and "
765 "not penalized. Sweep FRAC to trace the hue/chroma frontier : ~0.02 "
766 "gives inset 0.63 / skin 1.3 deg / refl max 10 deg ; ~0.05 gives inset "
767 "0.75 / skin 0.8 deg / refl max 6 deg (saturated colors visibly bleach) ; "
768 "beyond ~0.05 hue stops improving. Supersedes --fit-priority (which is "
769 "roughly FRAC 0.02 on a uniform 0.35 inset).")
770 ap.add_argument("--min-bleach", action="store_true",
771 help="Fit the NO-BLEACH variant : minimize the bracket's own desaturation, "
772 "letting hue drift (which the downstream Ych hue-recovery restores — "
773 "there is no downstream saturation recovery, so chroma is what must be "
774 "protected here). COUNTERINTUITIVE result this encodes : a hard 0%% "
775 "inset is the WORST case for saturation (~7.7%% avg desat), because "
776 "with no inset the outset cannot over-expand without wrecking "
777 "conditioning, so bright colors bleach from the raw per-channel curve "
778 "with nothing to recover them. The over-expanding outset is what un-"
779 "bleaches (pulls chroma back up to the output<=input clamp), and it "
780 "only becomes well-conditioned at inset >= ~0.2. So minimum bleach sits "
781 "at a MODERATE inset ~0.25 (avg desat < 0.5%%, conditioning ~3, very "
782 "stable) — not near zero. Objective : minimize avg + worst-color "
783 "desaturation over the priority set, inset in [0.20, 0.30], worst hue "
784 "<= 24 deg (recoverable), conditioning <= 4.5 (stability), skin red-ward "
785 "drift <= -2.5 deg.")
786 ap.add_argument("--fit-low-bleach", action="store_true",
787 help="Fit the LOW-BLEACH variant as the PERCEPTUAL MIDPOINT of no-bleach and "
788 "high-bleach. Rather than a desaturation budget (which put low-bleach's "
789 "hue too close to high-bleach — the visible gap no->low was larger than "
790 "low->high), the target is the straight average of the no-bleach and "
791 "high-bleach PROCESSED OUTPUTS (post-bracket display RGB) over a skin + "
792 "reflective + Rec2020-boundary sample set (skin weighted x2 for "
793 "portraits) ; the low-bleach bracket is solved to reproduce that average "
794 "in least squares. Result bisects the hue drift evenly on both sides and "
795 "keeps skin chroma (avoids high-bleach's skin whitening). Constrained to "
796 "Rec2020 gamut safety, skin red-ward veto, positivity, conditioning "
797 "<= 6.5. Reads no/high-bleach from SHIPPED_VARIANTS ; refit if either "
798 "endpoint changes.")
799 ap.add_argument("--fit-medium-bleach", action="store_true",
800 help="Fit the MEDIUM-BLEACH variant as the PERCEPTUAL MIDPOINT of low-bleach and "
801 "high-bleach. See --fit-low-bleach")
802 ap.add_argument("--interpolate-fits", action="store_true",
803 help="Fit the MEDIUM-BLEACH variant as the PERCEPTUAL MIDPOINT of low-bleach and "
804 "high-bleach. See --fit-low-bleach")
805 ap.add_argument("--report", action="store_true",
806 help="Measure the three SHIPPED variants (no/low/high bleach) and print the "
807 "{avg ; max} desaturation and hue-shift table for skin tones vs "
808 "reflective colors — the source of the tables in doc/filmic-agx.md and "
809 "the user docs. Reads the constants from SHIPPED_VARIANTS (which must "
810 "mirror filmic_agx_prepare_bracket in the C), so it doubles as a drift "
811 "check that the shipped brackets still measure as documented.")
812 ap.add_argument("--diagnose", action="store_true",
813 help="Per-hue colour-CONTINUITY diagnostic across the whole bleach ladder : for "
814 "each of 12 hue bins, the APPARENT BRIGHTNESS (output luminance x (1 + H-K "
815 "excess)) and the SIGNED hue drift of every shipped variant, with per-step "
816 "deltas. A smooth ladder is monotone with even steps ; a jump or sign "
817 "reversal flags a variant off the ramp (reds/magentas over-brightening, or "
818 "a hue rotating the wrong way). The tool behind the continuity fixes.")
819 ap.add_argument("--hk-weight", type=float, default=0.0, metavar="W",
820 help="OPTIONAL Helmholtz-Kohlrausch FIDELITY term for --min-bleach (default 0 = "
821 "off). Saturated colours look brighter than an equally-luminous grey, hue-"
822 "dependently (strongest blue/red/magenta, weakest yellow-green ; Nayatani "
823 "1997 VAC model). A bracket that bleaches some hues more than others "
824 "therefore SHIFTS their apparent brightness and can amplify e.g. the "
825 "red<->green brightness gap. With W > 0, --min-bleach adds "
826 "W * mean(|H-K excess(output) - H-K excess(input)|) over the reflective "
827 "set : it penalizes the apparent-brightness CHANGE the bracket introduces, "
828 "keeping each colour's perceived brightness as close to the original as "
829 "possible (a fidelity term, complementary to the delta_e_yrg chroma/hue "
830 "distance). It is the DIFFERENCE before vs after, NOT the absolute output "
831 "excess — penalizing the absolute would flatten the vivid hues toward "
832 "neutral, i.e. AWAY from the original. Applied to reflective colours only, "
833 "so it does not fight skin-chroma protection. The per-set mean change is "
834 "small (the fit already preserves chroma) and the other objective terms "
835 "sum to ~1, so try W ~ 5-20 ; sweep to taste. Higher W holds apparent "
836 "brightness closer to the original.")
837 ap.add_argument("--desat-frac", type=float, default=0.5, metavar="F",
838 help="For --fit-medium-bleach : position the interior variant at reflective-"
839 "desaturation fraction F between the no-bleach (0) and extra-bleach (1) "
840 "ends. 0.25 -> low-bleach, 0.5 -> medium-bleach (default), 0.75 -> high-"
841 "bleach. Same per-hue-ramp / soft-gamut / interpolation-anchor fit, so the "
842 "whole ladder stays a continuous, gamut-safe ramp between the settled ends.")
843 ap.add_argument("--fit-extra-bleach", action="store_true",
844 help="Fit the EXTRA-BLEACH end : minimize the REFLECTIVE colours' hue shift "
845 "(bleach is allowed, hue must stay correct) AND the SKIN delta-E (skin "
846 "stays faithful) — both as the objective, not caps. No reflective desat "
847 "term, so the fit bleaches as hard as Rec2020 gamut safety + conditioning "
848 "allow, flattening per-channel hue drift : the extreme end of the look "
849 "axis. Hue is taken in radians to match the delta-E scale. Skin red-ward "
850 "drift stays vetoed. Reads nothing from SHIPPED_VARIANTS (a true end).")
851 ap.add_argument("--ab-pull", type=float, default=0.0, metavar="W",
852 help="--min-bleach only : pull each hue's APPARENT BRIGHTNESS (output luminance "
853 "x (1 + Nayatani H-K excess)) toward the MIDPOINT of the shipped no-bleach "
854 "and low-bleach, so no-bleach stops darkening reds/magentas off the ladder "
855 "('true red sits between no and low'). Fixed reference read from "
856 "SHIPPED_VARIANTS once (not circular). 0 = off (pure min-delta-E).")
857 ap.add_argument("--bleach-nudge", type=float, default=0.5, metavar="W",
858 help="Soft reflective-desaturation reward on --fit-extra-bleach (default 0.5). "
859 "Tips the hue-vs-bleach trade-off toward MORE bleaching without a hard "
860 "desat target — a nudge, not a requirement (extreme bleach is deferred to "
861 "creative grading). Bounded by the gamut penalty, skin delta-E and the "
862 "conditioning cap, so it stays gamut-safe. W=0 is the pure hue/skin-"
863 "faithful fit (the theoretically-sound end) ; raise it for a bolder end.")
864 ap.add_argument("--ab-stabilize", type=float, default=0.0, metavar="W",
865 help="--fit-extra-bleach only : weight of per-hue APPARENT-BRIGHTNESS UNIFORMITY "
866 "(output luminance x (1 + H-K excess)) at the extra end — keep every hue at the "
867 "SAME apparent brightness so bleaching does NOT over-brighten reds/magentas "
868 "relative to other hues. This term is TARGET-FREE (penalizes spread around the "
869 "mean), so it does not make the solver sensitive to the exact target level. "
870 "0 = off. Pairs with --ab-level (the gentle absolute-level pull).")
871 ap.add_argument("--ab-level", type=float, default=10.0, metavar="W",
872 help="--fit-extra-bleach only : weight of the GENTLE pull of the MEAN apparent "
873 "brightness toward the target LEVEL (scene_ab_target average, ~0.357), SEPARATE "
874 "from --ab-stabilize (uniformity). Kept LOW by design : folding the level into "
875 "the uniformity term (the old W*sum((ab-target)^2)) put ~45%% of the weight on "
876 "the absolute level, so a 0.003 target change flipped the fit into a blue-"
877 "distorting basin. 0 = let the level float entirely (uniformity only ; the "
878 "--bleach-nudge desat reward then sets the level).")
879 ap.add_argument("--fit-bisect", nargs=2, metavar=("LO", "HI"),
880 help="Fit the PERCEPTUAL MIDPOINT between two shipped variants LO and HI : targets, "
881 "per reflective hue, the MIDPOINT of their apparent brightness AND signed hue "
882 "drift (+ skin faithfulness, gamut safety). Build the interior by SUCCESSIVE "
883 "BISECTION so every step is confined between its neighbours (monotone, even "
884 "steps) : medium = bisect(no-bleach, extra-bleach), then low = bisect(no-bleach, "
885 "medium-bleach), high = bisect(medium-bleach, extra-bleach). Re-fit inner "
886 "steps after either bounding variant changes.")
887 args = ap.parse_args()
888
889 if args.report:
891 return
892
893 if args.diagnose:
895 return
896
897 if args.fit_bisect:
898 from scipy.optimize import minimize
899 lo_key, hi_key = args.fit_bisect
900 if lo_key not in SHIPPED_VARIANTS or hi_key not in SHIPPED_VARIANTS:
901 print("// --fit-bisect needs two existing SHIPPED_VARIANTS keys (e.g. no-bleach extra-bleach).")
902 return
903 y_row = REC2020_TO_XYZ_D50[1]
904 def _cah(RGB): # module chroma_hue_batch is shadowed inside main()
905 LMS = ((RGB @ REC2020_TO_XYZ_D50.T) @ XYZ_D50_to_D65_CAT16.T) @ XYZ_D65_to_LMS_2006.T
906 a = LMS.sum(axis=1, keepdims=True)
907 rg = (LMS / np.where(a == 0.0, 1.0, a)) @ LMS_to_filmlightRGB.T
908 return np.hypot(rg[:, 0] - WHITE_YRG[1], rg[:, 1] - WHITE_YRG[2]), \
909 np.arctan2(rg[:, 1] - WHITE_YRG[2], rg[:, 0] - WHITE_YRG[1])
910 S_sk, S_rf = skin_and_reflective_sets()
911 Csk, Hsk = _cah(S_sk); Crf, Hrf = _cah(S_rf)
912 mk = Csk > 0.04; S_sk, Csk, Hsk = S_sk[mk], Csk[mk], Hsk[mk]
913 mr = Crf > 0.05; S_rf, Crf, Hrf = S_rf[mr], Crf[mr], Hrf[mr]
914 nbins = 12
915 bin_idx = (np.floor(np.remainder(Hrf, 2 * np.pi) / (2 * np.pi) * nbins).astype(int)) % nbins
916
917 def ph_ab_hd(M, Mo): # per-hue apparent brightness, hue drift, output chroma
918 x = (np.log2(np.maximum(S_rf @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
919 O = np.maximum(np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape) @ Mo.T, 1e-10)
920 ab = (O @ y_row) * (1.0 + nayatani_hk_excess(O))
921 c_f, h_f = _cah(O)
922 cr = np.minimum(c_f / np.maximum(Crf, 1e-9), 1.0)
923 dh = np.rad2deg(np.remainder(h_f - Hrf + np.pi, 2 * np.pi) - np.pi)
924 AB = np.array([ab[bin_idx == b].mean() if (bin_idx == b).any() else np.nan for b in range(nbins)])
925 HD = np.array([dh[(bin_idx == b) & (cr > 0.2)].mean() if ((bin_idx == b) & (cr > 0.2)).any()
926 else np.nan for b in range(nbins)])
927 CH = np.array([c_f[bin_idx == b].mean() if (bin_idx == b).any() else np.nan for b in range(nbins)])
928 return AB, HD, CH
929
930 lo_v, hi_v = SHIPPED_VARIANTS[lo_key], SHIPPED_VARIANTS[hi_key]
931 AB_lo, HD_lo, CH_lo = ph_ab_hd(*variant_bracket(lo_v))
932 AB_hi, HD_hi, CH_hi = ph_ab_hd(*variant_bracket(hi_v))
933 ab_tgt = 0.5 * (AB_lo + AB_hi) # per-hue MIDPOINT of the two neighbours :
934 hd_tgt = 0.5 * (HD_lo + HD_hi) # apparent brightness, signed hue drift,
935 ch_tgt = 0.5 * (CH_lo + CH_hi) # AND output CHROMA (saturation) -> monotone ladder
936
937 _bnd = [np.maximum(np.array(c, float), 1e-6) for c in
938 ([1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 1], [1, 0, 1], [1, 1, 0])]
939 BND = np.array([s * GREY * 2.0 ** ev / (y_row @ s) for s in _bnd for ev in np.arange(-12.0, 8.01, 0.5)])
940 def worst_lum(M, Mo):
941 x = (np.log2(np.maximum(BND @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
942 O = np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape) @ Mo.T
943 return float((O @ y_row).min())
944 BIG = 1e5
945 # W_AB : hold each hue's apparent brightness on its neighbour-midpoint (nailed ; ~1e-4 scale).
946 # W_CH : likewise nail each hue's output CHROMA (saturation) on the midpoint. WITHOUT it,
947 # chroma is a FREE variable — only refl_dE nudges it toward the INPUT, not the midpoint — so
948 # the 2nd-stage bisections (low, high) undershoot and saturation ZIG-ZAGS (reds/magentas end up
949 # more muted at low/high than at medium/extra : the non-monotone-saturation bug). Same ~1e-4
950 # scale as AB, so the same weight nails it → monotone saturation ladder.
951 # W_HD : DAMPENING FACTOR for even hue-shift spacing between steps (deg^2 scale). At the fitted
952 # optimum the colour-fidelity term refl_dE (~0.11) out-weighs the hue-midpoint term, which is
953 # why hue steps are slightly front-loaded ; raise W_HD toward ~refl_dE to pull each step's hue
954 # drift onto its exact midpoint = MORE EVEN hue steps, at a little input-hue fidelity. Tunable:
955 # 0.30 = original (fidelity-first), 1.0 = even-spacing-first. Sweep to taste in --diagnose.
956 W_AB, W_CH, W_HD = 4000.0, 4000.0, 1.0
957
958 def objective(p):
959 M, Mo = brk(p)
960 if M.min() < 0.0:
961 return BIG
962 if max(np.linalg.cond(M), np.linalg.cond(Mo)) > 7.1:
963 return BIG
964 wl = worst_lum(M, Mo)
965 if wl < -0.02:
966 return BIG
967 skin_cr, skin_dh = measure_batch(S_sk, Csk, Hsk, M, Mo)
968 if skin_dh.min() < -3.0: # loose skin red-ward veto (racial bias)
969 return BIG
970 AB, HD, CH = ph_ab_hd(M, Mo)
971 ab_err = float(np.nanmean((AB - ab_tgt) ** 2)) # per-hue apparent-brightness midpoint
972 hd_err = float(np.nanmean((HD - hd_tgt) ** 2)) # per-hue signed hue-drift midpoint
973 ch_err = float(np.nanmean((CH - ch_tgt) ** 2)) # per-hue output-chroma midpoint (monotone saturation)
974 skin_dE = delta_e_yrg(skin_cr, skin_dh)
975 gamut_pen = 1.0e4 * max(0.0, 0.0002 - wl)
976 refl_cr, refl_dh = measure_batch(S_rf, Crf, Hrf, M, Mo)
977 refl_dE = delta_e_yrg(refl_cr, refl_dh)
978 return (W_AB * ab_err + W_CH * ch_err + W_HD * hd_err
979 + skin_dE.mean() + skin_dE.max() + refl_dE.mean() + gamut_pen)
980
981 def interp_prim(c0, a0, c1, a1, t): # chroma-plane (qualia-preserving) seed
982 oc, oa = [], []
983 for i in range(3):
984 vx = (1 - t) * c0[i] * np.cos(a0[i]) + t * c1[i] * np.cos(a1[i])
985 vy = (1 - t) * c0[i] * np.sin(a0[i]) + t * c1[i] * np.sin(a1[i])
986 oc.append(float(np.hypot(vx, vy))); oa.append(float(np.arctan2(vy, vx)))
987 return oc, oa
988 def interp_at(t):
989 i_in, i_ir = interp_prim(lo_v["inset"], lo_v["irot"], hi_v["inset"], hi_v["irot"], t)
990 i_out, i_or = interp_prim(lo_v["outset"], lo_v["orot"], hi_v["outset"], hi_v["orot"], t)
991 return i_in + i_ir + i_out + i_or
992 def flat(v):
993 return [*v["inset"], *v["irot"], *v["outset"], *v["orot"]]
994
995 seeds = [interp_at(t) for t in np.linspace(0.2, 0.8, 5)] + [flat(lo_v), flat(hi_v)]
996 best = None
997 for s in seeds:
998 r = minimize(objective, list(s), method="Nelder-Mead",
999 options={"xatol": 1e-6, "fatol": 1e-7, "maxiter": 9000, "maxfev": 9000})
1000 if r.fun < BIG and (best is None or r.fun < best.fun):
1001 best = r
1002 if best is None:
1003 print("// no feasible bisection bracket (gamut-safe) between %s and %s." % (lo_key, hi_key))
1004 return
1005 p = best.x
1006 AB, HD, CH = ph_ab_hd(*brk(p))
1007 print("// bisect(%s, %s) : per-hue RMS-to-midpoint AB %.4f chroma %.4f hue-drift %.2f° ; gamut %+.4f ; cond %.1f"
1008 % (lo_key, hi_key, np.sqrt(np.nanmean((AB - ab_tgt) ** 2)),
1009 np.sqrt(np.nanmean((CH - ch_tgt) ** 2)),
1010 np.sqrt(np.nanmean((HD - hd_tgt) ** 2)),
1011 rec2020_worst_boundary_luminance(*brk(p)), max(np.linalg.cond(brk(p)[0]), np.linalg.cond(brk(p)[1]))))
1012 print_diagnostics(p, "--fit-bisect %s %s" % (lo_key, hi_key))
1013 return
1014
1015 if args.fit_extra_bleach:
1016 from scipy.optimize import minimize
1017 def _cah(RGB): # module chroma_hue_batch is shadowed inside main()
1018 LMS = ((RGB @ REC2020_TO_XYZ_D50.T) @ XYZ_D50_to_D65_CAT16.T) @ XYZ_D65_to_LMS_2006.T
1019 a = LMS.sum(axis=1, keepdims=True)
1020 rg = (LMS / np.where(a == 0.0, 1.0, a)) @ LMS_to_filmlightRGB.T
1021 return np.hypot(rg[:, 0] - WHITE_YRG[1], rg[:, 1] - WHITE_YRG[2]), \
1022 np.arctan2(rg[:, 1] - WHITE_YRG[2], rg[:, 0] - WHITE_YRG[1])
1023 S_sk, S_rf = skin_and_reflective_sets()
1024 Csk, Hsk = _cah(S_sk)
1025 Crf, Hrf = _cah(S_rf)
1026 mk = Csk > 0.04; S_sk, Csk, Hsk = S_sk[mk], Csk[mk], Hsk[mk]
1027 mr = Crf > 0.05; S_rf, Crf, Hrf = S_rf[mr], Crf[mr], Hrf[mr]
1028 BIG = 1e5
1029
1030 # VECTORIZED boundary luminance for the gamut penalty : the module
1031 # rec2020_worst_boundary_luminance is a 246-iteration Python loop, far too slow to call
1032 # every objective eval in a sweep. Precompute the Rec2020 primary/secondary samples at
1033 # each EV (the danger zone is dark BLUE ~EV -5.5) and batch the tone-map.
1034 _yr = REC2020_TO_XYZ_D50[1]
1035 _bnd = [np.maximum(np.array(c, float), 1e-6) for c in
1036 ([1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 1], [1, 0, 1], [1, 1, 0])]
1037 BND = np.array([s * GREY * 2.0 ** ev / (_yr @ s) for s in _bnd for ev in np.arange(-12.0, 8.01, 0.5)])
1038 def worst_lum(M, Mo):
1039 x = (np.log2(np.maximum(BND @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
1040 O = np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape) @ Mo.T
1041 return float((O @ _yr).min())
1042
1043 COND_CAP = 7.0 # extra-bleach's bleach intensity dial : the objective has no
1044 # desat term, so it bleaches until conditioning binds (~6.5 is the
1045 # current extra-bleach level ; raise it for an even more extreme end).
1046
1047 # per-hue APPARENT BRIGHTNESS reference (no-bleach) for --ab-stabilize : keep the extra end
1048 # from over-brightening reds/magentas — apparent brightness should stay ~put across the
1049 # bleach axis (only chroma/hue change). Reflective, 12 hue bins.
1050 _abnb = 12
1051 _abin = (np.floor(np.remainder(Hrf, 2 * np.pi) / (2 * np.pi) * _abnb).astype(int)) % _abnb
1052 def _ph_ab(M, Mo):
1053 x = (np.log2(np.maximum(S_rf @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
1054 O = np.maximum(np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape) @ Mo.T, 1e-10)
1055 ab = (O @ _yr) * (1.0 + nayatani_hk_excess(O))
1056 return np.array([ab[_abin == b].mean() if (_abin == b).any() else np.nan for b in range(_abnb)])
1057 # Uniform apparent-brightness target = the DATA-DERIVED AVERAGE of the H-K-neutral
1058 # gray-equivalent FLOOR (scene_ab_target(...,0.0) ~0.336) and the scene-preserving CEILING
1059 # (scene_ab_target(...,1.0) ~0.379) = ~0.357 (equivalently scene_ab_target(...,0.5)). Below
1060 # min-bleach's full-retention value : the extra end bleaches chroma/H-K OUT, so a lower
1061 # target is faithful AND permits more desaturation (verified: lower desaturates better).
1062 # Replaces the hand-tuned 0.360 it lands on.
1063 ab_no_ref = [0.5 * (scene_ab_target(S_rf, 0.0) + scene_ab_target(S_rf, 1.0))] * _abnb
1064
1065 # per-hue OUTPUT-CHROMA CEILING (no-bleach) : extra-bleach MUST be LESS saturated than
1066 # no-bleach at EVERY hue — more bleach => less chroma. The colour-fidelity term (global_dE)
1067 # rewards chroma retention toward the INPUT, which lets the outset over-recover MAGENTA (R+B)
1068 # ABOVE the no-bleach level : a saturation-ORDER inversion (extra magenta > no magenta) that
1069 # then propagates through the bisections. One-sided penalty on any hue whose extra output
1070 # chroma exceeds no-bleach's. Fixed reference (read from SHIPPED once).
1071 def _ph_chroma(M, Mo):
1072 x = (np.log2(np.maximum(S_rf @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
1073 O = np.maximum(np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape) @ Mo.T, 1e-10)
1074 c_f, _h = _cah(O)
1075 return np.array([c_f[_abin == b].mean() if (_abin == b).any() else np.nan for b in range(_abnb)])
1076 ch_no_ref = _ph_chroma(*variant_bracket(SHIPPED_VARIANTS["no-bleach"]))
1077
1078 def objective(p):
1079 M, Mo = brk(p)
1080 if M.min() < 0.0:
1081 return BIG
1082 if max(np.linalg.cond(M), np.linalg.cond(Mo)) > COND_CAP:
1083 return BIG
1084 wl = worst_lum(M, Mo) # vectorized boundary luminance (fast)
1085 if wl < -0.02: # real black — reject outright
1086 return BIG
1087 skin_cr, skin_dh = measure_batch(S_sk, Csk, Hsk, M, Mo)
1088 refl_cr, refl_dh = measure_batch(S_rf, Crf, Hrf, M, Mo)
1089 if skin_dh.min() < -3.0: # loose skin red-ward safety veto
1090 return BIG
1091 # EXTRA-BLEACH objective : minimize REFLECTIVE hue shift (in radians, to match the
1092 # delta-E scale) + SKIN delta-E, directly (not caps). No reflective desat term, so
1093 # the bracket bleaches as hard as conditioning allows to flatten hue — the extreme,
1094 # hue-faithful end. Skin delta-E keeps skin from washing out with it.
1095 # GAMUT as a SOFT penalty (not a hard wall) : minimizing reflective hue wants strong
1096 # blue bleach, which pushes dark BLUE (~EV -5.5) negative -> black. A hard barrier
1097 # cannot be gradient-navigated at that edge (the 512-start sweep found nothing), so
1098 # trade hue against blue-gamut smoothly, driving the worst luminance up to +0.0005.
1099 # NO H-K term here : minimizing reflective H-K drift on the extreme end pushes the
1100 # optimizer to over-brighten saturated red/magenta (the "self-luminous"/neon lipstick
1101 # artifact — skin stays stable but red-fuchsia reads too luminous). The extra end is
1102 # deliberately hue-faithful + skin-faithful ONLY ; H-K fidelity is left to the tamer
1103 # variants, where the shallower bracket does not amplify saturated-red apparent brightness.
1104 refl_hue = np.deg2rad(np.abs(refl_dh[refl_cr > 0.2]))
1105 skin_dE = delta_e_yrg(skin_cr, skin_dh)
1106 global_dE = delta_e_yrg(refl_cr, refl_dh)
1107 gamut_pen = 1.0e4 * max(0.0, 0.0002 - wl) # keep dark blue clearly positive (achievable)
1108 # SOFT bleach preference : a gentle reward (NOT a hard desat target) that tips the
1109 # hue-vs-bleach trade-off toward MORE reflective desaturation when fidelity is roughly
1110 # tied — extreme creative bleach is left to grading, this only leans the character.
1111 # Bounded by gamut_pen (1e4), skin_dE and the conditioning cap, so it cannot push the
1112 # bracket gamut-unsafe ; --bleach-nudge 0 recovers the pure hue/skin-faithful fit.
1113 bleach_reward = args.bleach_nudge * float(np.mean(1.0 - refl_cr))
1114 # SOFT per-hue apparent-brightness stabilization toward no-bleach (--ab-stabilize) :
1115 # keeps reds/magentas from over-brightening at the extreme end (bleach = desaturate at
1116 # ~constant apparent brightness). 0 = off.
1117 ab_stab = 0.0
1118 if args.ab_stabilize > 0.0 or args.ab_level > 0.0:
1119 ab = _ph_ab(M, Mo)
1120 ab_mean = np.nanmean(ab)
1121 nvalid = int(np.sum(~np.isnan(ab)))
1122 # DECOUPLED apparent-brightness stabilisation (fixes the target sensitivity) :
1123 # - UNIFORMITY (--ab-stabilize) : spread of per-hue AB around its own mean. TARGET-FREE,
1124 # strong — the real goal (no hue over/under-bright relative to the others).
1125 # - LEVEL (--ab-level) : gentle pull of the MEAN toward the target. Target-sensitive,
1126 # so kept weak. The old coupled sum((ab-target)^2) == var + nvalid*(mean-target)^2 put
1127 # ~45%% of W on the level, so a 0.003 target shift moved the objective ~0.03 and flipped
1128 # the winning seed into a blue-distorting basin. ab_no_ref is uniform -> [0] is the level.
1129 ab_stab = (args.ab_stabilize * float(np.nansum((ab - ab_mean) ** 2))
1130 + args.ab_level * nvalid * float((ab_mean - ab_no_ref[0]) ** 2))
1131 # CHROMA CEILING vs no-bleach : one-sided penalty forcing extra output chroma STRICTLY
1132 # BELOW no-bleach at every hue (fixes the magenta saturation-order inversion). The 0.98
1133 # margin makes it clearly LOWER (not merely equal) — "more bleach => less chroma". Strong
1134 # so it firmly binds ; only bites the hue(s) near/above the ceiling (the rest sit well below).
1135 chroma_ceiling = 5.0e3 * float(np.nansum(np.maximum(0.0, _ph_chroma(M, Mo) - 0.98 * ch_no_ref) ** 2))
1136 return (refl_hue.mean() + refl_hue.max()
1137 + skin_dE.mean() + global_dE.mean() + skin_dE.max() + skin_dE.mean()
1138 + gamut_pen - bleach_reward + ab_stab + chroma_ceiling)
1139
1140 bounds = [(0.40, 0.98)] * 3 + [(-0.2, 0.2)] * 3 + [(0.30, 0.98)] * 3 + [(-0.2, 0.2)] * 3
1141 # TARGETED seed set (was a blind 4^3 x 4^3 = 4096-start grid). The strictly-gamut-safe
1142 # extreme bracket is a SINGLE known basin : it rails the inset floor (~0.40 red/blue, ~0.78
1143 # green) at cond 7.0, and every grid start converged there. So seed from the shipped extra
1144 # + a handful of structured points with the right per-channel shape (blue/red deep, green
1145 # shallow) and refine locally — same optimum, ~500x fewer evals. Widen again only if the
1146 # objective changes basin (watch the printed best).
1147 ex = SHIPPED_VARIANTS["extra-bleach"]
1148 seeds = [[*ex["inset"], *ex["irot"], *ex["outset"], *ex["orot"]]]
1149 for ins in ([0.40, 0.78, 0.40], [0.45, 0.75, 0.43], [0.55, 0.80, 0.45]):
1150 for out in ([0.34, 0.83, 0.37], [0.50, 0.80, 0.45]):
1151 seeds.append([*ins, 0.0, 0.0, 0.0, *out, 0.0, 0.0, 0.0])
1152 best = None
1153 for guess in seeds:
1154 r = minimize(objective, guess, method="Nelder-Mead", bounds=bounds,
1155 options={"xatol": 1e-6, "fatol": 1e-6, "maxiter": 8000, "maxfev": 8000})
1156 if r.fun < BIG and (best is None or r.fun < best.fun):
1157 best = r
1158 Mb, Mob = brk(r.x)
1159 print("// extra best obj %.4f inset %.2f/%.2f/%.2f gamut %+.4f cond %.1f"
1160 % (r.fun, r.x[0], r.x[1], r.x[2],
1162 max(np.linalg.cond(Mb), np.linalg.cond(Mob))))
1163 if best is None:
1164 print("// no feasible extra-bleach bracket under the constraints.")
1165 return
1166 print_diagnostics(best.x, "--fit-extra-bleach")
1167 return
1168
1169 if args.interpolate_fits:
1170 n_fits = 3 # intermediate steps between bounds
1171 bounds = [SHIPPED_VARIANTS["no-bleach"], SHIPPED_VARIANTS["extra-bleach"]]
1172
1173 def compute_vector(coeff, angle):
1174 return coeff * np.cos(angle), coeff * np.sin(angle)
1175
1176 def vector_to_coeff_angle(vec):
1177 coeff = np.hypot(vec[0], vec[1])
1178 angle = np.arctan2(vec[1], vec[0])
1179 return coeff, angle
1180
1181 def build_variant_from_vectors(name, fit, in_vectors, out_vectors):
1182 inset = []
1183 irot = []
1184 outset = []
1185 orot = []
1186 for vec in in_vectors:
1187 coeff, angle = vector_to_coeff_angle(vec)
1188 inset.append(float(coeff))
1189 irot.append(float(angle))
1190 for vec in out_vectors:
1191 coeff, angle = vector_to_coeff_angle(vec)
1192 outset.append(float(coeff))
1193 orot.append(float(angle))
1194 variant = dict(fit=fit, inset=inset, irot=irot, outset=outset, orot=orot)
1195 SHIPPED_VARIANTS[name] = variant
1196 return variant
1197
1198 def interpolate_vectors(vec0, vec1, t):
1199 return ((1.0 - t) * vec0[0] + t * vec1[0],
1200 (1.0 - t) * vec0[1] + t * vec1[1])
1201
1202 primaries_in = []
1203 primaries_out = []
1204 for bound in bounds:
1205 for i in range(3):
1206 primaries_in.append(compute_vector(bound["inset"][i], bound["irot"][i]))
1207 primaries_out.append(compute_vector(bound["outset"][i], bound["orot"][i]))
1208
1209 print("// interpolated variants between no-bleach and extra-bleach")
1210 for k in range(1, n_fits + 1):
1211 t = float(k) / (n_fits + 1)
1212 in_vectors = [interpolate_vectors(primaries_in[i], primaries_in[i + 3], t)
1213 for i in range(3)]
1214 out_vectors = [interpolate_vectors(primaries_out[i], primaries_out[i + 3], t)
1215 for i in range(3)]
1216 name = "interp-%d" % k
1217 fit = "--interpolate-fits %d/%d" % (k, n_fits)
1218 variant = build_variant_from_vectors(name, fit, in_vectors, out_vectors)
1219 print("// %s t=%.3f" % (name, t))
1220 print_variant_entry(name, fit, variant["inset"], variant["irot"], variant["outset"], variant["orot"])
1221 print_c_case("DT_FILMIC_COLORSCIENCE_V%d" % (8 + k - 1), fit,
1222 variant["inset"], variant["irot"], variant["outset"], variant["orot"])
1223
1225 return
1226
1227 if args.fit_low_bleach:
1228 p = fit_midpoint("no-bleach", "high-bleach", 0.20, 0.75, (0.35, 0.45, 0.55))
1229 if p is not None:
1230 print_midpoint_constants(p, "--fit-low-bleach", "no-bleach and high-bleach")
1231 else:
1232 print("// no feasible low-bleach midpoint under the constraints.")
1233 return
1234
1235 if args.fit_medium_bleach:
1236 from scipy.optimize import minimize
1237 if "no-bleach" not in SHIPPED_VARIANTS or "extra-bleach" not in SHIPPED_VARIANTS:
1238 print("// medium needs the two ENDS in SHIPPED_VARIANTS first — refit no-bleach and extra-bleach.")
1239 return
1240 y_row = REC2020_TO_XYZ_D50[1]
1241
1242 def _cah(RGB): # module chroma_hue_batch is shadowed inside main()
1243 LMS = ((RGB @ REC2020_TO_XYZ_D50.T) @ XYZ_D50_to_D65_CAT16.T) @ XYZ_D65_to_LMS_2006.T
1244 a = LMS.sum(axis=1, keepdims=True)
1245 rg = (LMS / np.where(a == 0.0, 1.0, a)) @ LMS_to_filmlightRGB.T
1246 return np.hypot(rg[:, 0] - WHITE_YRG[1], rg[:, 1] - WHITE_YRG[2]), \
1247 np.arctan2(rg[:, 1] - WHITE_YRG[2], rg[:, 0] - WHITE_YRG[1])
1248 S_sk, S_rf = skin_and_reflective_sets()
1249 Csk, Hsk = _cah(S_sk)
1250 mk = Csk > 0.04; S_sk, Csk, Hsk = S_sk[mk], Csk[mk], Hsk[mk]
1251 Crf, Hrf = _cah(S_rf)
1252 mr = Crf > 0.05; S_rf, Crf, Hrf = S_rf[mr], Crf[mr], Hrf[mr]
1253 no_v, ex_v = SHIPPED_VARIANTS["no-bleach"], SHIPPED_VARIANTS["extra-bleach"]
1254
1255 def flat(v):
1256 return [*v["inset"], *v["irot"], *v["outset"], *v["orot"]]
1257
1258 def interp_prim(c0, a0, c1, a1, t): # chroma-plane interpolation (qualia)
1259 oc, oa = [], []
1260 for i in range(3):
1261 vx = (1 - t) * c0[i] * np.cos(a0[i]) + t * c1[i] * np.cos(a1[i])
1262 vy = (1 - t) * c0[i] * np.sin(a0[i]) + t * c1[i] * np.sin(a1[i])
1263 oc.append(float(np.hypot(vx, vy))); oa.append(float(np.arctan2(vy, vx)))
1264 return oc, oa
1265 def interp_at(t):
1266 i_in, i_ir = interp_prim(no_v["inset"], no_v["irot"], ex_v["inset"], ex_v["irot"], t)
1267 i_out, i_or = interp_prim(no_v["outset"], no_v["orot"], ex_v["outset"], ex_v["orot"], t)
1268 return i_in + i_ir + i_out + i_or
1269
1270 # per-hue APPARENT BRIGHTNESS = output luminance x (1 + H-K excess). "Darkening reds/
1271 # magentas" is a drop in this ; keeping every hue's value inside the ends' [min,max]
1272 # bracket forbids any hue leaving the ramp (the exact bug). Reflective, 12 hue bins.
1273 nbins = 12
1274 bin_idx = (np.floor(np.remainder(Hrf, 2 * np.pi) / (2 * np.pi) * nbins).astype(int)) % nbins
1275
1276 def per_hue_ab(M, Mo):
1277 x = (np.log2(np.maximum(S_rf @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
1278 O = np.maximum(np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape) @ Mo.T, 1e-10)
1279 ab = (O @ y_row) * (1.0 + nayatani_hk_excess(O))
1280 return np.array([ab[bin_idx == b].mean() if (bin_idx == b).any() else np.nan for b in range(nbins)])
1281
1282 def desat_of(M, Mo):
1283 rcr, _ = measure_batch(S_rf, Crf, Hrf, M, Mo)
1284 return float((1.0 - rcr).mean())
1285
1286 Mno, Mono_ = variant_bracket(no_v)
1287 Mex, Moex = variant_bracket(ex_v)
1288 ab_no, ab_ex = per_hue_ab(Mno, Mono_), per_hue_ab(Mex, Moex)
1289 ab_lo, ab_hi = np.minimum(ab_no, ab_ex), np.maximum(ab_no, ab_ex)
1290 frac = float(args.desat_frac) # 0.25 low, 0.5 medium, 0.75 high
1291 d_no, d_ex = desat_of(Mno, Mono_), desat_of(Mex, Moex)
1292 target = d_no + frac * (d_ex - d_no) # reflective-desat position on the ramp
1293
1294 # smooth-character anchor : the interpolation point at the desat midpoint (itself may be
1295 # gamut-unsafe, but we only PULL toward it while enforcing gamut safety hard below).
1296 ts = np.linspace(0.0, 1.0, 61)
1297 dpath = np.array([desat_of(*brk(interp_at(t))) for t in ts])
1298 anchor = interp_at(float(ts[int(np.argmin(np.abs(dpath - target)))]))
1299
1300 # vectorized boundary luminance for the SOFT gamut penalty (rec2020_worst_boundary_
1301 # luminance is a slow Python loop) — Rec2020 primaries/secondaries over EV -12..+8.
1302 _bnd = [np.maximum(np.array(c, float), 1e-6) for c in
1303 ([1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 1, 1], [1, 0, 1], [1, 1, 0])]
1304 BND = np.array([s * GREY * 2.0 ** ev / (y_row @ s) for s in _bnd for ev in np.arange(-12.0, 8.01, 0.5)])
1305 def worst_lum(M, Mo):
1306 x = (np.log2(np.maximum(BND @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
1307 O = np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape) @ Mo.T
1308 return float((O @ y_row).min())
1309 BIG = 1e5
1310
1311 def objective(p):
1312 M, Mo = brk(p)
1313 if M.min() < 0.0:
1314 return BIG
1315 if max(np.linalg.cond(M), np.linalg.cond(Mo)) > 7.1: # include the ends (extra ~7.0)
1316 return BIG
1317 wl = worst_lum(M, Mo)
1318 if wl < -0.02: # real black — reject outright
1319 return BIG
1320 skin_cr, skin_dh = measure_batch(S_sk, Csk, Hsk, M, Mo)
1321 if skin_dh.min() < -3.0:
1322 return BIG
1323 pos = (desat_of(M, Mo) - target) ** 2 # position at bleach midpoint
1324 ab = per_hue_ab(M, Mo)
1325 mono = float(np.nansum(np.maximum(0.0, ab_lo - ab) + np.maximum(0.0, ab - ab_hi))) # per-hue ramp
1326 skin_dE = delta_e_yrg(skin_cr, skin_dh)
1327 qualia = float(np.sum((np.array(p) - np.array(anchor)) ** 2))
1328 gamut_pen = 1.0e4 * max(0.0, 0.0002 - wl) # SOFT : keep dark blue clearly positive
1329 return 100.0 * pos + 20.0 * mono + skin_dE.mean() + skin_dE.max() + 0.30 * qualia + gamut_pen
1330
1331 # bruteforce-ish seed set : the two ends + several interpolation-path points (the soft
1332 # gamut penalty guides infeasible seeds back, so no need to pre-filter for gamut).
1333 seeds = [flat(no_v), flat(ex_v)] + [interp_at(tt) for tt in np.linspace(0.2, 0.9, 6)]
1334 best = None
1335 for s in seeds:
1336 r = minimize(objective, list(s), method="Nelder-Mead",
1337 options={"xatol": 1e-6, "fatol": 1e-7, "maxiter": 9000, "maxfev": 9000})
1338 if r.fun < BIG and (best is None or r.fun < best.fun):
1339 best = r
1340 if best is None:
1341 print("// no feasible medium-bleach bracket (gamut-safe, inside the per-hue ramp).")
1342 return
1343 p = best.x
1344 M, Mo = brk(p)
1345 ab = per_hue_ab(M, Mo)
1346 n_out = int(np.nansum((ab < ab_lo - 1e-3) | (ab > ab_hi + 1e-3)))
1347 print("// interior fit frac %.2f : reflective desat %.1f%% (target %.1f%%, ends %.1f..%.1f) ; "
1348 "hues out-of-ramp %d/%d ; gamut %+.4f ; cond %.1f"
1349 % (frac, 100 * desat_of(M, Mo), 100 * target, 100 * d_no, 100 * d_ex, n_out, nbins,
1350 rec2020_worst_boundary_luminance(M, Mo), max(np.linalg.cond(M), np.linalg.cond(Mo))))
1351 print_diagnostics(p, "--fit-medium-bleach --desat-frac %.2f" % frac)
1352 return
1353
1354 if args.max_desat is not None:
1355 from scipy.optimize import minimize
1356 budget = float(args.max_desat)
1357 # UNIFIED colour-constancy set (single source of truth) : the SAME skin + reflective
1358 # samples --report and every other fit mode use, so a "desaturation %" is comparable
1359 # across modes. This mode used to conflate skin into the reflective bucket
1360 # (priority_samples returns skin+diffuse combined) and build a divergent ±1-std / 4-EV
1361 # skin set, which is exactly why its "reflective desat" disagreed with the report's.
1362 S_skin, S_refl = skin_and_reflective_sets()
1363
1364 def yrg_rg_batch(RGB): # RGB (N,3) -> (r, g) chromaticity
1365 LMS = ((RGB @ REC2020_TO_XYZ_D50.T) @ XYZ_D50_to_D65_CAT16.T) @ XYZ_D65_to_LMS_2006.T
1366 a = LMS.sum(axis=1, keepdims=True)
1367 rg = (LMS / np.where(a == 0.0, 1.0, a)) @ LMS_to_filmlightRGB.T
1368 return rg[:, 0], rg[:, 1]
1369
1370 def chroma_hue_batch(RGB):
1371 r, g = yrg_rg_batch(RGB)
1372 dr, dg = r - WHITE_YRG[1], g - WHITE_YRG[2]
1373 return np.hypot(dr, dg), np.arctan2(dg, dr)
1374
1375 C_refl, H_refl = chroma_hue_batch(S_refl) # input chroma/hue, invariant
1376 C_skin, H_skin = chroma_hue_batch(S_skin)
1377 # Drop near-neutral inputs : a gray has no saturation to preserve and no
1378 # meaningful hue, so its chroma RATIO (c_out/c_in) is numerical noise that
1379 # otherwise inflates the desaturation average and the worst-color cap — which
1380 # is exactly what the green-heavy inset was exploiting.
1381 rkeep = C_refl > 0.05
1382 S_refl, C_refl, H_refl = S_refl[rkeep], C_refl[rkeep], H_refl[rkeep]
1383 skeep = C_skin > 0.04 # keep pale skin, drop the near-grays
1384 S_skin, C_skin, H_skin = S_skin[skeep], C_skin[skeep], H_skin[skeep]
1385 hk_in_refl = nayatani_hk_excess(S_refl) # input H-K excess (invariant)
1386
1387 def measure(S, C_in, H_in, M, Mo): # -> chroma_ratio, drift_deg (N,)
1388 x = (np.log2(np.maximum(S @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
1389 y = np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape)
1390 O = np.maximum(y @ Mo.T, 1e-10)
1391 c_f, h_f = chroma_hue_batch(O)
1392 cr = np.minimum(c_f / np.maximum(C_in, 1e-9), 1.0)
1393 dh = np.rad2deg(np.remainder(h_f - H_in + np.pi, 2 * np.pi) - np.pi)
1394 return cr, dh
1395
1396 BIG = 1e5
1397
1398 def evaluate(p):
1399 """(skin_cr, skin_dh, refl_cr, refl_dh, cond) or None if degenerate."""
1400 M, Mo = brk(p)
1401
1402 # reject negative input matrices
1403 if M.min() < 0.0:
1404 return None
1405
1406 # reject near-degenerate brackets
1407 cond = max(np.linalg.cond(M), np.linalg.cond(Mo))
1408 if cond > 6.5:
1409 return None
1410
1411 # reject brackets that escape Rec2020 gamut
1412 if gamut_violation(M, Mo) > 0.0:
1413 return None
1414
1415 skin_cr, skin_dh = measure(S_skin, C_skin, H_skin, M, Mo)
1416 refl_cr, refl_dh = measure(S_refl, C_refl, H_refl, M, Mo)
1417 return skin_cr, skin_dh, refl_cr, refl_dh, cond
1418
1419 def objective(p):
1420 ev = evaluate(p)
1421 if ev is None:
1422 return BIG
1423 skin_cr, skin_dh, refl_cr, refl_dh, cond = ev
1424 # desaturation = the chroma the bracket costs (mostly the bright-color /
1425 # highlight bleach — the AgX wash-out look — which IS the cost the budget
1426 # trades ; individual highlights bleaching hard is fine, so no per-color cap).
1427 desats = np.concatenate([1.0 - skin_cr, 1.0 - refl_cr])
1428 mean_desat = float(desats.mean())
1429 gated = np.abs(refl_dh[refl_cr > 0.2]) # hue where chroma survives
1430 worst_hue = float(gated.max())
1431 # hue fidelity : mean AND worst single color ('best hue match' = no color
1432 # badly off, not a good average hiding an outlier).
1433 hue_err = np.mean(gated) + worst_hue + np.mean(np.abs(skin_dh)) + np.max(np.abs(skin_dh))
1434 # delta-E SAFETY JACKET (the shared delta_e_yrg, same metric as --min-bleach).
1435 # It folds skin chroma-loss AND skin
1436 # hue-drift into one perceptual shift. A THRESHOLD CAP, not a co-objective : it
1437 # stays slack (zero) until the worst skin colour shifts past SKIN_DE_CAP, then
1438 # bites hard — so the aggressive reflective bleaching this mode trades for hue
1439 # stability keeps its character, but skin cannot be quietly whitened past the
1440 # cap (a racial-bias failure). NOTE the coupling : skin is red/yellow, so
1441 # sparing it forces the red/green inset down (the per-channel inset keeps blue
1442 # high) — a tighter cap therefore softens the reflective bleaching too. Only
1443 # SKIN is jacketed : reflective colours are MEANT to bleach, so their delta-E
1444 # saturates near 1 by design and cannot tell an intended wash-out from a
1445 # pathological one. SKIN_DE_CAP ~= worst tolerated skin chroma loss (0.12 ~ 12%).
1446 SKIN_DE_CAP = 0.12
1447 skin_dE = delta_e_yrg(skin_cr, skin_dh)
1448 skin_jacket = 200.0 * max(0.0, float(skin_dE.max()) - SKIN_DE_CAP)
1449
1450 # OPTIONAL H-K fidelity term, OFF by default (--hk-weight 0). On the heavy-bleach
1451 # top end minimizing reflective H-K drift over-brightens saturated red/magenta —
1452 # the "self-luminous"/neon lipstick artifact — so leave it disabled here ; the
1453 # tamer variants carry H-K fidelity, where the shallower bracket does not amplify it.
1454 hk_term = 0.0
1455 if args.hk_weight > 0.0:
1456 xr = (np.log2(np.maximum(S_refl @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
1457 Or = np.maximum(np.interp(np.clip(xr, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(xr.shape) @ Mo.T, 1e-10)
1458 hk_term = args.hk_weight * float(np.abs(nayatani_hk_excess(Or) - hk_in_refl).mean())
1459 return (hue_err
1460 + skin_jacket # delta-E safety jacket on skin
1461 + hk_term # H-K fidelity (0 unless --hk-weight > 0)
1462 + 0.10 * mean_desat # prefer chroma when hue is tied (best-chroma-match)
1463 + 1e4 * max(0.0, mean_desat - budget) # HARD average-desaturation budget
1464 + max(0.0, worst_hue - 16.0) # HARD worst-case hue cap (deg)
1465 + 0.10 * cond) # favour well-conditionned matrices
1466
1467 p0 = [0.7, 0.7, 0.7, # inset anchor
1468 0.0, 0.0, 0.0, # inset rotation
1469 0.5, 0.5, 0.5, # outset anchor
1470 0.0, 0.0, 0.0] # outset rotation
1471 best = None
1472
1473 # Bruteforce parametric sweep on the objective function coeffs
1474 # At the end we only want the min hue drift at requested desaturation
1475 # that doesn't fuck up Rec2020 gamut.
1476 insets = np.linspace(0.5, 0.9, 4)
1477 outsets = np.linspace(0.5, 0.9, 4)
1478 for di1 in insets:
1479 p0[0] = di1
1480 for di2 in insets:
1481 p0[1] = di2
1482 for di3 in insets:
1483 p0[2] = di3
1484 for do1 in outsets:
1485 p0[6] = do1
1486 for do2 in outsets:
1487 p0[7] = do2
1488 for do3 in outsets:
1489 p0[8] = do3
1490
1491 r = minimize(objective, p0, method="Nelder-Mead",
1492 bounds=[
1493 (0.50, 0.9), (0.50, 0.9), (0.50, 0.9), # inset
1494 (-0.1, 0.1), (-0.1, 0.1), (-0.1, 0.1), # rotation anchor: limit to +/- 5.7°
1495 (0.4, 0.9), (0.4, 0.9), (0.4, 0.9), # outset
1496 (-0.1, 0.1), (-0.1, 0.1), (-0.1, 0.1), # rotation anchor: limit to +/- 5.7°
1497 ],
1498 options={"xatol": 1e-5, "fatol": 1e-4, "maxiter": 8000, "maxfev": 8000})
1499 if r.fun < BIG and (best is None or r.fun < best.fun):
1500 best = r
1501 p = best.x
1502 skin_cr, skin_dh, refl_cr, refl_dh, cond = evaluate(p)
1503 desats = np.concatenate([1.0 - skin_cr, 1.0 - refl_cr])
1504 mean_desat = float(desats.mean())
1505 gated = np.abs(refl_dh[refl_cr > 0.2])
1506 M, Mo = brk(p)
1507 inset = p[0]
1508 print("// achieved avg desaturation %.1f%% (budget %.1f%%) ; worst single color %.1f%% (bright-color bleach)"
1509 % (100 * mean_desat, 100 * budget, 100 * desats.max()))
1510 print("// priority set : skin |mean| %.1f deg [%+.1f..%+.1f] ; reflective mean %.1f max %.1f ; cond %.1f"
1511 % (np.mean(np.abs(skin_dh)), skin_dh.min(), skin_dh.max(),
1512 gated.mean(), gated.max(), cond))
1513
1514 if best is None:
1515 print("// no feasible bracket under the constraints (skin red veto / positivity / conditioning).")
1516 print("// the desaturation budget is not the binding constraint — check --max-desat is >= 0.")
1517 return
1518
1519 p = best.x
1520 message = "--max-desat %f" % budget
1521 print_diagnostics(p, message)
1522 return
1523
1524 if args.min_bleach:
1525 from scipy.optimize import minimize
1526 hk_weight = float(args.hk_weight) # optional Helmholtz-Kohlrausch term (0 = off)
1527
1528 # UNIFIED colour-constancy set (single source of truth), same as --report / --max-desat /
1529 # --fit-extra-bleach. Previously conflated skin into the reflective bucket + divergent skin.
1530 S_skin, S_refl = skin_and_reflective_sets()
1531
1532 def chroma_hue_batch(RGB):
1533 LMS = ((RGB @ REC2020_TO_XYZ_D50.T) @ XYZ_D50_to_D65_CAT16.T) @ XYZ_D65_to_LMS_2006.T
1534 a = LMS.sum(axis=1, keepdims=True)
1535 rg = (LMS / np.where(a == 0.0, 1.0, a)) @ LMS_to_filmlightRGB.T
1536 dr, dg = rg[:, 0] - WHITE_YRG[1], rg[:, 1] - WHITE_YRG[2]
1537 return np.hypot(dr, dg), np.arctan2(dg, dr)
1538
1539 C_refl, H_refl = chroma_hue_batch(S_refl)
1540 C_skin, H_skin = chroma_hue_batch(S_skin)
1541 rkeep = C_refl > 0.05
1542 S_refl, C_refl, H_refl = S_refl[rkeep], C_refl[rkeep], H_refl[rkeep]
1543 skeep = C_skin > 0.04
1544 S_skin, C_skin, H_skin = S_skin[skeep], C_skin[skeep], H_skin[skeep]
1545 S_all = np.vstack([S_skin, S_refl])
1546 C_all = np.concatenate([C_skin, C_refl])
1547 H_all = np.concatenate([H_skin, H_refl])
1548 hk_in_refl = nayatani_hk_excess(S_refl) # input H-K excess (invariant) for the optional correction
1549
1550 # PER-HUE APPARENT-BRIGHTNESS PULL (--ab-pull, 0 = off) : the min-delta-E objective has
1551 # NO luminance term, so high-H-K hues (red, magenta) DARKEN off the ladder — no-bleach
1552 # kinks reds dark and flips the green<->magenta apparent-brightness balance vs low-bleach,
1553 # a jarring no->low step. Pull each hue's apparent brightness (output luminance x
1554 # (1 + H-K excess)) toward the MIDPOINT of what the CURRENT shipped no-bleach and low-bleach
1555 # produce ("true red sits between the two"). FIXED reference (read from SHIPPED once,
1556 # before the re-fit), so it is not circular.
1557 ab_pull_w = float(args.ab_pull)
1558 ab_y_row = REC2020_TO_XYZ_D50[1]
1559 ab_nbins = 12
1560 ab_bin = (np.floor(np.remainder(H_refl, 2 * np.pi) / (2 * np.pi) * ab_nbins).astype(int)) % ab_nbins
1561 def per_hue_ab(M, Mo):
1562 x = (np.log2(np.maximum(S_refl @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
1563 O = np.maximum(np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape) @ Mo.T, 1e-10)
1564 ab = (O @ ab_y_row) * (1.0 + nayatani_hk_excess(O))
1565 return np.array([ab[ab_bin == b].mean() if (ab_bin == b).any() else np.nan for b in range(ab_nbins)])
1566
1567 # PRINCIPLED uniform apparent-brightness target : the scene's own apparent brightness
1568 # carried through the achromatic tone curve (scene_ab_target ~0.379) — one DERIVED value,
1569 # replacing the hand-tuned 0.380 it matches (and the earlier no/low midpoint). min-bleach
1570 # keeps full chroma/H-K, so it sits at the H-K-preserving top of the target band.
1571 ab_target = [scene_ab_target(S_refl)] * ab_nbins
1572
1573 def measure(S, C_in, H_in, M, Mo): # same signature as --max-desat's
1574 x = (np.log2(np.maximum(S @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
1575 y = np.interp(np.clip(x, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(x.shape)
1576 O = np.maximum(y @ Mo.T, 1e-10)
1577 c_f, h_f = chroma_hue_batch(O)
1578 cr = np.minimum(c_f / np.maximum(C_in, 1e-9), 1.0)
1579 dh = np.remainder(h_f - H_in + np.pi, 2 * np.pi) - np.pi
1580 return cr, np.rad2deg(dh)
1581
1582 BIG = 1e5
1583
1584 def objective(p):
1585 M, Mo = brk(p)
1586 if M.min() < 0.0:
1587 return BIG
1588 cond = max(np.linalg.cond(M), np.linalg.cond(Mo))
1589 if cond > 6: # conditioning : stability
1590 return BIG
1591 gv = gamut_violation(M, Mo)
1592 if gv > 0.0: # Rec2020 gamut safety (hard) — no black
1593 return BIG + gv * 100.0
1594 skin_cr, skin_dh = measure(S_skin, C_skin, H_skin, M, Mo)
1595 refl_cr, refl_dh = measure(S_refl, C_refl, H_refl, M, Mo)
1596 if skin_dh.min() < -3.0: # loose skin red-ward safety veto (racial bias)
1597 return BIG
1598 # NO-BLEACH objective (delta-E redesign) : minimize the REFLECTIVE colours'
1599 # combined move delta_e_yrg AND their desaturation, DIRECTLY as the objective —
1600 # not as caps. This is the "keep colours vivid and faithful" end of the look axis.
1601 # delta_e_yrg already couples chroma+hue with chroma-weighted hue damping (a hue
1602 # error on a near-grey barely counts) ; the extra desat term doubles the chroma-
1603 # preservation pressure that defines "no bleach". Skin is diffuse and rides along
1604 # (recovered by the outset), guarded only by the loose red veto above. mean AND
1605 # max so no single colour is left badly off. Optional H-K fidelity term (--hk-weight).
1606 refl_dE = delta_e_yrg(refl_cr, refl_dh)
1607 refl_desat = 1.0 - refl_cr
1608 hk_term = 0.0
1609 if hk_weight > 0.0:
1610 xr = (np.log2(np.maximum(S_refl @ M.T, 1e-10) / GREY) - BLACK_EV) / (WHITE_EV - BLACK_EV)
1611 Or = np.maximum(np.interp(np.clip(xr, 0.0, 1.0).ravel(), LUT_X, LUT_Y).reshape(xr.shape) @ Mo.T, 1e-10)
1612 hk_term = hk_weight * float(np.abs(nayatani_hk_excess(Or) - hk_in_refl).mean())
1613 ab_pull = 0.0
1614 if ab_pull_w > 0.0:
1615 ab_pull = ab_pull_w * float(np.nansum((per_hue_ab(M, Mo) - ab_target) ** 2))
1616 return (refl_dE.mean() + refl_dE.max()
1617 + refl_desat.mean() + refl_desat.max()
1618 + hk_term + ab_pull)
1619
1620 best = None
1621 guess = [
1622 0.2, 0.2, 0.2,
1623 -0.0045667, -0.0085405, +0.0070037, # from extra bleach rotations
1624 0.5, 0.5, 0.5,
1625 -0.0007132, -0.0099789, +0.0057890, # from extra bleach rotations
1626 ]
1627
1628 outsets = [0.35, 0.65]
1629
1630 # Bruteforce parameters sweep for initial parameters because
1631 # the solution space is full of
1632 # local minima and we can't know in which one we fall until
1633 # we do a full scan.
1634 for di1 in np.linspace(0.35, 0.6, 4):
1635 guess[0] = di1
1636 for di2 in np.linspace(0.35, 0.6, 4):
1637 guess[1] = di2
1638 for di3 in np.linspace(0.35, 0.6, 4):
1639 guess[2] = di3
1640 for do1 in outsets:
1641 guess[6] = do1
1642 for do2 in outsets:
1643 guess[7] = do2
1644 for do3 in outsets:
1645 guess[8] = do3
1646
1647 print((di1, di2, di3), (do1, do2, do3))
1648
1649 r = minimize(objective, guess, method="Nelder-Mead",
1650 bounds=[
1651 (0.33, 0.6), (0.33, 0.6), (0.33, 0.6), # inset
1652 (-0.2, 0.2), (-0.2, 0.2), (-0.2, 0.2), # rotation anchor: limit to +/- 11°
1653 (0.15, 0.8), (0.15, 0.8), (0.15, 0.8), # outset
1654 (-0.2, 0.2), (-0.2, 0.2), (-0.2, 0.2), # rotation anchor: limit to +/- 11°
1655 ],
1656 options={"xatol": 1e-6, "fatol": 1e-6, "maxiter": 8000, "maxfev": 8000})
1657
1658 if r.fun < BIG and (best is None or r.fun < best.fun):
1659 best = r
1660 p = best.x
1661 skin_cr, skin_dh = measure(S_skin, C_skin, H_skin, *brk(p))
1662 cr, _ = measure(S_all, C_all, H_all, *brk(p))
1663 refl_cr, refl_dh = measure(S_refl, C_refl, H_refl, *brk(p))
1664 g = np.abs(refl_dh[refl_cr > 0.2])
1665 M, Mo = brk(p)
1666 print("// avg desaturation %.2f%% ; worst single color %.0f%% ; refl hue mean %.1f max %.1f (recovered downstream)"
1667 % (100 * (1 - cr).mean(), 100 * (1 - cr).max(), g.mean(), g.max()))
1668 print("// skin |mean| %.1f deg [%+.1f..%+.1f] ; cond %.1f"
1669 % (np.mean(np.abs(skin_dh)), skin_dh.min(), skin_dh.max(), max(np.linalg.cond(M), np.linalg.cond(Mo))))
1670
1671 if best is None:
1672 print("// no feasible no-bleach bracket under the constraints.")
1673 return
1674
1675 p = best.x
1676 message = "--min-bleach"
1677 print_diagnostics(p, message)
1678 return
1679
1680
1681if __name__ == "__main__":
1682 main()
const dt_aligned_pixel_t f
static const float const float const float min
const float max
static const int row
per_hue_ab_and_drift(v, S, C, H, bin_idx, nbins)
scene_ab_target(S_refl, hk_retention=1.0)
fit_midpoint(lo_key, hi_key, inset_lo, inset_hi, seed_insets)
print_c_case(case_name, fit, inset, irot, outset, orot)
print_variant_entry(name, fit, inset, irot, outset, orot)
residuals(params, drift_target_rad_per_ev)