#!/usr/bin/env python3 """ AVE character_sheet — 定妆照 Grid Method 自动生成 功能: 1. 角色描述 → 2x3 网格定妆照(Kling文生图 / 备选方案) 2. 从网格图裁剪单格参考图 3. 生成角色描述块 JSON(跨场景一致性注入用) 用法: python character_sheet.py --desc "一位28岁的中国男性..." --output sheet.png python character_sheet.py --desc "..." --output sheet.png --extract # 同时裁出6张单格 API: Kling 文生图 (官方 API: api-beijing.klingai.com) 备选: 阿里云百炼通义万相, fal.ai """ import sys import os import json import hashlib import time from pathlib import Path # 确保能找到上级模块 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from lib.config import load_config from lib.logger import get_logger logger = get_logger("character_sheet") # ── 缓存 ── CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR", str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet"))) CACHE_DIR.mkdir(parents=True, exist_ok=True) # ── Kling Image API ── KLING_BASE = "https://api-beijing.klingai.com" DASHSCOPE_BASE = "https://dashscope.aliyuncs.com" # ═══════════════════════════════════════════════════════════ # Prompt 构建 # ═══════════════════════════════════════════════════════════ # ── 标准 2x3 网格(日常/漫剧风格)── GRID_PROMPT_EN = ( "A professional character design sheet for {desc}. " "Layout is a 2x3 grid on a clean white background. " "Top row (3 panels): full body front view, full body side profile view, full body 3/4 turn view. " "Bottom row (3 panels): close-up of face - neutral expression, smiling expression, angry expression. " "Consistent character design, flat shading, concept art style. --ar 3:2" ) # ── 角色定妆照通用约束(★ 背景必须干净:带场景的参考图会污染后续图生视频)── # ── 角色定妆照三要素(★ 背景必须干净:带场景的参考图会污染图生视频)── # 设计要点(按提示词工程原则): # ① 不加 markdown 符号(** 等会干扰模型理解) # ② 背景约束**前置**(模型对提示词开头最敏感) # ③ 全文一致,不留矛盾表述(不能一处写"白色"、一处写"冷灰") # ④ 尽量短 —— 关键约束不被稀释 SHEET_BG_GREY_ZH = ( "纯色无缝冷灰摄影棚背景,背景上除了人物什么都没有(无墙、无家具、无道具、" "无窗、无植物、无风景、无地面阴影),各格背景色调完全一致。" ) SHEET_BG_WHITE_ZH = ( "纯色无缝纯白摄影棚背景,背景上除了人物什么都没有(无墙、无家具、无道具、" "无窗、无植物、无风景、无地面阴影),各格背景色调完全一致。" ) # 当前默认(实测对比用;改这一行即可切换) SHEET_BG_PREFIX_ZH = SHEET_BG_GREY_ZH # 研究结论(lanshu 工具包 §2,整合 20 个 5M+ 播放教程): # **相机型号是写实"杀手锏"** —— 触发模型的真实摄影先验; # `unfiltered` / 毛孔可见 是反 AI 塑料感的关键词。 SHEET_STYLE_ZH = ( "摄影棚均匀柔光,中性素净(不加影片调色)。" "真实皮肤质感、毛孔与细小绒毛可见、不磨皮不塑料感。" "拍摄质感:Fujifilm X100V 35mm 镜头直出,unfiltered,自然肤色。" ) GRID_PROMPT_ZH = ( "角色定妆参考表,四格并排(左一:胸像正面;左二:左侧脸 profile;" "右二:全身正面;右一:全身背面),四格为同一人,五官发型服装体型完全一致。" + SHEET_BG_PREFIX_ZH + SHEET_STYLE_ZH + "展示:{desc}。比例16:9。" ) GRID_PROMPT_CONCEPT_ZH = ( "角色设计表,三格并排(左:胸像正面;中:全身正面;右:全身背面)," "三格为同一角色,设计一致。" + SHEET_BG_PREFIX_ZH + "扁平着色概念艺术风格。" "展示:{desc}。比例16:9。" ) # ── 体育专用 4x2 网格(写实摄影风格,脸部优先)── # 布局: 4列×2行,8格 # Row 0: body_front | body_side | face_front | face_3qL # Row 1: face_profile | face_3qR | face_focus | face_intense SPORT_GRID_PROMPT_EN = ( "A photorealistic character reference sheet for {desc}. " "Layout is a 4x2 grid (4 columns, 2 rows) on a clean white background, thin gray dividers between panels. " "Row 1 (upper): full body front view, full body side profile, close-up face neutral front view, close-up face 3/4 left view. " "Row 2 (lower): close-up pure left side profile, close-up face 3/4 right view, close-up face focused determined expression, close-up face intense exertion expression. " "All 8 panels show the exact same person with identical face, hairstyle, body shape. " "Photorealistic, sports photography style, natural lighting, high detail, sharp focus on face panels. --ar 3:2" ) SPORT_GRID_PROMPT_ZH = ( "写实角色参考定妆照,4列2行共8格,格间细灰线,8格为同一人。" + SHEET_BG_PREFIX_ZH + "第1行:全身正面、全身侧面、面部正面特写、面部3/4左侧特写。" "第2行:面部左侧轮廓、面部3/4右侧、专注眼神特写、坚定表情特写。" + SHEET_STYLE_ZH + "展示:{desc}。比例3:2。" ) # ── 体育 I2I 专用 Prompt(基于参考照生成)── SPORT_GRID_I2I_PROMPT_EN = ( "Based on the reference photo, create a photorealistic 4x2 character reference sheet (8 panels). " "The character is {desc}. " "Layout: 4 columns x 2 rows, clean white background, thin gray dividers. " "Row 1: full body front view, full body side profile, close-up face neutral front, close-up face 3/4 left. " "Row 2: close-up pure left side profile, close-up face 3/4 right, close-up face focused, close-up face intense exertion. " "MUST maintain exact same face, hairstyle, body shape as the reference photo person. " "Photorealistic sports photography, natural lighting, high detail, sharp focus on face panels. --ar 3:2" ) GRID_ASPECT_RATIO = "3:2" # 网格图(多格横向布局,需要宽画幅) PORTRAIT_ASPECT_RATIO = "9:16" # 单视角半身/定妆照(**竖屏** —— 短剧主拍竖屏) # Kling 合法值:16:9 / 9:16 / 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 21:9 / 9:20 # ⚠️ 可灵图片模型(2026-10-04 实测):kling-v1 / v1-5 / v2 **已停用** # 可用:kling-v3 ✅ / kling-v2-1 ✅ KLING_IMAGE_MODEL = "kling-v3" def _ar_for_mode(mode: str) -> str: """按模式选画幅 · portrait(角色套装)· scene(场景套装)→ 9:16 竖屏 · 其他(网格定妆表)→ 3:2 横版 """ return PORTRAIT_ASPECT_RATIO if mode in ("portrait", "scene") else GRID_ASPECT_RATIO GRID_WIDTH = 1536 # 目标网格宽度 GRID_HEIGHT = 1024 # 标准 2x3 网格高度 SPORT_GRID_HEIGHT = 1536 # 体育 3x3 网格高度 DEFAULT_SEED = 42 # 默认固定种子,保证可复现 def build_character_block(desc: str) -> str: """构建角色描述块(全片复用的特征锚点)""" return desc # 用户输入的描述即为角色块 def build_grid_prompt(desc: str, lang: str = "zh", mode: str = "standard", use_ref: bool = False) -> str: """ 构建网格定妆照 Prompt mode: "standard" → 2x3 概念艺术(日常/漫剧) "sport" → 4x2 写实摄影(8格,脸部优先) use_ref: True → 使用 I2I 参考图模式 Prompt(sport mode 专用) """ if mode == "sport": if use_ref: return SPORT_GRID_I2I_PROMPT_EN.format(desc=desc) if lang == "zh": return SPORT_GRID_PROMPT_ZH.format(desc=desc) return SPORT_GRID_PROMPT_EN.format(desc=desc) # standard mode if lang == "zh": return GRID_PROMPT_ZH.format(desc=desc) return GRID_PROMPT_EN.format(desc=desc) # ═══════════════════════════════════════════════════════════ # 生成引擎: Kling 文生图 # ═══════════════════════════════════════════════════════════ def generate_grid( desc: str, output_path: str = "", lang: str = "zh", do_extract: bool = False, force: bool = False, mode: str = "standard", ref_image: str = "", seed: int | None = None, view: str = "", raw_prompt: str = "", ) -> dict: """ 生成网格定妆照 参数: desc: 角色描述 output_path: 输出路径 (.png/.jpg) lang: zh/en do_extract: 是否裁出单格图 force: 强制重新生成 mode: "standard"=2x3概念艺术 / "sport"=4x2写实体育(8格,脸部优先) ref_image: 参考图路径(sport mode 下用于 I2I,提升人脸一致性) seed: 固定随机种子(int),传入后 Kling 生成结果可复现; 为 None 时不传 seed,由 Kling 随机生成(并在返回中记录) 返回: {"grid": str, "cells": [...], "seed": int|None} — grid=网格图路径, cells=单格路径列表, seed=本次使用的种子 """ if not output_path: cache_key = hashlib.md5(f"{desc}{mode}".encode()).hexdigest()[:12] output_path = str(CACHE_DIR / f"grid_{cache_key}.png") cfg = load_config() provider = cfg.get("character_sheet", {}).get("provider", "kling") result = {"grid": "", "cells": [], "seed": seed} if provider == "kling": result = _generate_kling(desc, output_path, lang, force, mode=mode, ref_image=ref_image, seed=seed, view=view) else: raise ValueError(f"未知 provider: {provider},支持: kling") grid_path = result.get("grid", "") logger.info(f"定妆照生成完成: grid={grid_path}, seed={result.get('seed')}") # ★ 生成后质检(零成本,纯本地计算)—— 背景是否干净直接决定图生视频的一致性 try: from character_generator.quality_check import check_sheet_quality, clean_reference_background q = check_sheet_quality(grid_path) result["quality"] = q med = q.get("median_cell_std", -1) if q.get("ok"): logger.info(" ✅ 背景质检通过(各格中位标准差 %.1f,越低越干净)", med) else: logger.warning(" ⚠️ 背景不够干净(中位标准差 %.1f,阈值 <25):%s", med, (q.get("issues") or [""])[0][:70]) for c in (q.get("per_cell") or [])[:8]: logger.warning(" %s: %.1f", c.get("cell"), c.get("edge_std", -1)) # 自动兜底:抠图换纯色底(rembg 可用时;不可用则优雅跳过) cleaned = clean_reference_background(grid_path, bg="cool_grey") if cleaned.get("ok"): logger.info(" 🧹 已自动白底清理 → %s(边缘标准差 %.1f → %.1f)", os.path.basename(cleaned["output"]), cleaned["before"]["edge_std"], cleaned["after"]["edge_std"]) result["grid"] = cleaned["output"] result["cleaned"] = cleaned elif cleaned.get("skipped"): logger.info(" (跳过自动清理:%s)", cleaned.get("reason", "")[:60]) except Exception as e: logger.debug("质检跳过: %s", e) # 裁格 cells_result = {} if do_extract and grid_path and os.path.exists(grid_path): cells_result = extract_cells(grid_path, os.path.dirname(grid_path) or CACHE_DIR, mode=mode) result["cells"] = cells_result logger.info(f" 已裁出 {len(cells_result)} 张单格图") elif do_extract: logger.warning(" do_extract=True 但网格图路径无效,跳过裁格") # 生成 face_anchor.png(从 face_neutral 提取) face_anchor_path = "" if cells_result: face_anchor_path = _export_face_anchor(cells_result, os.path.dirname(grid_path) or CACHE_DIR) if face_anchor_path: result["face_anchor"] = face_anchor_path return result def build_portrait_set( desc: str, character_name: str = "", out_dir: str = "", anchor_image: str = "", only: tuple = (), force: bool = False, progress_cb=None, ) -> dict: """生成短剧角色参考图**套装**(5 视角 · 分开生成 + 参考图锚定五官) ★ 关键流程(业界短剧角色卡做法): ① 生成【正面半身主定妆】(front_half) —— 文生图 ② 以主定妆图为**参考图**,逐张生成其余 4 个视角 —— 图生图(I2I) 「把主定妆当参考图」是跨角度五官不崩的关键 · **逐张分开生成**(不是网格图)—— 每张全分辨率,五官清晰 · 断点续跑:已存在的文件默认跳过(force=True 强制重生成) · 每张生成后自动质检背景纯净度 · anchor_image:直接指定已满意的主定妆图(跳过第①步) 成本:约 ¥0.2/张 × 5 = ¥1(可灵图片计费,与视频额度分开) 返回:{anchor, views: {vid: {path, ok, quality, is_anchor}}, done, skipped, cost_estimate, note} """ from character_generator.portrait_set import PORTRAIT_VIEWS, VIEW_ORDER import time as _t _cb = progress_cb or (lambda p, n: None) base = Path(out_dir) if out_dir else (CACHE_DIR / (character_name or "portrait_set")) base.mkdir(parents=True, exist_ok=True) targets = [v for v in VIEW_ORDER if (not only or v in only)] out: dict = {"anchor": "", "views": {}, "done": 0, "skipped": 0, "cost_estimate": round(len(targets) * 0.2, 2)} anchor_path = anchor_image for idx, vid in enumerate(targets, 1): v = PORTRAIT_VIEWS[vid] dst = base / f"{vid}.png" _cb(int((idx - 1) / max(1, len(targets)) * 100), f"{v['label']}") if dst.exists() and not force and dst.stat().st_size > 10_000: # ★ 关键:若本张的生成时间**早于主定妆**,说明它是"无锚定"产物 # (首轮主定妆失败 → 后续降级文生图)→ 必须重跑,否则五官不锚定 _anchor_f = base / "front_half.png" _stale = (not v["is_anchor"]) and _anchor_f.exists() \ and dst.stat().st_mtime < _anchor_f.stat().st_mtime if not _stale: logger.info(" ♻️ 跳过已存在:%s", dst.name) _q = None try: from character_generator.quality_check import check_reference_quality _q = check_reference_quality(str(dst)).get("edge_std") except Exception: pass out["views"][vid] = {"path": str(dst), "ok": True, "skipped": True, "quality": _q, "is_anchor": v["is_anchor"]} out["skipped"] += 1 if v["is_anchor"] and not anchor_path: anchor_path = str(dst) continue logger.info(" 🔁 %s 早于主定妆生成(未锚定五官)→ 重新生成", dst.name) if v["is_anchor"]: if anchor_path and os.path.exists(str(anchor_path)): import shutil as _sh _sh.copy2(str(anchor_path), dst) logger.info(" ♻️ 主定妆复用指定参考图:%s", Path(str(anchor_path)).name) out["views"][vid] = {"path": str(dst), "ok": True, "reused": True, "is_anchor": True} out["anchor"] = str(dst) continue ref = "" # 无参考图 → 文生图 else: ref = anchor_path or (str(base / "front_half.png") if (base / "front_half.png").exists() else "") if not ref: logger.warning(" ⚠️ 无主定妆参考图,%s 将走文生图(五官可能漂移)", v["label"]) logger.info(" 🎨 [%d/%d] %s(%s)", idx, len(targets), v["label"], "图生图·锚定主定妆" if ref else "文生图") try: r = generate_grid(desc, str(dst), lang="zh", mode="portrait", ref_image=ref, view=vid, force=True) path = r.get("grid") or str(dst) ok = bool(path and os.path.exists(path)) q = None if ok: try: from character_generator.quality_check import check_reference_quality q = check_reference_quality(path).get("edge_std") except Exception: q = None out["views"][vid] = {"path": path, "ok": ok, "quality": q, "is_anchor": v["is_anchor"], "used_ref": bool(ref)} if ok: out["done"] += 1 if v["is_anchor"]: anchor_path = path out["anchor"] = path except Exception as e: logger.error(" ❌ %s 生成失败: %s", v["label"], str(e)[:120]) out["views"][vid] = {"path": "", "ok": False, "error": str(e)[:200], "is_anchor": v["is_anchor"]} _t.sleep(0.5) # ★ 自动入库:把 5 张写进角色资产(否则用户只能在缓存目录里找,看不到) if character_name: try: out["saved_to"] = _save_set_to_assets(character_name, out, base) except Exception as e: logger.warning(" ⚠️ 套装入库失败(文件仍在 cache 目录): %s", e) out["save_error"] = str(e)[:200] _cb(100, "完成") okn = sum(1 for x in out["views"].values() if x.get("ok")) out["note"] = (f"套装完成 {okn}/{len(targets)} 张" + ("(含已有文件)" if out["skipped"] else "") + (f";已入库「{character_name}」" if out.get("saved_to") else "") + ";参考图只用于锁定人物,环境交给分镜描述") logger.info(" 📸 %s", out["note"]) return out def _save_set_to_assets(character_name: str, out: dict, base: Path) -> dict: """把套装产物写入角色资产库(按名称查找/创建角色 + 复制 5 张 + 更新 reference_images) 返回 {aid, added: [...]} """ import shutil as _sh from datetime import datetime as _dt import sys as _sys _root = str(Path(__file__).resolve().parent.parent) if _root not in _sys.path: _sys.path.insert(0, _root) from assets_manager import AssetStore st = AssetStore("characters") it = st.get(character_name) # 先按 id 找 if not it: # 再按 name 找 for x in st.list(): if (x.get("name") or "").strip() == character_name.strip(): it = x break aid = it.get("id") if it else character_name if not it: # 都没有 → 新建 st.create({"id": aid, "name": character_name, "description": "由「📸 生成套装」创建的短剧角色", "tags": ["套装", "短剧"]}) it = st.get(aid) or {"id": aid} logger.info(" 🆕 已创建角色: %s", character_name) target = Path(st.kind_dir(aid)) target.mkdir(parents=True, exist_ok=True) refs = dict((it or {}).get("reference_images") or {}) SLOT = {"front_half": "front", "side_45": "side_45", "profile": "profile", "face_closeup": "face_closeup", "shoulder_look": "shoulder_look", "full_body_front": "full_body_front", "full_body_3q": "full_body_3q"} added = [] for vid, v in (out.get("views") or {}).items(): src = Path(v.get("path") or "") if not src.exists() or not v.get("ok"): continue slot = SLOT.get(vid, vid) if slot == "front" and refs.get("front"): # 覆盖 front 前备份 old = target / Path(str(refs["front"])).name if old.exists(): bak = old.with_name(f"{old.stem}_orig{old.suffix}") if not bak.exists(): _sh.copy2(old, bak) dst = target / f"ref_{vid}.png" _sh.copy2(src, dst) refs[slot] = dst.name added.append(f"{slot}={dst.name}") if added: hist = list((it or {}).get("generation_history") or []) hist.append({"at": _dt.now().isoformat(timespec="seconds"), "kind": "portrait_set", "note": "五视角套装(主定妆锚定)", "files": added}) st.update(aid, {"reference_images": refs, "generation_history": hist}) logger.info(" 💾 已入库角色「%s」:%d 张", character_name, len(added)) return {"aid": aid, "added": added} def _generate_kling( desc: str, output_path: str, lang: str = "zh", force: bool = False, mode: str = "standard", ref_image: str = "", seed: int | None = None, view: str = "", raw_prompt: str = "", ) -> dict: """ 通过 Kling API 生成定妆照 mode="standard": 文生图 2x3 网格 mode="sport": I2I 4x2(ref_image 锚定人脸,8格脸部优先) mode="portrait": **单张半身视角**(短剧角色卡套装用) · view 指定视角:front_half / side_45 / profile / face_closeup / shoulder_look · 传 ref_image → I2I(★ 以主定妆为参考,五官锁定) · 不传 → 文生图 """ import httpx import jwt import base64 # 缓存检查 if os.path.exists(output_path) and not force: logger.info(f"缓存命中: {output_path}") return {"grid": output_path, "cells": []} cfg = load_config() kling_cfg = cfg.get("kling", {}) api_key = (kling_cfg.get("api_key") or "").strip() ak = kling_cfg.get("access_key", "") sk = kling_cfg.get("secret_key", "") base_url = (kling_cfg.get("base_url") or KLING_BASE).rstrip("/") if not api_key and not (ak and sk): raise ValueError("缺少 Kling API 配置(kling.api_key 或 kling.access_key+secret_key)") # ★ 放开 I2I 限制:任何 mode 只要给了有效参考图就走图生图 # (原来写死 mode=="sport" → 套装流程的其余 4 张无法用主定妆锚定五官) use_ref = bool(ref_image and os.path.exists(ref_image)) if raw_prompt: # ★ 直接使用调用方给的成品提示词(场景生成等) prompt = raw_prompt elif mode == "portrait": from character_generator.portrait_set import build_view_prompt prompt = build_view_prompt(view or "front_half", desc) else: prompt = build_grid_prompt(desc, lang, mode=mode, use_ref=use_ref) logger.info(f"Kling 生成 [{mode}{'|I2I' if use_ref else '|T2I'}]" f"{'|' + view if view else ''}: {prompt[:80]}...") # 认证:优先 Bearer api-key(新版/第三方 key),回退官方 AK/SK → JWT if api_key: headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} logger.info(" Kling 鉴权: Bearer api-key") else: now = int(time.time()) token = jwt.encode( {"iss": ak, "exp": now + 1800, "nbf": now - 5}, sk, algorithm="HS256", ) headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} logger.info(" Kling 鉴权: AK/SK JWT") if use_ref: # ── I2I 模式:参考图 + prompt,最大化人脸一致性 ── with open(ref_image, "rb") as f: img_b64 = base64.b64encode(f.read()).decode() payload = { "model": KLING_IMAGE_MODEL, "prompt": prompt, "image": img_b64, "strength": 0.65, # 0.6-0.7: 保持人脸同时允许姿态变换 "aspect_ratio": _ar_for_mode(mode), "n": 1, } if seed is not None: payload["seed"] = seed endpoint = f"{base_url}/v1/images/generations" logger.info(f" 使用参考图 I2I: {ref_image}") else: # ── T2I 模式:纯文字生成 ── payload = { "model": KLING_IMAGE_MODEL, "prompt": prompt, "aspect_ratio": _ar_for_mode(mode), "n": 1, } if seed is not None: payload["seed"] = seed endpoint = f"{base_url}/v1/images/generations" # 带退避重试的 POST for attempt in range(3): resp = httpx.post(endpoint, headers=headers, json=payload, timeout=30, trust_env=False) if resp.status_code == 429: wait = 2 ** attempt * 15 # 15s, 30s, 60s logger.warning(f" 429 限流,等待 {wait}s 后重试...") time.sleep(wait) continue resp.raise_for_status() break data = resp.json() task_id = data.get("data", {}).get("task_id", "") if not task_id: raise RuntimeError(f"Kling 提交失败: {data}") logger.info(f" 任务: {task_id}, 轮询中...") # 轮询 for i in range(40): time.sleep(5) q = httpx.get(f"{base_url}/v1/images/generations/{task_id}", headers=headers, timeout=15, trust_env=False) q.raise_for_status() status_data = q.json().get("data", {}) task_status = status_data.get("task_status", "") if task_status == "succeed": images = status_data.get("task_result", {}).get("images", []) img_url = images[0].get("url", "") if images else "" # 读取 Kling 返回的 seed(如果 API 返回了的话) returned_seed = status_data.get("seed", seed) if img_url: logger.info(f" 下载中...") _download(img_url, output_path) # 保存 seed 到 .seed 文件,下次可直接复用 seed_file = output_path + ".seed" with open(seed_file, "w") as f: f.write(str(returned_seed)) logger.info(f" ✅ 网格图: {output_path} [seed={returned_seed}]") return {"grid": output_path, "cells": [], "seed": returned_seed} elif task_status == "failed": raise RuntimeError(f"Kling 生成失败: {status_data.get('fail_reason', '?')}") raise TimeoutError("Kling 生成超时(200s)") def _download(url: str, path: str): """下载文件""" import httpx os.makedirs(os.path.dirname(path) or ".", exist_ok=True) with httpx.stream("GET", url, timeout=600) as r: r.raise_for_status() with open(path, "wb") as f: for chunk in r.iter_bytes(8192): f.write(chunk) # ═══════════════════════════════════════════════════════════ # Grid 裁剪: 2x3 → 6 张单格图 / 3x3 → 9 张单格图 # ═══════════════════════════════════════════════════════════ # 标准 2x3 格名(行优先排列) CELL_NAMES_2X3 = [ "fullbody_front", # R0C0 全身正面 "fullbody_side", # R0C1 全身侧面 "fullbody_threeq", # R0C2 全身 3/4 "face_neutral", # R1C0 面部-中性 "face_smile", # R1C1 面部-微笑 "face_angry", # R1C2 面部-愤怒 ] # 体育 4x2 格名(行优先排列,8格) # Row 0: 全身正面 | 全身侧面 | 脸部正面中性 | 四分之三左侧 # Row 1: 纯左侧轮廓 | 四分之三右侧 | 脸部正面专注 | 脸部正面发力 CELL_NAMES_4X2 = [ "body_front", # R0C0 全身正面 "body_side", # R0C1 全身侧面 "face_front", # R0C2 正面中性 "face_3qL", # R0C3 四分之三左侧 "face_profile", # R1C0 纯左侧轮廓 "face_3qR", # R1C1 四分之三右侧 "face_focus", # R1C2 正面专注 "face_intense", # R1C3 正面发力 ] # 向后兼容旧名称映射 CELL_NAMES_3X3 = [ # deprecated,保留兼容 "fullbody_front", "fullbody_side", "fullbody_threeq", "face_neutral", "face_focused", "face_intense", "sport_run", "sport_jump", "sport_stance", ] # 向后兼容 CELL_NAMES = CELL_NAMES_2X3 def extract_cells(grid_path: str, output_dir: str = "", mode: str = "standard") -> dict[str, str]: """ 从网格图裁剪出单格参考图 参数: grid_path: 网格图路径 output_dir: 输出目录(默认与网格图同目录) mode: "standard"=2x3(6格) / "sport"=4x2(8格,脸部优先) / "face4x2"=4x2(8格) 返回: {"body_front": str, ..., "face_intense": str, ...} """ try: from PIL import Image except ImportError: logger.warning("PIL 未安装,跳过 cells 裁剪") return {} if not os.path.exists(grid_path): logger.error(f"网格图不存在: {grid_path}") return {} if not output_dir: output_dir = os.path.dirname(grid_path) or CACHE_DIR os.makedirs(output_dir, exist_ok=True) img = Image.open(grid_path) w, h = img.size # "sport" 和 "face4x2" 均使用 4x2 布局(8格,脸部优先) if mode in ("face4x2", "sport"): rows, cols = 2, 4 cell_names = CELL_NAMES_4X2 else: # 2 行 3 列(standard) rows, cols = 2, 3 cell_names = CELL_NAMES_2X3 col_w = w // cols row_h = h // rows base_name = os.path.splitext(os.path.basename(grid_path))[0] cells = {} for idx, cell_name in enumerate(cell_names): col = idx % cols row = idx // cols left = col * col_w upper = row * row_h right = left + col_w lower = upper + row_h cell_img = img.crop((left, upper, right, lower)) cell_path = os.path.join(output_dir, f"{base_name}_{cell_name}.png") cell_img.save(cell_path, "PNG") cells[cell_name] = cell_path logger.info(f" 裁剪: [{row},{col}] {cell_name} → {cell_path}") logger.info(f" 共裁剪 {len(cells)} 格 ({mode} mode)") return cells def _export_face_anchor(cells: dict[str, str], output_dir: str) -> str: """ 从已裁剪的 cells 中提取 face_front,生成 face_anchor.png face_anchor.png 是后续所有场景 I2I 的脸部固定锚点 返回: face_anchor.png 路径,未找到 face_front 时返回空字符串 """ face_key = "face_front" if face_key not in cells: logger.warning(f" face_anchor: 未找到 {face_key} cell,跳过") return "" import shutil src = cells[face_key] dst = os.path.join(output_dir, "face_anchor.png") try: shutil.copy2(src, dst) logger.info(f" ✅ face_anchor: {dst}") return dst except Exception as e: logger.warning(f" face_anchor 复制失败: {e}") return "" # ═══════════════════════════════════════════════════════════ # Kling I2I 变体生成 # ═══════════════════════════════════════════════════════════ def generate_variations( source_image: str, output_dir: str = "", variations: int = 3, prompt: str = "", force: bool = False, ) -> list[str]: """ 基于参考图通过 Kling I2I 生成多个变体 参数: source_image: 参考图路径 output_dir: 输出目录 variations: 变体数量 prompt: 可选,变体 prompt 描述 force: 强制重新生成 返回: 变体图片路径列表 """ try: import httpx import jwt from PIL import Image except ImportError: logger.warning("PIL 未安装,跳过变体生成") return [] if not os.path.exists(source_image): logger.error(f"参考图不存在: {source_image}") return [] if not output_dir: output_dir = os.path.dirname(source_image) or CACHE_DIR os.makedirs(output_dir, exist_ok=True) base_name = os.path.splitext(os.path.basename(source_image))[0] # 缓存检查 existing = [] for v in range(variations): vpath = os.path.join(output_dir, f"{base_name}_var{v+1}.png") if os.path.exists(vpath) and not force: existing.append(vpath) if len(existing) == variations: logger.info(f"变体缓存命中: {existing}") return existing cfg = load_config() kling_cfg = cfg.get("kling", {}) ak = kling_cfg.get("access_key", "") sk = kling_cfg.get("secret_key", "") if not ak or not sk: raise ValueError("缺少 Kling API 配置") # 读取并 base64 编码参考图 with open(source_image, "rb") as f: import base64 img_b64 = base64.b64encode(f.read()).decode() prompt_text = prompt or f"Character {base_name} in different pose, consistent design, same style" now = int(time.time()) token = jwt.encode( {"iss": ak, "exp": now + 1800, "nbf": now - 5}, sk, algorithm="HS256", ) headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} results = [] # Kling I2I 单次只能生成一张 for v in range(variations): vpath = os.path.join(output_dir, f"{base_name}_var{v+1}.png") if os.path.exists(vpath) and not force: results.append(vpath) continue payload = { "model": "kling-v1", "prompt": prompt_text, "image": img_b64, "strength": 0.7, # 与原图的相似度 } resp = httpx.post(f"{KLING_BASE}/v1/images/generations", headers=headers, json=payload, timeout=30, trust_env=False) resp.raise_for_status() data = resp.json() task_id = data.get("data", {}).get("task_id", "") if not task_id: raise RuntimeError(f"Kling I2I 提交失败: {data}") # 轮询 for i in range(30): time.sleep(5) q = httpx.get(f"{KLING_BASE}/v1/images/generations/{task_id}", headers=headers, timeout=15, trust_env=False) q.raise_for_status() status_data = q.json().get("data", {}) task_status = status_data.get("task_status", "") if task_status == "succeed": images = status_data.get("task_result", {}).get("images", []) img_url = images[0].get("url", "") if images else "" if img_url: _download(img_url, vpath) results.append(vpath) logger.info(f" ✅ 变体 {v+1}: {vpath}") break elif task_status == "failed": raise RuntimeError(f"Kling I2I 失败: {status_data.get('fail_reason', '?')}") return results # ═══════════════════════════════════════════════════════════ # 全流程: 从角色描述到注册中心 # ═══════════════════════════════════════════════════════════ def generate_and_register( character_name: str, description: str, registry_path: str = "", force: bool = False, mode: str = "standard", ref_image: str = "", seed: int | None = DEFAULT_SEED, ) -> dict: """ 全流程: 生成定妆照 → 裁剪 → 注册到 CharacterRegistry 参数: character_name: 角色名(与 registry.yaml 一致) description: 角色描述 registry_path: registry.yaml 路径(可选) force: 强制重新生成 mode: "standard"=2x3 / "sport"=4x2(脸部优先) / "face4x2"=4x2 ref_image: I2I 参考图路径(可选) seed: 固定种子(默认 42) 返回: { "character": "...", "grid": "path/to/grid.png", "cells": {"body_front": "...", ...}, "registry_updated": True } """ # 1. 生成网格图 output_dir = str(CACHE_DIR / character_name) os.makedirs(output_dir, exist_ok=True) grid_path = os.path.join(output_dir, f"{character_name}_grid.png") grid_result = generate_grid( desc=description, output_path=grid_path, force=force, do_extract=True, mode=mode, ref_image=ref_image, seed=seed, ) grid_path = grid_result["grid"] used_seed = grid_result.get("seed") cells = grid_result.get("cells", {}) face_anchor = grid_result.get("face_anchor", "") # 3. 同步到 CharacterRegistry try: from character_registry import CharacterRegistry if registry_path: registry = CharacterRegistry(registry_path) else: # 查找项目 registry.yaml registry = CharacterRegistry( os.path.join(os.path.dirname(__file__), "character_registry", "registry.yaml") ) # 构建参考图字典 ref_images = { "grid": grid_path, "face_anchor": face_anchor, # 场景 I2I 专用脸部锚点(从 face_front 提取) "portrait": cells.get("face_front", ""), "seed": used_seed, } # 体型参考 body = {} for cell_name, cell_path in cells.items(): if cell_name.startswith("body_"): body[cell_name.replace("body_", "")] = cell_path if body: ref_images["body"] = body # 表情参考(自动捕获所有 face_* 格) expressions = {} for cell_name, cell_path in cells.items(): if cell_name.startswith("face_"): emotion = cell_name.replace("face_", "") expressions[emotion] = cell_path # 表情参考 expressions = {} for cell_name, cell_path in cells.items(): if cell_name.startswith("face_"): emotion = cell_name.replace("face_", "") expressions[emotion] = cell_path if expressions: ref_images["expressions"] = expressions # 更新 registry registry.update_reference_images(character_name, ref_images) logger.info(f"✅ 已同步到 CharacterRegistry: {character_name}") registry_updated = True except Exception as e: logger.warning(f"同步到 CharacterRegistry 失败: {e}(不影响定妆照生成)") registry_updated = False return { "character": character_name, "grid": grid_path, "cells": cells, "registry_updated": registry_updated, } # ═══════════════════════════════════════════════════════════ # 持久化角色描述块 # ═══════════════════════════════════════════════════════════ CHARACTER_DB_DIR = CACHE_DIR / "characters" CHARACTER_DB_DIR.mkdir(parents=True, exist_ok=True) def save_character(name: str, desc: str, grid_path: str = "") -> str: """保存角色描述到本地库""" record = { "name": name, "description": desc, "grid_path": grid_path, "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), } safe_name = name.replace(" ", "_").replace("/", "_") path = CHARACTER_DB_DIR / f"{safe_name}.json" path.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8") logger.info(f"角色已保存: {path}") return str(path) def load_character(name: str) -> dict | None: """加载角色描述""" safe_name = name.replace(" ", "_").replace("/", "_") path = CHARACTER_DB_DIR / f"{safe_name}.json" if path.exists(): return json.loads(path.read_text(encoding="utf-8")) return None def list_characters() -> list[dict]: """列出所有保存的角色""" results = [] for f in sorted(CHARACTER_DB_DIR.glob("*.json")): try: data = json.loads(f.read_text(encoding="utf-8")) results.append(data) except (json.JSONDecodeError, OSError): pass return results # ═══════════════════════════════════════════════════════════ # CLI # ═══════════════════════════════════════════════════════════ def cli(args: list[str] | None = None): """CLI 入口""" import argparse parser = argparse.ArgumentParser(description="定妆照 Grid Method 生成") parser.add_argument("--desc", required=True, help="角色描述") parser.add_argument("--output", default="", help="输出路径") parser.add_argument("--lang", default="zh", choices=["zh", "en"], help="语言") parser.add_argument("--extract", action="store_true", help="裁出 6 张单格图") parser.add_argument("--save", default="", help="保存到角色库 (指定角色名)") parser.add_argument("--list", action="store_true", help="列出所有已保存角色") parser.add_argument("--load", default="", help="加载已保存角色的描述") parser.add_argument("--force", action="store_true", help="强制重新生成") parser.add_argument("--register", default="", help="同步到 CharacterRegistry (指定角色名)") parser.add_argument("--char-name", default="", help="角色名(用于注册时)") parser.add_argument("--seed", type=int, default=None, help="固定 Kling 随机种子(如 42),确保结果可复现;不指定则随机生成") parser.add_argument("--mode", default="standard", choices=["standard", "sport", "face4x2"], help="布局模式: standard=2x3, sport=4x2(脸部优先), face4x2=4x2") parser.add_argument("--ref", dest="ref_image", default="", help="I2I 参考图路径(sport/face4x2 模式下使用,提升人脸一致性)") parsed = parser.parse_args(args) if parsed.list: chars = list_characters() if not chars: print("暂无已保存角色") return print(f"已保存角色 ({len(chars)}):") for c in chars: print(f" {c['name']:20s} → {c.get('grid_path', '无网格图')}") return if parsed.load: char = load_character(parsed.load) if char: print(f"角色: {char['name']}") print(f"描述: {char['description']}") if char.get("grid_path"): print(f"网格图: {char['grid_path']}") parsed.desc = char["description"] if not parsed.char_name: parsed.char_name = char["name"] if parsed.register: # 全流程: 生成 + 裁剪 + 注册 char_name = parsed.register result = generate_and_register( character_name=char_name, description=parsed.desc, force=parsed.force, mode=parsed.mode, ref_image=parsed.ref_image, seed=parsed.seed, ) print(f"\n✅ 定妆照: {result['grid']}") for cell_name, cell_path in result.get("cells", {}).items(): print(f" {cell_name}: {cell_path}") if result.get("registry_updated"): print(f"✅ 已同步到 CharacterRegistry: {char_name}") else: print(f"⚠️ 定妆照已生成但未同步到注册中心(检查 registry.yaml 路径)") elif parsed.desc: result = generate_grid( desc=parsed.desc, output_path=parsed.output, lang=parsed.lang, do_extract=parsed.extract, force=parsed.force, mode=parsed.mode, ref_image=parsed.ref_image, seed=parsed.seed, ) print(f"\n✅ 定妆照: {result['grid']}") if result.get("seed") is not None: print(f" seed: {result['seed']} (可复现,存入 {result['grid']}.seed)") if parsed.extract: # 实际执行裁剪 output_dir = os.path.dirname(result['grid']) or CACHE_DIR cells = extract_cells(result['grid'], output_dir) for cell_name, cell_path in cells.items(): print(f" {cell_name}: {cell_path}") # face_anchor 已在 generate_grid 内生成,这里单独提示 face_anchor = result.get("face_anchor", "") if face_anchor: print(f" face_anchor: {face_anchor} ← 场景 I2I 专用锚点") # 保存到角色库 save_name = parsed.save or parsed.char_name or \ f"char_{hashlib.md5(parsed.desc.encode()).hexdigest()[:8]}" save_character(save_name, parsed.desc, result["grid"]) print(f"✅ 角色已保存: {save_name}") if __name__ == "__main__": cli() def build_reference_line(name: str = "") -> str: """生成「角色引用线」—— 在使用定妆照作为参考图时**必须**附上 ⚠️ 研究结论(nigo-studio):必须明确告诉模型"不要用参考图的背景和分格布局", 否则背景与格子会被带进生成结果 —— 这正是我们之前"背景嵌入"的机制之一。 """ n = (name or "该角色").strip() return (f"@图1 定义 {n} 的面部特征、发型与服装。" f"**不要使用图中的灰色背景,也不要使用分格布局**。") def build_positive_state(remove_items: str = "") -> str: """把「移除某物」改写成**正向状态描述**(研究结论:用正向,不用否定) 反例:"不要戴耳机" → 模型可能反而画出耳机 正例:"光着头,只有自然头发" """ m = { "耳机": "光着头,只有自然头发", "帽子": "光着头,只有自然头发", "眼镜": "裸眼,无任何镜片遮挡", "手套": "双手裸露无遮挡", "口罩": "面部完全露出,无遮挡", "护目镜": "裸眼,无护目装备", } for k, v in m.items(): if k in (remove_items or ""): return v return ""