Adding yellow tint control

This commit is contained in:
2026-07-19 20:23:08 +02:00
parent 17e35920b6
commit 71f2c51b1b
3 changed files with 71 additions and 33 deletions
+54 -33
View File
@@ -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) 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). RG 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: def _render_preview(ref: Path, filters: "list[dict]", out_png: Path) -> None:
"""Save the filtered still composited over magenta (to judge the matte).""" """Save the filtered still composited over magenta (to judge the matte)."""
vf = _grade_video_filter(filters) 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: def _stage_grade(project_path, filters, ref, out_dir, source_name, ss) -> int:
"""Deterministic 'look' sweep 0.01.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 Two axes: 'look' ramps the auto-levels/vividness punch (flat → strong), and
vividness (saturation) and a slight pale lean, from flat (0) to strong (1), 'yellow_tint' hue-selectively pulls the yellow costume back from the orange
so the candidates differ boldly instead of by a hair.""" 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") key = _grade_step(filters, "gnommokey")
values = [round(i / 5, 1) for i in range(6)] # look 0.0 .. 1.0 looks = [0.4, 0.7, 1.0]
print(" Sweeping look (auto-levels + vividness) 0.0 → 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 = [] candidates = []
for i, p in enumerate(values, 1): i = 0
cg = { for p in looks:
"type": "color_grade", for yt in tints:
"auto_levels": round(p, 3), # levels/contrast punch i += 1
"saturation": round(1.0 + 0.5 * p, 3), # 1.0 → 1.5 vivid cg = {
"contrast": round(1.0 + 0.15 * p, 3), # gentle, colorlevels adds the rest "type": "color_grade",
"brightness": round(0.02 * p, 3), "auto_levels": round(p, 3),
"rm": round(-0.04 * p, 3), # a touch paler/cooler "saturation": round(1.0 + 0.5 * p, 3),
} "contrast": round(1.0 + 0.15 * p, 3),
chain = _mask_and(filters, key, cg) "brightness": round(0.02 * p, 3),
skin = _measure_skin(ref, chain) "yellow_tint": yt,
png = out_dir / f"grade_{i}.png" }
_render_preview(ref, chain, png) chain = _mask_and(filters, key, cg)
params = {k: cg[k] for k in ("auto_levels", "saturation", "contrast", "brightness", "rm")} suit = _measure_suit(ref, chain)
params["look"] = p png = out_dir / f"grade_{i}.png"
candidates.append({ _render_preview(ref, chain, png)
"id": f"grade_{i}", "file": png.name, "params": params, params = {k: cg[k] for k in ("auto_levels", "saturation", "contrast", "brightness", "yellow_tint")}
"hint": {"skin_rgb": list(skin), "warmth": skin[0] - skin[2]}, 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 = { manifest = {
"stage": "grade", "target_step": "color_grade", "stage": "grade", "target_step": "color_grade",
"source": source_name, "ss": round(ss, 2), "swept": ["look"], "source": source_name, "ss": round(ss, 2), "swept": ["look", "yellow_tint"],
"recommended": "grade_4", "candidates": candidates, "recommended": "grade_8", "candidates": candidates, # look 1.0, tint -0.4
} }
mpath = _grade_write_manifest(out_dir, manifest) mpath = _grade_write_manifest(out_dir, manifest)
rec = manifest["recommended"] 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}") print(f" {'-'*10} {'-'*5} {'-'*5} {'-'*17} {'-'*6} {'-'*3}")
for c in candidates: for c in candidates:
star = " ◀ suggested" if c["id"] == rec else "" star = " ◀ suggested" if c["id"] == rec else ""
print(f" {c['id']:>10} {c['params']['look']:>5} {c['params']['saturation']:>5} " print(f" {c['id']:>10} {c['params']['look']:>5} {c['params']['yellow_tint']:>5} "
f"{str(tuple(c['hint']['skin_rgb'])):>17} {c['hint']['warmth']:>6} {c['file']}{star}") f"{str(tuple(c['hint']['suit_rgb'])):>17} {c['hint']['orange']:>6} {c['file']}{star}")
print(f"\n Manifest: {mpath}") print("\n orange = suit RG: lower is more yellow, higher is more orange")
print(" Pick the look you like (flat → punchy):") print(f" Manifest: {mpath}")
print(f" gnommo -p {project_path.name} grade --pick grade_4") print(" Pick punch (look) + a tint that keeps the suit yellow:")
print(f" gnommo -p {project_path.name} grade --pick grade_8")
return 0 return 0
+6
View File
@@ -202,6 +202,12 @@ class ColorGradeConfig:
# window to full range, so the keyed-out background never skews it. # window to full range, so the keyed-out background never skews it.
auto_levels: float = 0.0 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 # Custom curves for lift/gamma/gain control
# Format: "0/0 0.5/0.56 1/1" means (input/output) control points # Format: "0/0 0.5/0.56 1/1" means (input/output) control points
curves_r: str = "" # Red channel curve curves_r: str = "" # Red channel curve
+11
View File
@@ -1057,6 +1057,16 @@ def build_color_grade_filter(config: dict) -> str:
if eq_parts: if eq_parts:
parts.append(f"eq={':'.join(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 specified)
custom_curves = [] custom_curves = []
if grade_config.curves_r: if grade_config.curves_r:
@@ -1099,6 +1109,7 @@ def parse_color_grade_config(config: dict) -> ColorGradeConfig:
brightness=float(config.get("brightness", 0.0)), brightness=float(config.get("brightness", 0.0)),
saturation=float(config.get("saturation", 1.0)), saturation=float(config.get("saturation", 1.0)),
auto_levels=float(config.get("auto_levels", 0.0)), auto_levels=float(config.get("auto_levels", 0.0)),
yellow_tint=float(config.get("yellow_tint", 0.0)),
# Custom curves # Custom curves
curves_r=config.get("curves_r", ""), curves_r=config.get("curves_r", ""),
curves_g=config.get("curves_g", ""), curves_g=config.get("curves_g", ""),