diff --git a/gnommo/cli.py b/gnommo/cli.py index 1189a03..4c0e8e5 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -4847,71 +4847,63 @@ def _grade_write_manifest(out_dir: Path, manifest: dict) -> Path: def _stage_key(project_path, filters, ref, out_dir, source_name, ss) -> int: - """Auto matte search (objective) → one recommended candidate + manifest. - - Coordinate descent over the matte fields, minimising 'partial' (unresolved - green fringe) subject to keeping the subject opaque — a candidate that drops - the opaque fraction below baseline (over-keying eats the subject) is rejected. - """ - def clamp(v, lo, hi): - return max(lo, min(hi, v)) + """Deterministic aggressiveness sweep → 10 matte variants to pick from. + An objective search always prefers the most aggressive key (lowest fringe), + which erodes edges and eats low-saturation subject pixels (e.g. eyes go + transparent/magenta). So instead of auto-picking, this ramps the three + eroding matte knobs together — screen_gain, shadow_boost, clip_black — from + gentle (subject fully intact, maybe faint background residue) to aggressive + (background gone but subject eroding), and lets the eye judge. The opaque + column falls as the key eats the subject; pick the balance before that.""" base = _grade_step(filters, "gnommokey") - cur = dict(base) - bt, bo, bp = _eval_alpha(ref, _mask_and(filters, cur)) - floor = bo * 0.98 - print(f" Baseline: transparent {bt*100:.1f}% opaque {bo*100:.1f}% partial {bp*100:.1f}%") - - g0, b0 = float(cur.get("screen_gain", 100)), float(cur.get("screen_balance", 50)) - grids = { - "screen_gain": sorted({int(clamp(g0 * f, 80, 300)) for f in (0.7, 0.85, 1.0, 1.2, 1.4, 1.7)}), - "screen_balance": sorted({int(clamp(b0 + d, 0, 100)) for d in (-20, -10, 0, 10, 20)}), - "shadow_boost": [0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], - "clip_black": [0, 2, 4, 6, 8, 10, 12], - } - - def better(cand, best): - ct, co, cp = cand - bt_, bo_, bp_ = best - cok, bok = co >= floor, bo_ >= floor - if cok != bok: - return cok - if abs(cp - bp_) > 1e-6: - return cp < bp_ - return ct > bt_ - - best = (bt, bo, bp) - for _ in range(2): - for param, values in grids.items(): - lb_cfg, lb = dict(cur), best - for v in values: - cand = dict(cur) - cand[param] = v - s = _eval_alpha(ref, _mask_and(filters, cand)) - if better(s, lb): - lb, lb_cfg = s, cand - cur, best = lb_cfg, lb - - ft, fo, fp = best - params = {k: cur[k] for k in ("screen_gain", "screen_balance", "shadow_boost", "clip_black") if k in cur} - png = out_dir / "key_1.png" - _render_preview(ref, _mask_and(filters, cur), png) + steps = [round(i / 9, 2) for i in range(10)] # aggressiveness 0.0 .. 1.0 + print(" Sweeping key aggressiveness 0.0 → 1.0 (gain 120→280, shadow_boost 0→3, clip_black 0→12)") + candidates = [] + for i, a in enumerate(steps, 1): + params = { + "screen_gain": int(round(120 + 160 * a)), + "shadow_boost": round(3.0 * a, 2), + "clip_black": int(round(12 * a)), + } + cfg = dict(base, **params) + t, o, p = _eval_alpha(ref, _mask_and(filters, cfg)) + png = out_dir / f"key_{i}.png" + _render_preview(ref, _mask_and(filters, cfg), png) + params["aggressiveness"] = a + candidates.append({ + "id": f"key_{i}", "file": png.name, "params": params, + "hint": {"transparent_pct": round(t * 100, 1), + "opaque_pct": round(o * 100, 1), + "partial_pct": round(p * 100, 1)}, + }) + # Recommend the *gentlest* setting that has essentially cleared the background + # — the knee where transparency stops climbing. Past it, more aggression just + # erodes the subject, which is exactly what we're trying to avoid. + rec = candidates[len(candidates) // 3]["id"] + for i in range(1, len(candidates)): + dt = candidates[i]["hint"]["transparent_pct"] - candidates[i - 1]["hint"]["transparent_pct"] + if dt < 0.3: + rec = candidates[i - 1]["id"] + break manifest = { "stage": "key", "target_step": "gnommokey", - "source": source_name, "ss": round(ss, 2), "swept": list(params.keys()), - "recommended": "key_1", - "candidates": [{ - "id": "key_1", "file": png.name, "params": params, - "hint": {"transparent_pct": round(ft * 100, 1), "partial_pct": round(fp * 100, 1)}, - }], + "source": source_name, "ss": round(ss, 2), + "swept": ["screen_gain", "shadow_boost", "clip_black"], + "recommended": rec, "candidates": candidates, } mpath = _grade_write_manifest(out_dir, manifest) - print(f"\n Selected (partial {bp*100:.1f}% → {fp*100:.1f}%, subject held at {fo*100:.1f}%):") - for k, v in params.items(): - print(f" {k}: {base.get(k, '—')} → {v}") - print(f" Preview: {png}") + print(f" {'id':>8} {'aggr':>5} {'gain':>4} {'shadow':>6} {'transp':>7} {'opaque':>7} {'partial':>7} png") + print(f" {'-'*8} {'-'*5} {'-'*4} {'-'*6} {'-'*7} {'-'*7} {'-'*7} {'-'*3}") + for c in candidates: + star = " ◀ suggested" if c["id"] == rec else "" + pr, h = c["params"], c["hint"] + print(f" {c['id']:>8} {pr['aggressiveness']:>5} {pr['screen_gain']:>4} {pr['shadow_boost']:>6} " + f"{h['transparent_pct']:>6}% {h['opaque_pct']:>6}% {h['partial_pct']:>6}% {c['file']}{star}") + print("\n opaque falls as the key eats the subject (eroded edges, keyed eyes) — pick just before that.") print(f" Manifest: {mpath}") - print(f" Apply: gnommo -p {project_path.name} grade --pick key_1") + print(" Pick the gentlest variant that clears the background without eroding the head/eyes:") + print(f" gnommo -p {project_path.name} grade --pick key_4") return 0 @@ -5040,8 +5032,9 @@ 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 - # '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")} + # 'look'/'paleness'/'aggressiveness' are UI-only dials, not real fields. + params = {k: v for k, v in cand["params"].items() + if k not in ("look", "paleness", "aggressiveness")} _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()))