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 "
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()
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).")
903 y_row = REC2020_TO_XYZ_D50[1]
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])
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]
915 bin_idx = (np.floor(np.remainder(Hrf, 2 * np.pi) / (2 * np.pi) * nbins).astype(int)) % nbins
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)
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)])
930 lo_v, hi_v = SHIPPED_VARIANTS[lo_key], SHIPPED_VARIANTS[hi_key]
933 ab_tgt = 0.5 * (AB_lo + AB_hi)
934 hd_tgt = 0.5 * (HD_lo + HD_hi)
935 ch_tgt = 0.5 * (CH_lo + CH_hi)
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())
956 W_AB, W_CH, W_HD = 4000.0, 4000.0, 1.0
962 if max(np.linalg.cond(M), np.linalg.cond(Mo)) > 7.1:
964 wl = worst_lum(M, Mo)
968 if skin_dh.min() < -3.0:
970 AB, HD, CH = ph_ab_hd(M, Mo)
971 ab_err = float(np.nanmean((AB - ab_tgt) ** 2))
972 hd_err = float(np.nanmean((HD - hd_tgt) ** 2))
973 ch_err = float(np.nanmean((CH - ch_tgt) ** 2))
975 gamut_pen = 1.0e4 *
max(0.0, 0.0002 - wl)
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)
981 def interp_prim(c0, a0, c1, a1, t):
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)))
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
993 return [*v[
"inset"], *v[
"irot"], *v[
"outset"], *v[
"orot"]]
995 seeds = [interp_at(t)
for t
in np.linspace(0.2, 0.8, 5)] + [flat(lo_v), flat(hi_v)]
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):
1003 print(
"// no feasible bisection bracket (gamut-safe) between %s and %s." % (lo_key, hi_key))
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)),
1015 if args.fit_extra_bleach:
1016 from scipy.optimize
import minimize
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])
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]
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())
1051 _abin = (np.floor(np.remainder(Hrf, 2 * np.pi) / (2 * np.pi) * _abnb).astype(int)) % _abnb
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)
1056 return np.array([ab[_abin == b].mean()
if (_abin == b).any()
else np.nan
for b
in range(_abnb)])
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)
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"]))
1082 if max(np.linalg.cond(M), np.linalg.cond(Mo)) > COND_CAP:
1084 wl = worst_lum(M, Mo)
1089 if skin_dh.min() < -3.0:
1104 refl_hue = np.deg2rad(np.abs(refl_dh[refl_cr > 0.2]))
1107 gamut_pen = 1.0e4 *
max(0.0, 0.0002 - wl)
1113 bleach_reward = args.bleach_nudge * float(np.mean(1.0 - refl_cr))
1118 if args.ab_stabilize > 0.0
or args.ab_level > 0.0:
1120 ab_mean = np.nanmean(ab)
1121 nvalid = int(np.sum(~np.isnan(ab)))
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))
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)
1140 bounds = [(0.40, 0.98)] * 3 + [(-0.2, 0.2)] * 3 + [(0.30, 0.98)] * 3 + [(-0.2, 0.2)] * 3
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])
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):
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))))
1164 print(
"// no feasible extra-bleach bracket under the constraints.")
1169 if args.interpolate_fits:
1171 bounds = [SHIPPED_VARIANTS[
"no-bleach"], SHIPPED_VARIANTS[
"extra-bleach"]]
1173 def compute_vector(coeff, angle):
1174 return coeff * np.cos(angle), coeff * np.sin(angle)
1176 def vector_to_coeff_angle(vec):
1177 coeff = np.hypot(vec[0], vec[1])
1178 angle = np.arctan2(vec[1], vec[0])
1181 def build_variant_from_vectors(name, fit, in_vectors, out_vectors):
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
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])
1204 for bound
in bounds:
1206 primaries_in.append(compute_vector(bound[
"inset"][i], bound[
"irot"][i]))
1207 primaries_out.append(compute_vector(bound[
"outset"][i], bound[
"orot"][i]))
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)
1214 out_vectors = [interpolate_vectors(primaries_out[i], primaries_out[i + 3], t)
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"])
1227 if args.fit_low_bleach:
1228 p =
fit_midpoint(
"no-bleach",
"high-bleach", 0.20, 0.75, (0.35, 0.45, 0.55))
1232 print(
"// no feasible low-bleach midpoint under the constraints.")
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.")
1240 y_row = REC2020_TO_XYZ_D50[1]
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])
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"]
1256 return [*v[
"inset"], *v[
"irot"], *v[
"outset"], *v[
"orot"]]
1258 def interp_prim(c0, a0, c1, a1, t):
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)))
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
1274 bin_idx = (np.floor(np.remainder(Hrf, 2 * np.pi) / (2 * np.pi) * nbins).astype(int)) % nbins
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)
1280 return np.array([ab[bin_idx == b].mean()
if (bin_idx == b).any()
else np.nan
for b
in range(nbins)])
1282 def desat_of(M, Mo):
1284 return float((1.0 - rcr).mean())
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)
1291 d_no, d_ex = desat_of(Mno, Mono_), desat_of(Mex, Moex)
1292 target = d_no + frac * (d_ex - d_no)
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)))]))
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())
1315 if max(np.linalg.cond(M), np.linalg.cond(Mo)) > 7.1:
1317 wl = worst_lum(M, Mo)
1321 if skin_dh.min() < -3.0:
1323 pos = (desat_of(M, Mo) - target) ** 2
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)))
1327 qualia = float(np.sum((np.array(p) - np.array(anchor)) ** 2))
1328 gamut_pen = 1.0e4 *
max(0.0, 0.0002 - wl)
1329 return 100.0 * pos + 20.0 * mono + skin_dE.mean() + skin_dE.max() + 0.30 * qualia + gamut_pen
1333 seeds = [flat(no_v), flat(ex_v)] + [interp_at(tt)
for tt
in np.linspace(0.2, 0.9, 6)]
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):
1341 print(
"// no feasible medium-bleach bracket (gamut-safe, inside the per-hue ramp).")
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,
1354 if args.max_desat
is not None:
1355 from scipy.optimize
import minimize
1356 budget = float(args.max_desat)
1364 def yrg_rg_batch(RGB):
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]
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)
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
1384 S_skin, C_skin, H_skin = S_skin[skeep], C_skin[skeep], H_skin[skeep]
1387 def measure(S, C_in, H_in, M, Mo):
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)
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)
1399 """(skin_cr, skin_dh, refl_cr, refl_dh, cond) or None if degenerate."""
1407 cond =
max(np.linalg.cond(M), np.linalg.cond(Mo))
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
1423 skin_cr, skin_dh, refl_cr, refl_dh, cond = ev
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])
1430 worst_hue = float(gated.max())
1433 hue_err = np.mean(gated) + worst_hue + np.mean(np.abs(skin_dh)) + np.max(np.abs(skin_dh))
1448 skin_jacket = 200.0 *
max(0.0, float(skin_dE.max()) - SKIN_DE_CAP)
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)
1463 + 1e4 *
max(0.0, mean_desat - budget)
1464 +
max(0.0, worst_hue - 16.0)
1467 p0 = [0.7, 0.7, 0.7,
1476 insets = np.linspace(0.5, 0.9, 4)
1477 outsets = np.linspace(0.5, 0.9, 4)
1491 r = minimize(objective, p0, method=
"Nelder-Mead",
1493 (0.50, 0.9), (0.50, 0.9), (0.50, 0.9),
1494 (-0.1, 0.1), (-0.1, 0.1), (-0.1, 0.1),
1495 (0.4, 0.9), (0.4, 0.9), (0.4, 0.9),
1496 (-0.1, 0.1), (-0.1, 0.1), (-0.1, 0.1),
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):
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])
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))
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.")
1520 message =
"--max-desat %f" % budget
1525 from scipy.optimize
import minimize
1526 hk_weight = float(args.hk_weight)
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)
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])
1557 ab_pull_w = float(args.ab_pull)
1558 ab_y_row = REC2020_TO_XYZ_D50[1]
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)
1565 return np.array([ab[ab_bin == b].mean()
if (ab_bin == b).any()
else np.nan
for b
in range(ab_nbins)])
1573 def measure(S, C_in, H_in, M, Mo):
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)
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)
1588 cond =
max(np.linalg.cond(M), np.linalg.cond(Mo))
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:
1607 refl_desat = 1.0 - refl_cr
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)
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)
1623 -0.0045667, -0.0085405, +0.0070037,
1625 -0.0007132, -0.0099789, +0.0057890,
1628 outsets = [0.35, 0.65]
1634 for di1
in np.linspace(0.35, 0.6, 4):
1636 for di2
in np.linspace(0.35, 0.6, 4):
1638 for di3
in np.linspace(0.35, 0.6, 4):
1647 print((di1, di2, di3), (do1, do2, do3))
1649 r = minimize(objective, guess, method=
"Nelder-Mead",
1651 (0.33, 0.6), (0.33, 0.6), (0.33, 0.6),
1652 (-0.2, 0.2), (-0.2, 0.2), (-0.2, 0.2),
1653 (0.15, 0.8), (0.15, 0.8), (0.15, 0.8),
1654 (-0.2, 0.2), (-0.2, 0.2), (-0.2, 0.2),
1656 options={
"xatol": 1e-6,
"fatol": 1e-6,
"maxiter": 8000,
"maxfev": 8000})
1658 if r.fun < BIG
and (best
is None or r.fun < best.fun):
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])
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))))
1672 print(
"// no feasible no-bleach bracket under the constraints.")
1676 message =
"--min-bleach"