From 17e35920b69f4f6a98c424a1af82a2364b480034 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sun, 19 Jul 2026 01:39:02 +0200 Subject: [PATCH] Adding grading sweep and autokeying --- README.md | 27 +++++++++++++++++++++---- gnommo/cli.py | 45 ++++++++++++++++++++++++------------------ gnommo/models.py | 6 ++++++ gnommo/preprocessor.py | 14 +++++++++++++ 4 files changed, 69 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3a155c0..19a4c44 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,29 @@ gnommo -p myproject import --- +### Stage 3: Grading + +Iterate on keying/grading without running a full preprocess. It seeks a few +seconds into a raw clip, runs it through the `talkinghead` filter chain, and +writes `grade_preview.mov` (ProRes 4444 with alpha) to the project root. + +# 1. KEY — auto, objective. Generates a candidate + preview, applies on pick. +gnommo -p video3 grade --stage key +open video3/grade_sweep/key_1.png # sanity-check the matte +gnommo -p video3 grade --pick key_1 # writes it to project.json + +# 2. DESPILL — sweep, YOU pick. 7 stills, spill_suppress 0.7–1.5. +gnommo -p video3 grade --stage despill +open video3/grade_sweep/ # eyeball despill_1..7.png +gnommo -p video3 grade --pick despill_5 # apply whichever looks clean + +# 3. GRADE — sweep, YOU pick. 6 stills, paleness 0.0–1.0. +gnommo -p video3 grade --stage grade +open video3/grade_sweep/ # eyeball grade_1..6.png +gnommo -p video3 grade --pick grade_3 # apply the paleness you like + + + ### Stage 3: Preprocess Applies video filters (chroma key, scaling, etc.) to narration segments. @@ -167,11 +190,7 @@ some blue, saturated yellow fabric reflects almost none. `yellow_protect` (yellow) while leaving skin/scalp spill fully suppressed. Bump it toward `1.0` if warm colours go orange; leave at `0` if you have no strong yellows. -#### Grade preview (`grade`) -Iterate on keying/grading without running a full preprocess. It seeks a few -seconds into a raw clip, runs it through the `talkinghead` filter chain, and -writes `grade_preview.mov` (ProRes 4444 with alpha) to the project root. ```bash gnommo -p myproject grade # first raw_mov clip, 3s from 5s in diff --git a/gnommo/cli.py b/gnommo/cli.py index eb900bd..d9eecfb 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -4885,43 +4885,50 @@ 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 paleness sweep 0.0–1.0 → color_grade candidates to pick.""" - base_cg = _grade_step(filters, "color_grade") or {} + """Deterministic 'look' sweep 0.0–1.0 → 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.""" key = _grade_step(filters, "gnommokey") - contrast = float(base_cg.get("contrast", 1.05)) - values = [round(i / 5, 1) for i in range(6)] # paleness 0.0 .. 1.0 - print(" Sweeping paleness 0.0 → 1.0") + 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") candidates = [] for i, p in enumerate(values, 1): - cg = {"type": "color_grade", "contrast": contrast, - "rm": round(-0.10 * p, 3), "gm": round(0.02 * p, 3), - "saturation": round(1.0 - 0.15 * p, 3), "brightness": round(0.05 * p, 3)} + 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 ("rm", "gm", "saturation", "brightness", "contrast")} - params["paleness"] = p + 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]}, }) manifest = { "stage": "grade", "target_step": "color_grade", - "source": source_name, "ss": round(ss, 2), "swept": ["paleness"], - "recommended": "grade_3", "candidates": candidates, + "source": source_name, "ss": round(ss, 2), "swept": ["look"], + "recommended": "grade_4", "candidates": candidates, } mpath = _grade_write_manifest(out_dir, manifest) rec = manifest["recommended"] - print(f" {'id':>10} {'paleness':>8} {'skin RGB':>17} {'warmth':>6} png") - print(f" {'-'*10} {'-'*8} {'-'*17} {'-'*6} {'-'*3}") + print(f" {'id':>10} {'look':>5} {'sat':>5} {'skin RGB':>17} {'warmth':>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']['paleness']:>8} " + 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 paleness you like:") - print(f" gnommo -p {project_path.name} grade --pick grade_3") + print(" Pick the look you like (flat → punchy):") + print(f" gnommo -p {project_path.name} grade --pick grade_4") return 0 @@ -4948,8 +4955,8 @@ def _grade_pick(project_path, stage_hint, pick) -> int: if cand is None: print(f" ERROR: candidate '{cand_id}' not in {mpath.name}.") return 1 - # 'paleness' is a UI-only dial, not a real color_grade field. - params = {k: v for k, v in cand["params"].items() if k != "paleness"} + # 'look'/'paleness' are UI-only dials, not real color_grade fields. + params = {k: v for k, v in cand["params"].items() if k not in ("look", "paleness")} _apply_candidate_to_project(project_path, manifest["target_step"], params) print(f" Applied {cand_id} → project.json ({manifest['target_step']}): " + ", ".join(f"{k}={v}" for k, v in params.items())) diff --git a/gnommo/models.py b/gnommo/models.py index 77903d9..a165fc9 100644 --- a/gnommo/models.py +++ b/gnommo/models.py @@ -196,6 +196,12 @@ class ColorGradeConfig: brightness: float = 0.0 # Brightness adjustment (-1.0 to 1.0, 0 = no change) saturation: float = 1.0 # Saturation multiplier (0.0-3.0, 1.0 = no change) + # Auto-levels: a fixed histogram stretch (crush blacks, lift whites) for + # punch, like Photoshop auto-levels. 0 = off, 1 = strong. Fixed (not + # per-frame adaptive) so it can't flicker; it remaps a constant [lo,hi] + # window to full range, so the keyed-out background never skews it. + auto_levels: 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 09c9662..c926d06 100644 --- a/gnommo/preprocessor.py +++ b/gnommo/preprocessor.py @@ -1004,6 +1004,19 @@ def build_color_grade_filter(config: dict) -> str: # Start with format conversion to RGBA for color operations parts.append("format=rgba") + # Auto-levels: a fixed contrast/levels stretch for punch. Crushes blacks and + # lifts whites by remapping [lo, hi] → [0, 1] equally per channel (luminance + # levels, no colour shift). Fixed points → no per-frame flicker, and no + # histogram is measured so the transparent green background can't skew it. + if grade_config.auto_levels > 0: + al = min(max(grade_config.auto_levels, 0.0), 1.0) + lo = round(0.10 * al, 4) + hi = round(1.0 - 0.10 * al, 4) + parts.append( + f"colorlevels=rimin={lo}:gimin={lo}:bimin={lo}:" + f"rimax={hi}:gimax={hi}:bimax={hi}" + ) + # Color balance (only add if any value is non-zero) colorbalance_parts = [] if grade_config.rs != 0: @@ -1085,6 +1098,7 @@ def parse_color_grade_config(config: dict) -> ColorGradeConfig: contrast=float(config.get("contrast", 1.0)), brightness=float(config.get("brightness", 0.0)), saturation=float(config.get("saturation", 1.0)), + auto_levels=float(config.get("auto_levels", 0.0)), # Custom curves curves_r=config.get("curves_r", ""), curves_g=config.get("curves_g", ""),