From 71f2c51b1bdb79e58d085ee96b8eb85b38bf2192 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sun, 19 Jul 2026 20:23:08 +0200 Subject: [PATCH] Adding yellow tint control --- gnommo/cli.py | 87 ++++++++++++++++++++++++++---------------- gnommo/models.py | 6 +++ gnommo/preprocessor.py | 11 ++++++ 3 files changed, 71 insertions(+), 33 deletions(-) diff --git a/gnommo/cli.py b/gnommo/cli.py index d9eecfb..c11c4de 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -4757,6 +4757,21 @@ def _measure_skin(ref: Path, filters: "list[dict]") -> "tuple[int, int, int]": return (sr // n, sg // n, sb // n) if n else (0, 0, 0) +def _measure_suit(ref: Path, filters: "list[dict]") -> "tuple[int, int, int]": + """Mean (R,G,B) of yellow-costume pixels (opaque, high R&G, low B). R−G is + the 'orange-ness': ~0 = pure yellow, larger = more orange.""" + vf = _grade_video_filter(filters) + cmd = ["ffmpeg", "-v", "error", "-i", str(ref), "-frames:v", "1", + "-vf", f"{vf},format=rgba", "-f", "rawvideo", "-pix_fmt", "rgba", "-"] + raw = subprocess.run(cmd, capture_output=True).stdout + sr = sg = sb = n = 0 + for i in range(0, len(raw) - 3, 4 * 7): + r, g, b, a = raw[i], raw[i + 1], raw[i + 2], raw[i + 3] + if a > 200 and r > 120 and g > 90 and b < 90 and r > b and g > b: + sr += r; sg += g; sb += b; n += 1 + return (sr // n, sg // n, sb // n) if n else (0, 0, 0) + + def _render_preview(ref: Path, filters: "list[dict]", out_png: Path) -> None: """Save the filtered still composited over magenta (to judge the matte).""" vf = _grade_video_filter(filters) @@ -4885,50 +4900,56 @@ def _stage_despill(project_path, filters, ref, out_dir, source_name, ss) -> int: def _stage_grade(project_path, filters, ref, out_dir, source_name, ss) -> int: - """Deterministic 'look' sweep 0.0–1.0 → punchy color_grade candidates. + """Deterministic look × yellow-tint grid → punchy color_grade candidates. - The dial ramps a Photoshop-auto-levels-style contrast/levels stretch plus - vividness (saturation) and a slight pale lean, from flat (0) to strong (1), - so the candidates differ boldly instead of by a hair.""" + Two axes: 'look' ramps the auto-levels/vividness punch (flat → strong), and + 'yellow_tint' hue-selectively pulls the yellow costume back from the orange + that punch introduces (negative = greener/preserve, 0 = as-is). Skin (reds) + is untouched by the tint, so you can crank vibrance and keep the suit yellow.""" key = _grade_step(filters, "gnommokey") - values = [round(i / 5, 1) for i in range(6)] # look 0.0 .. 1.0 - print(" Sweeping look (auto-levels + vividness) 0.0 → 1.0") + looks = [0.4, 0.7, 1.0] + tints = [0.0, -0.4, -0.8] # 0 = as-is, negative = pull yellows back from orange + print(" Grid: look {0.4, 0.7, 1.0} × yellow_tint {0.0, -0.4, -0.8}") candidates = [] - for i, p in enumerate(values, 1): - cg = { - "type": "color_grade", - "auto_levels": round(p, 3), # levels/contrast punch - "saturation": round(1.0 + 0.5 * p, 3), # 1.0 → 1.5 vivid - "contrast": round(1.0 + 0.15 * p, 3), # gentle, colorlevels adds the rest - "brightness": round(0.02 * p, 3), - "rm": round(-0.04 * p, 3), # a touch paler/cooler - } - chain = _mask_and(filters, key, cg) - skin = _measure_skin(ref, chain) - png = out_dir / f"grade_{i}.png" - _render_preview(ref, chain, png) - params = {k: cg[k] for k in ("auto_levels", "saturation", "contrast", "brightness", "rm")} - params["look"] = p - candidates.append({ - "id": f"grade_{i}", "file": png.name, "params": params, - "hint": {"skin_rgb": list(skin), "warmth": skin[0] - skin[2]}, - }) + i = 0 + for p in looks: + for yt in tints: + i += 1 + cg = { + "type": "color_grade", + "auto_levels": round(p, 3), + "saturation": round(1.0 + 0.5 * p, 3), + "contrast": round(1.0 + 0.15 * p, 3), + "brightness": round(0.02 * p, 3), + "yellow_tint": yt, + } + chain = _mask_and(filters, key, cg) + suit = _measure_suit(ref, chain) + png = out_dir / f"grade_{i}.png" + _render_preview(ref, chain, png) + params = {k: cg[k] for k in ("auto_levels", "saturation", "contrast", "brightness", "yellow_tint")} + params["look"] = p + candidates.append({ + "id": f"grade_{i}", "file": png.name, "params": params, + "hint": {"suit_rgb": list(suit), "orange": suit[0] - suit[1]}, + }) manifest = { "stage": "grade", "target_step": "color_grade", - "source": source_name, "ss": round(ss, 2), "swept": ["look"], - "recommended": "grade_4", "candidates": candidates, + "source": source_name, "ss": round(ss, 2), "swept": ["look", "yellow_tint"], + "recommended": "grade_8", "candidates": candidates, # look 1.0, tint -0.4 } mpath = _grade_write_manifest(out_dir, manifest) rec = manifest["recommended"] - print(f" {'id':>10} {'look':>5} {'sat':>5} {'skin RGB':>17} {'warmth':>6} png") + print(f" {'id':>10} {'look':>5} {'tint':>5} {'suit RGB':>17} {'orange':>6} png") print(f" {'-'*10} {'-'*5} {'-'*5} {'-'*17} {'-'*6} {'-'*3}") for c in candidates: star = " ◀ suggested" if c["id"] == rec else "" - print(f" {c['id']:>10} {c['params']['look']:>5} {c['params']['saturation']:>5} " - f"{str(tuple(c['hint']['skin_rgb'])):>17} {c['hint']['warmth']:>6} {c['file']}{star}") - print(f"\n Manifest: {mpath}") - print(" Pick the look you like (flat → punchy):") - print(f" gnommo -p {project_path.name} grade --pick grade_4") + print(f" {c['id']:>10} {c['params']['look']:>5} {c['params']['yellow_tint']:>5} " + f"{str(tuple(c['hint']['suit_rgb'])):>17} {c['hint']['orange']:>6} {c['file']}{star}") + print("\n orange = suit R−G: lower is more yellow, higher is more orange") + print(f" Manifest: {mpath}") + print(" Pick punch (look) + a tint that keeps the suit yellow:") + print(f" gnommo -p {project_path.name} grade --pick grade_8") return 0 diff --git a/gnommo/models.py b/gnommo/models.py index a165fc9..3b8b783 100644 --- a/gnommo/models.py +++ b/gnommo/models.py @@ -202,6 +202,12 @@ class ColorGradeConfig: # window to full range, so the keyed-out background never skews it. auto_levels: float = 0.0 + # Yellow tint: hue-selective nudge of ONLY the yellow range (leaves reds/skin + # alone), to counter the orange shift auto-levels/saturation gives a yellow + # costume. <0 pulls yellows back toward green/pure yellow (preserve), >0 + # pushes them warmer/orange. Range roughly -1.0..1.0. 0 = off. + yellow_tint: float = 0.0 + # Custom curves for lift/gamma/gain control # Format: "0/0 0.5/0.56 1/1" means (input/output) control points curves_r: str = "" # Red channel curve diff --git a/gnommo/preprocessor.py b/gnommo/preprocessor.py index c926d06..e93d400 100644 --- a/gnommo/preprocessor.py +++ b/gnommo/preprocessor.py @@ -1057,6 +1057,16 @@ def build_color_grade_filter(config: dict) -> str: if eq_parts: parts.append(f"eq={':'.join(eq_parts)}") + # Yellow tint: hue-selective correction of the yellow range only (skin, in + # the reds, is untouched). In CMYK terms a yellow's "orange-ness" is its + # magenta content, so we push magenta up (warmer/orange) or down (greener, + # preserving the costume yellow) proportional to yellow_tint. + if grade_config.yellow_tint != 0: + yt = max(-1.0, min(1.0, grade_config.yellow_tint)) + magenta = round(0.6 * yt, 3) # red content of the yellow range + yellow = round(0.2 * yt, 3) # a little saturation follow so it reads + parts.append(f"selectivecolor=yellows=0 {magenta} {yellow} 0") + # Custom curves (if specified) custom_curves = [] if grade_config.curves_r: @@ -1099,6 +1109,7 @@ def parse_color_grade_config(config: dict) -> ColorGradeConfig: brightness=float(config.get("brightness", 0.0)), saturation=float(config.get("saturation", 1.0)), auto_levels=float(config.get("auto_levels", 0.0)), + yellow_tint=float(config.get("yellow_tint", 0.0)), # Custom curves curves_r=config.get("curves_r", ""), curves_g=config.get("curves_g", ""),