feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,11 @@
"""
角色生成器包 — Character Generator Package
提供从粗方向到完整角色变体素材的全流程:
- DirectionExpander 方向扩展(粗方向→详细描述)
- AttributeExtractor 属性提取(自然语言→结构化属性)
- PromptAssembler 提示词组装(结构化属性→Kling/即梦提示词)
- VariantGenerator 变体生成(Kling API 批量生成多角度/表情)
- AssetRegistrar 资产注册(注册到 registry.yaml + 知识库)
- CharacterGenerationPipeline 全流程编排器
"""
@@ -0,0 +1,304 @@
"""
素材注册器 (AssetRegistrar) — 将生成的角色变体素材注册到 registry.yaml + 知识库
注册内容:
- 更新 registry.yaml 中的 reference_images (face_anchor, body各角度, expressions)
- 更新 outfit_presets(如果有自定义服装变体)
- 生成标签元数据供 asset_manager 索引
- 回写到 prompts/character_sheet_prompts.md(知识库)
"""
import json
import os
import sys
import yaml
from pathlib import Path
from datetime import datetime
from typing import Optional
path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, path)
from lib.logger import get_logger
logger = get_logger('asset_registrar')
# ── 路径 ──
SCRIPTS_DIR = Path(__file__).resolve().parent
REGISTRY_PATH = SCRIPTS_DIR.parent / 'character_registry' / 'registry.yaml'
REGISTRY_BACKUP_DIR = SCRIPTS_DIR.parent / 'character_registry' / 'backups'
PROMPTS_DIR = SCRIPTS_DIR / 'prompts'
CHARACTER_SHEET_PROMPTS = PROMPTS_DIR / 'character_sheet_prompts.md'
# ── 变体标签映射 ──
VARIANT_TAGS = {
'front_face': ('面部特写', '基准', '正面'),
'multi_angle': {
'default': ('全身', '正面', '体型'),
'front': ('全身', '正面', '体型'),
'side': ('全身', '侧面', '体型'),
'right_three_quarter': ('全身', '右侧45°', '体型'),
'right_side': ('全身', '纯右侧', '体型'),
'back': ('全身', '背面', '体型'),
},
'expression': {
'default': ('表情', '中性'),
'neutral': ('表情', '中性'),
'smile': ('表情', '微笑'),
'focused': ('表情', '专注'),
'laugh': ('表情', '大笑'),
'serious': ('表情', '严肃'),
},
'scene': {
'morning_run': ('场景', '晨跑', '运动'),
'gym_workout': ('场景', '健身房', '力量训练'),
'street_basketball': ('场景', '篮球', '街头'),
'office_work': ('场景', '办公室', '工作'),
'outdoor_fitness': ('场景', '户外', '健身'),
},
'sport_action': {
'running': ('运动', '奔跑', '动态'),
'jumping': ('运动', '跳跃', '动态'),
'defensive': ('运动', '防守', '姿态'),
'stretching': ('运动', '拉伸', '热身'),
},
}
class AssetRegistrar:
"""将生成的角色素材注册到 registry.yaml + 知识库。"""
def __init__(self):
self._registry_data = self._load_registry()
self._registry_path = REGISTRY_PATH
# ══════════════════════════════════════════════════
# 底层 IO
# ══════════════════════════════════════════════════
def _load_registry(self) -> dict:
"""从 registry.yaml 加载数据。"""
if not REGISTRY_PATH.exists():
return {'active_character': None, 'characters': {}}
with open(REGISTRY_PATH, 'r', encoding='utf-8') as f:
return yaml.safe_load(f) or {'active_character': None, 'characters': {}}
def _save_registry(self):
"""保存 registry.yaml(先备份再写入)
⚠️ registry.yaml 已**废弃**(账号/角色归属以 ORACLE.yaml 为准,见 CONSTITUTION 规则 3),
默认**不再写入**以免污染仓库;如需临时启用,设置环境变量 `AVE_WRITE_REGISTRY=1`。
"""
import os
if os.environ.get("AVE_WRITE_REGISTRY") != "1":
print(" ⚠️ registry.yaml 已废弃,跳过写入(如需启用设 AVE_WRITE_REGISTRY=1)")
return
REGISTRY_BACKUP_DIR.mkdir(parents=True, exist_ok=True)
if REGISTRY_PATH.exists():
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = REGISTRY_BACKUP_DIR / f'registry_{timestamp}.yaml'
with open(backup_path, 'w', encoding='utf-8') as f:
f.write(REGISTRY_PATH.read_text(encoding='utf-8'))
with open(REGISTRY_PATH, 'w', encoding='utf-8') as f:
yaml.dump(
self._registry_data,
f,
allow_unicode=True,
default_flow_style=False,
sort_keys=False,
)
# ══════════════════════════════════════════════════
# 注册角色属性
# ══════════════════════════════════════════════════
def register_character_properties(self, name: str, attrs: dict) -> bool:
"""注册/更新角色的属性信息到 registry.yaml。
Args:
name: 角色名
attrs: 结构化属性字典
Returns:
是否成功
"""
chars = self._registry_data['characters']
if name not in chars:
chars[name] = {}
char = chars[name]
# 写入永久属性
for key in ('name', 'description', 'base', 'personality', 'voice_style', 'art_style'):
if key in attrs and attrs[key]:
char[key] = attrs[key]
# 写入分层特征
for category in ('appearance', 'face', 'body', 'clothing', 'accessories'):
if category in attrs and attrs[category]:
char[category] = attrs[category]
self._save_registry()
logger.info(f' 已注册角色属性: {name}')
return True
# ══════════════════════════════════════════════════
# 注册参考图
# ══════════════════════════════════════════════════
def register_reference_images(
self,
name: str,
ref_type: str,
filepath: str,
option: str = '',
) -> bool:
"""注册单张参考图到 registry。
Args:
name: 角色名
ref_type: 类型 (face_anchor/baseline/body/expression/real_photo)
filepath: 图片路径
option: 选项(如 body 的 front/side, expression 的 smile/neutral)
Returns:
是否成功
"""
chars = self._registry_data['characters']
if name not in chars:
chars[name] = {}
char = chars[name]
if 'reference_images' not in char:
char['reference_images'] = {}
refs = char['reference_images']
if ref_type not in refs:
refs[ref_type] = []
entry = {'path': filepath}
if option:
entry['option'] = option
refs[ref_type].append(entry)
self._save_registry()
return True
# ══════════════════════════════════════════════════
# 批量注册定妆照
# ══════════════════════════════════════════════════
def register_portrait_set(
self,
name: str,
attrs: dict,
baseline: Optional[str] = None,
angles: Optional[dict] = None,
expressions: Optional[dict] = None,
) -> bool:
"""批量注册一套完整的定妆照。
Args:
name: 角色名
attrs: 结构化属性
baseline: 基准照路径
angles: {angle_name: filepath, ...}
expressions: {expr_name: filepath, ...}
Returns:
是否成功
"""
if baseline:
self.register_reference_images(name, 'face_anchor', baseline)
if angles:
for angle, path in angles.items():
self.register_reference_images(name, 'body', path, option=angle)
if expressions:
for expr, path in expressions.items():
self.register_reference_images(name, 'expression', path, option=expr)
return True
# ══════════════════════════════════════════════════
# 更新知识库
# ══════════════════════════════════════════════════
def update_knowledge_base(self, name: str, attrs: dict) -> bool:
"""将角色描述更新到 character_sheet_prompts.md。
Args:
name: 角色名
attrs: 结构化属性
Returns:
是否成功
"""
base = attrs.get('base', '')
face = attrs.get('face', {})
body = attrs.get('body', {})
# 构建角色描述块
char_block_parts = [f'### {name}']
char_block_parts.append(f'- **描述**: {attrs.get("description", "")}')
char_block_parts.append(
f'- **面部**: 脸型={face.get("shape", "?")}, '
f'眼睛={face.get("eyes", "?")}, '
f'发型={face.get("hair", "?")}, '
f'肤色={face.get("skin", "?")}'
)
char_block_parts.append(
f'- **体型**: 体态={body.get("build", "?")}, '
f'身高={body.get("height", "?")}'
)
char_block_parts.append(
f'- **性格**: {attrs.get("personality", "?")}'
)
char_block_parts.append(
f'- **语音**: {attrs.get("voice_style", "?")}'
)
char_block = '\n'.join(char_block_parts)
new_section = f'\n\n{char_block} # 自动注册)\n\n'
# 写入 prompts/character_sheet_prompts.md
PROMPTS_DIR.mkdir(parents=True, exist_ok=True)
if CHARACTER_SHEET_PROMPTS.exists():
existing = CHARACTER_SHEET_PROMPTS.read_text(encoding='utf-8')
if f'### {name}' not in existing:
with open(CHARACTER_SHEET_PROMPTS, 'a', encoding='utf-8') as f:
f.write(new_section)
else:
with open(CHARACTER_SHEET_PROMPTS, 'w', encoding='utf-8') as f:
f.write(f'# 角色定妆照提示词库\n{new_section}')
logger.info(f' 已更新知识库: {name}')
return True
# ══════════════════════════════════════════════════
# 标签工具
# ══════════════════════════════════════════════════
def get_variant_tags(self, variant_type: str, option: str = '') -> tuple:
"""获取变体类型的标签元数据。
Args:
variant_type: 变体类型
option: 选项名
Returns:
标签元组
"""
tags = VARIANT_TAGS.get(variant_type, {})
if isinstance(tags, tuple):
return tags
if isinstance(tags, dict):
return tags.get(option, tags.get('default', ()))
return ()
if __name__ == '__main__':
registrar = AssetRegistrar()
print(f'素材注册器就绪, registry: {REGISTRY_PATH}')
print(f'知识库: {CHARACTER_SHEET_PROMPTS}')
@@ -0,0 +1,276 @@
"""
属性提取器 (AttributeExtractor) — 从自然语言角色描述中提取结构化属性
输出结构:
{
"name": "角色名",
"description": "完整自然语言描述",
"base": "核心特征(不含服装的永久特征)",
"appearance": ["特征1", "特征2", ...], # 外观特征列表
"face": {"shape": "国字脸", "eyes": "单眼皮", ...},
"body": {"build": "健壮", "height": "175cm", ...},
"clothing": {"top": "深灰夹克", "inner": "白色内衬", ...},
"accessories": ["蓝牙耳机"],
"personality": "沉稳内敛",
"voice_style": "成熟稳重男声",
"art_style": "写实摄影风格",
}
"""
import json
import re
from typing import Optional
# ── 语音风格枚举 ──
VOICE_STYLES = (
'活泼少女音',
'温柔青年音',
'成熟稳重男声',
'清澈少年音',
'低沉磁性',
'甜美少女音',
'知性女声',
'治愈系',
'默认',
)
# ── 画风枚举 ──
ART_STYLES = (
'写实摄影风格',
'日系漫画风格',
'电影级摄影风格',
'超写实风格',
'赛博朋克风格',
'水墨风格',
)
class AttributeExtractor:
"""从自然语言描述中提取结构化角色属性。"""
# ══════════════════════════════════════════════════
# 主入口
# ══════════════════════════════════════════════════
def extract(self, description: str, name: str = '') -> dict:
"""从自然语言描述中提取结构化属性。
Args:
description: 自然语言角色描述
name: 角色名称
Returns:
结构化属性字典
"""
if not description:
return self._default_attributes(name)
attrs = self._default_attributes(name)
attrs['description'] = description
attrs['base'] = self._extract_base(description)
attrs['appearance'] = self._extract_appearance_list(description)
attrs['face'] = self._extract_face(description)
attrs['body'] = self._extract_body(description)
attrs['clothing'] = self._extract_clothing(description)
attrs['accessories'] = self._extract_accessories(description)
attrs['personality'] = self._extract_personality(description)
attrs['voice_style'] = self._extract_voice(description)
attrs['art_style'] = self._extract_art_style(description)
return attrs
# ══════════════════════════════════════════════════
# 默认值
# ══════════════════════════════════════════════════
def _default_attributes(self, name: str = '') -> dict:
"""返回默认空属性模板。"""
return {
'name': name or '未命名',
'description': '',
'base': '',
'appearance': [],
'face': {k: '' for k in ('shape', 'eyes', 'nose', 'lips', 'hair', 'skin', 'facial_hair', 'glasses', 'distinctive')},
'body': {k: '' for k in ('build', 'height', 'posture', 'age_group')},
'clothing': {k: '' for k in ('top', 'inner', 'bottom', 'shoes', 'style')},
'accessories': [],
'personality': '默认',
'voice_style': '写实摄影风格',
}
# ══════════════════════════════════════════════════
# 各类别提取
# ══════════════════════════════════════════════════
def _extract_base(self, desc: str) -> str:
"""提取核心特征(去服装、去场景的永久性特征)。"""
base = re.sub(r'身穿[^,。]*', '', desc)
base = re.sub(r'穿着[^,。]*', '', base)
base = re.sub(r'([^)]*)', '', base)
base = base.replace(',', '').replace('。', '').replace(' ', '')
return base.strip()
def _extract_appearance_list(self, desc: str) -> list:
"""提取外观特征列表。
按分类匹配模式,返回 ["脸型:国字脸", "眼部:单眼皮", ...]。
"""
patterns = [
('(国字脸|圆脸|方脸|瓜子脸|长脸)', '脸型'),
('(单眼皮|双眼皮|内双|大眼睛|小眼睛|细长眼)', '眼部'),
('(黑色短发|黑短发|棕色短发|寸头|齐刘海|中长发|三七分|背头|卷发)', '发型'),
('(健壮|偏瘦|壮硕|匀称|标准|苗条|丰满|粗犷)', '体型'),
('(深灰[色]?[^,。]*夹克|浅灰[色]?[^,。]*卫衣|运动[^,。]*|连帽[^,。]*)', '服装'),
]
features = []
for pattern, category in patterns:
m = re.search(pattern, desc)
if m:
matched = m.group(1) if m.lastindex and m.lastindex >= 1 else m.group(0)
feature = f'{category}:{matched}'
if len(feature) <= 8:
features.append(feature)
return features
def _extract_face(self, desc: str) -> dict:
"""提取面部详细特征。"""
face = {k: '' for k in ('shape', 'eyes', 'nose', 'lips', 'hair', 'skin', 'facial_hair', 'glasses', 'distinctive')}
m = re.search(r'(国字脸|圆脸|方脸|瓜子脸|长脸|菱形脸)', desc)
if m:
face['shape'] = m.group(1)
m = re.search(r'(单眼皮|双眼皮|内双|大眼睛|小眼睛|细长眼|眼尾[^,。]*)', desc)
if m:
face['eyes'] = m.group(1)
m = re.search(r'(鼻梁[^,。]*?|鼻翼[^,。]*?|[^,。]*鼻[^,。]*)', desc)
if m:
face['nose'] = m.group(1)
m = re.search(r'(嘴唇[^,。]*?|嘴角[^,。]*?)', desc)
if m:
face['lips'] = m.group(1)
m = re.search(r'(黑[色]?[^,。]*发|棕[色]?[^,。]*发|寸头|平头|光头|卷发|直发|[^,。]*短发[^,。]*)', desc)
if m:
face['hair'] = m.group(1)
m = re.search(r'(小麦色|白皙|健康[^,。]*|古铜色|偏[^,。]*[肤皮])', desc)
if m:
face['skin'] = m.group(1)
m = re.search(r'(胡须|胡子|络腮胡|山羊胡|胡茬)', desc)
if m:
face['facial_hair'] = m.group(1)
m = re.search(r'([^,。]*眼镜[^,。]*)', desc)
if m:
face['glasses'] = m.group(1)
m = re.search(r'(耳[机塞][^,。]*|耳机|特殊特征[^,。]*)', desc)
if m:
face['distinctive'] = m.group(1)
return face
def _extract_body(self, desc: str) -> dict:
"""提取体型特征。"""
body = {k: '' for k in ('build', 'height', 'posture', 'age_group')}
m = re.search(r'(健壮|偏瘦|壮硕|匀称|标准|苗条|丰满|粗犷|肌肉[^,。]*)', desc)
if m:
body['build'] = m.group(1)
m = re.search(r'(\d+cm|\d+m\d+)', desc)
if m:
body['height'] = m.group(1)
m = re.search(r'(\d+岁)', desc)
if m:
body['age_group'] = m.group(1)
m = re.search(r'(挺拔|微驼|笔直|自然)', desc)
if m:
body['posture'] = m.group(1)
return body
def _extract_clothing(self, desc: str) -> dict:
"""提取服装特征。"""
clothing = {k: '' for k in ('top', 'inner', 'bottom', 'shoes', 'style')}
m = re.search(r'([^,。]*?[夹克卫衣衬衫T恤外套][^,。]*)', desc)
if m:
clothing['top'] = m.group(1)
m = re.search(r'([^,。]*?[内衬打底背心][^,。]*)', desc)
if m:
clothing['inner'] = m.group(1)
m = re.search(r'([^,。]*?[裤子短裤牛仔裤][^,。]*)', desc)
if m:
clothing['bottom'] = m.group(1)
m = re.search(r'(运动[^,。]*|商务[^,。]*|休闲[^,。]*|正式[^,。]*)', desc)
if m:
clothing['style'] = m.group(1)
return clothing
def _extract_accessories(self, desc: str) -> list:
"""提取配饰列表。"""
accessories = []
for m in re.finditer(r'(耳机|眼镜|手表|手环|项链|帽子|围巾|腰带|护腕|背包)', desc):
accessories.append(m.group(1))
return accessories
def _extract_personality(self, desc: str) -> str:
"""提取性格特征。"""
m = re.search(r'(性格[^,。]*|气质[^,。]*|性[^,。]*?)', desc)
if m:
text = m.group(1)
keywords = ('沉稳', '开朗', '温和', '内敛', '专注', '活泼', '坚韧', '倔强')
found = [k for k in keywords if k in text]
if found:
return '、'.join(found)
return '默认'
def _extract_voice(self, desc: str) -> str:
"""提取语音风格。"""
m = re.search(r'([^,。]*[音声][^,。]*)', desc)
if m:
voice_text = m.group(1)
# 关键词匹配
if '音' in voice_text or '声' in voice_text:
if '中年' in voice_text or '沉稳' in voice_text:
return '成熟稳重男声'
elif '少女' in voice_text or '活泼' in voice_text:
return '活泼少女音'
elif '青年' in voice_text or '温和' in voice_text:
return '温柔青年音'
return '默认'
def _extract_art_style(self, desc: str) -> str:
"""提取画风。"""
m = re.search(r'([^,。]*?[风格][^,。]*)', desc)
if m:
style_text = m.group(1)
if '风' in style_text or '格' in style_text:
return '写实摄影风格'
return '写实摄影风格'
if __name__ == '__main__':
extractor = AttributeExtractor()
test_desc = (
'一位约42岁的中国男性,体型健壮肩宽,黑色短发利落,'
'国字脸单眼皮眼尾微垂,鼻梁挺直,神态沉稳内敛,'
'肤色健康自然。身穿深灰色拉链运动夹克配白色速干内衬,'
'右耳戴着蓝色无线运动耳机。'
)
result = extractor.extract(test_desc, 'ghai_sports')
print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -0,0 +1,132 @@
"""
方向扩展器 (DirectionExpander) — 将用户给的粗方向补充为完整自然语言描述
流程:
用户输入 → 调用本地 oMLX LLM (Qwen3.5) → 返回完整角色自然语言描述
扩展内容:
- 面部特征(五官、发型、脸型、肤色)
- 体型(身高、肩宽、体态)
- 服装(上装、下装、鞋子)
- 标志性配件/装饰
- 性格/气质
- 声音/语态
- 画风/风格倾向
如果用户描述已经足够详细,则直接使用,不做过度补充。
"""
import json
import os
import sys
from pathlib import Path
path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, path)
from lib.config import load_config
from lib.logger import get_logger
logger = get_logger('direction_expander')
environ = os.environ.get
OMLX_BASE = environ('OMLX_BASE', 'http://localhost:8000/v1')
OMLX_MODEL = environ('OMLX_MODEL', 'Qwen3.5-4B-MLX-4bit')
SYSTEM_PROMPT = (
"你是一个专业的角色设计师助理。用户会给你一个角色的\"粗方向\"(一两句话的粗略描述),\n"
"你需要将它扩展为一段丰富、详细、可执行的自然语言角色描述。\n\n"
"扩展规则:\n"
"1. 保留用户原始描述中的所有信息,不得修改或扭曲\n"
"2. 补充合理细节:面部特征(五官形态、脸型、肤质)、发型发色、体型、身高体态、服装风格、标志性配件\n"
"3. 补充性格气质和声音特征\n"
"4. 如果用户给出了具体的细节关键词(如\"国字脸\"\"单眼皮\"\"蓝牙耳机\"),保留并丰富它们\n"
"5. 如果用户描述已经足够详细(超过80字),则基本照原样输出,仅微调润色\n"
"6. 描述要写实,不要说\"动漫风格\"——写实角色以真人摄影为参照\n"
"7. 输出**只有**角色描述本身,不要带任何前缀、标签或解释\n\n"
"示例输入:\n"
"\"一个运动型中年男性,喜欢户外跑步\"\n\n"
"示例输出:\n"
"\"一位约42岁的中国男性,体型健壮肩宽,黑色短发利落,国字脸单眼皮眼尾微垂,"
"鼻梁挺直,神态沉稳内敛,肤色健康自然偏小麦色。"
"身穿深灰色拉链运动夹克配白色速干内衬。"
"整体气质坚毅沉稳,给人可靠踏实的感觉。声音偏向成熟稳重的中低音。\""
)
class DirectionExpander:
"""将粗方向扩展为详细自然语言描述"""
def __init__(self):
self._base_url = OMLX_BASE
self._model = OMLX_MODEL
def expand(self, direction: str) -> str:
"""将粗方向扩展为详细自然语言描述。
Args:
direction: 用户输入的粗方向,如"运动型中年男性"
Returns:
扩展后的完整角色描述字符串
扩展策略:
- 空输入 → 返回空字符串
- 超过80字 → 已足够详细,直接使用(log info)
- 80字以内 → 调用本地 LLM 扩展(异常时回退到原始方向)
"""
if not direction or not direction.strip():
return ''
if len(direction) > 80:
logger.info('方向描述已足够详细(>80字),直接使用')
return direction.strip()
try:
return self._call_llm(direction)
except Exception as e:
logger.warning(f'LLM 扩展失败 ({e}),回退到直接使用原始方向')
return direction.strip()
def _call_llm(self, direction: str) -> str:
"""调用本地 oMLX LLM 扩展方向描述。
Args:
direction: 原始方向描述
Returns:
LLM 返回的角色详细描述,去除首尾空白
Raises:
网络/API/解析异常 — 由调用者处理
"""
import httpx
payload = {
'model': self._model,
'messages': [
{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': direction},
],
'temperature': 0.7,
'max_tokens': 500,
}
resp = httpx.post(
f'{self._base_url}/chat/completions',
json=payload,
timeout=30,
)
resp.raise_for_status()
data = resp.json()
content = data.get('choices', [{}])[0].get('message', {}).get('content', '')
return content.strip()
if __name__ == '__main__':
expander = DirectionExpander()
test_inputs = [
'一个运动型中年男性',
'一位28岁的城市程序员,黑短发,戴细框金属眼镜',
]
for inp in test_inputs:
print(f'\n输入: {inp}')
print(f'输出: {expander.expand(inp)}')
@@ -0,0 +1,417 @@
"""
角色生成完整流水线 (CharacterGenerationPipeline)
完整流程:
用户方向 (粗方向)
↓
[DirectionExpander] — 调用本地 LLM 补充为完整自然语言描述
↓
详细角色描述
↓
[AttributeExtractor] — 提取结构化的面部/体型/服装/配件/性格
↓
结构化属性
↓
[PromptAssembler] — 按不同类型组装生成提示词
↓
生成提示词
↓
[VariantGenerator] — 调用 Kling API 生成各类变体
↓
变体图像
↓
[AssetRegistrar] — 写入 registry.yaml + 知识库
↓
完成
用法:
from character_generator.pipeline import CharacterGenerationPipeline
pipeline = CharacterGenerationPipeline()
# 完整流程:从粗方向开始
result = pipeline.run_full("一个运动型中年男性", character_name="new_char")
# 仅扩展描述(不生成图像)
desc = pipeline.expand_direction("一个运动型中年男性")
# 从已有描述开始(生成+注册)
result = pipeline.run_from_description("详细描述...", character_name="new_char")
# 仅生成定妆照网格
result = pipeline.generate_portrait("角色名", force=True)
"""
import json
import sys
import os
from pathlib import Path
from typing import Optional
path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, path)
from character_generator.direction_expander import DirectionExpander
from character_generator.attribute_extractor import AttributeExtractor
from character_generator.prompt_assembler import PromptAssembler
from character_generator.variant_generator import VariantGenerator
from character_generator.asset_registrar import AssetRegistrar
from lib.logger import get_logger
logger = get_logger('character_pipeline')
class CharacterGenerationPipeline:
"""角色生成完整流水线
整合五个子模块,提供从粗方向到角色变体素材的一站式接口。
"""
def __init__(self):
self._expander = DirectionExpander()
self._extractor = AttributeExtractor()
self._assembler = PromptAssembler()
self._generator = VariantGenerator()
self._registrar = AssetRegistrar()
# ──────────────────────────────────────────────
# 主流程
# ──────────────────────────────────────────────
def run_full(
self,
direction: str,
character_name: str = '',
lang: str = 'zh',
generate_variants: bool = True,
seed: int = 0,
) -> dict:
"""完整流水线:从粗方向到注册完成。
Args:
direction: 用户输入的粗方向,如"一个运动型中年男性"
character_name: 角色名(为空则自动生成)
lang: 语言
generate_variants: 是否生成变体图像
seed: 固定种子(0=随机)
Returns:
{
"name": str,
"description": str,
"attributes": dict,
"baseline": dict or None,
"angles": dict or None,
"expressions": dict or None,
"grid": dict or None,
"registered": True,
}
"""
step_results = {}
# [1/5] 扩展方向
logger.info(f'[1/5] 扩展方向: {direction[:50]}...')
description = self._expander.expand(direction)
step_results['description'] = description
# 自动生成角色名
if not character_name:
character_name = self._generate_name(direction, description)
# [2/5] 提取属性
logger.info(f'[2/5] 提取属性: {character_name}')
attrs = self._extractor.extract(description, name=character_name)
step_results['attributes'] = attrs
# [3/5] 注册角色属性
logger.info('[3/5] 注册角色属性')
self._registrar.register_character_properties(character_name, attrs)
# [4/5] 生成变体
if generate_variants:
generated = self._generate_all(attrs, lang, seed)
step_results['generated'] = generated
else:
step_results['generated'] = {}
# 注册定妆照到 registry
generated = step_results.get('generated', {})
baseline = generated.get('baseline', {}).get('path', '')
angles = {
k: v['path']
for k, v in generated.get('angles', {}).items()
if v.get('path')
}
expressions = {
k: v['path']
for k, v in generated.get('expressions', {}).items()
if v.get('path')
}
if baseline or angles or expressions:
self._registrar.register_portrait_set(
character_name,
attrs,
baseline=baseline or None,
angles=angles or None,
expressions=expressions or None,
)
# [5/5] 更新知识库
logger.info('[4/5] 注册素材到知识库')
self._registrar.update_knowledge_base(character_name, attrs)
return {
'name': character_name,
'description': description,
'attributes': attrs,
'baseline': generated.get('baseline', {}),
'angles': generated.get('angles', {}),
'expressions': generated.get('expressions', {}),
'grid': generated.get('grid', {}),
'registered': True,
}
# ──────────────────────────────────────────────
# 从已有描述开始(跳过方向扩展)
# ──────────────────────────────────────────────
def run_from_description(
self,
description: str,
character_name: str = '',
lang: str = 'zh',
seed: int = 0,
) -> dict:
"""从完整描述直接开始,绕过方向扩展步骤。
Args:
description: 完整的角色自然语言描述
character_name: 角色名(为空则自动生成)
lang: 语言
seed: 固定种子
Returns:
同 run_full()
"""
if not character_name:
character_name = self._generate_name(description, description)
attrs = self._extractor.extract(description, name=character_name)
self._registrar.register_character_properties(character_name, attrs)
generated = self._generate_all(attrs, lang, seed)
# 注册定妆照到 registry
baseline = generated.get('baseline', {}).get('path', '')
angles = {
k: v.get('path', '')
for k, v in generated.get('angles', {}).items()
if v.get('path')
}
expressions = {
k: v.get('path', '')
for k, v in generated.get('expressions', {}).items()
if v.get('path')
}
self._registrar.register_portrait_set(
character_name,
attrs,
baseline=baseline or None,
angles=angles or None,
expressions=expressions or None,
)
self._registrar.update_knowledge_base(character_name, attrs)
return {
'name': character_name,
'description': description,
'attributes': attrs,
'baseline': generated.get('baseline', {}),
'angles': generated.get('angles', {}),
'expressions': generated.get('expressions', {}),
'grid': generated.get('grid', {}),
'registered': True,
}
# ──────────────────────────────────────────────
# 仅生成定妆照
# ──────────────────────────────────────────────
def generate_portrait(self, character_name: str, force: bool = False, seed: int = 0) -> dict:
"""仅基于已注册的角色信息生成定妆照网格。
需要角色已在 registry 中注册(有 attrs 属性)。
Args:
character_name: 角色名
force: 强制重新生成
seed: 固定种子
Returns:
变体生成结果 dict
"""
from character_registry import CharacterRegistry
registry = CharacterRegistry()
char = registry.get_character(character_name)
attrs = char.to_dict()
generated = self._generate_all(attrs, 'zh', seed, force)
# 注册定妆照
baseline = generated.get('baseline', {}).get('path', '')
if baseline:
self._registrar.register_reference_images(
character_name, 'face_anchor', baseline
)
for angle, path in generated.get('angles', {}).items():
self._registrar.register_reference_images(
character_name, 'body', path, option=angle
)
for expr, path in generated.get('expressions', {}).items():
self._registrar.register_reference_images(
character_name, 'expression', path, option=expr
)
return generated
# ──────────────────────────────────────────────
# 单步操作
# ──────────────────────────────────────────────
def expand_direction(self, direction: str) -> str:
"""仅扩展方向描述(不提取属性、不生成图像)。"""
return self._expander.expand(direction)
def extract_attributes(self, description: str, name: str = '') -> dict:
"""仅提取结构化属性(不生成图像、不注册)。"""
return self._extractor.extract(description, name=name)
# ──────────────────────────────────────────────
# 内部:批量生成变体
# ──────────────────────────────────────────────
def _generate_all(
self,
attrs: dict,
lang: str = 'zh',
seed: int = 0,
force: bool = False,
) -> dict:
"""调用 VariantGenerator 生成基准照 + 多角度 + 表情 + 网格。
每个步骤独立 try/except,失败时记录 warning 并继续。
保证不会因为单步失败而中断整个流程。
Args:
attrs: 结构化属性 dict
lang: 语言
seed: 固定种子
force: 强制重新生成
Returns:
{
"baseline": dict or {"error": str},
"angles": dict or {"error": str},
"expressions": dict or {"error": str},
"grid": dict or {"error": str},
}
"""
logger.info(f'[5/5] 生成变体 (seed={seed if seed else "随机"})')
result = {}
# 1/4: 正面基准肖像
try:
logger.info(' 1/4: 正面基准肖像...')
result['baseline'] = self._generator.generate_baseline_portrait(
attrs, lang, force, seed
)
except Exception as e:
logger.warning(f' ⚠ 基准照生成失败: {e}')
result['baseline'] = {'error': str(e)}
# 2/4: 多角度全身
try:
logger.info(' 2/4: 多角度全身...')
result['angles'] = self._generator.generate_all_angles(
attrs, lang, force, seed
)
except Exception as e:
logger.warning(f' ⚠ 多角度生成失败: {e}')
result['angles'] = {'error': str(e)}
# 3/4: 表情变体
try:
logger.info(' 3/4: 表情变体...')
result['expressions'] = self._generator.generate_all_expressions(
attrs, lang, force, seed
)
except Exception as e:
logger.warning(f' ⚠ 表情变体生成失败: {e}')
result['expressions'] = {'error': str(e)}
# 4/4: 网格定妆照
try:
mode = attrs.get('sheet_mode', 'standard')
logger.info(' 4/4: 网格定妆照...')
result['grid'] = self._generator.generate_grid_sheet(
attrs, mode, lang, force, seed
)
except Exception as e:
logger.warning(f' ⚠ 网格定妆照生成失败: {e}')
result['grid'] = {'error': str(e)}
return result
# ──────────────────────────────────────────────
# 内部:从方向/描述生成角色名
# ──────────────────────────────────────────────
def _generate_name(self, direction: str, description: str) -> str:
"""从方向/描述中提取角色名。
策略:
1. 在 direction 中查找 "XX岁的中/老/青年男性/女性" 模式
2. 若未命中,查找 "[角色名]男/女" 模式
3. 若仍未命中,取 direction 前 3 字
4. 追加当前时间戳(%H%M)避免重名
"""
import re
m = re.search(r'(\d+岁)?[的]?([\u4e00-\u9fff]{2,4}(?:男性|女性|男生|女生|少年|少女|中年|老人))', direction)
if not m:
m = re.search(r'([\u4e00-\u9fff]{2,6})[男女人]', direction)
if m:
base = m.group(1)
base = base.replace('的', '').replace('男性', '').replace('女性', '')
name = base if len(base) <= 3 else base[:-3]
else:
name = direction[:3]
from datetime import datetime
timestamp = datetime.now().strftime('%H%M')
return f'{name}_{timestamp}'
if __name__ == '__main__':
pipeline = CharacterGenerationPipeline()
print('\n=== 测试1: 方向扩展 ===')
direction = '一个运动型中年男性,喜欢户外跑步和健身'
desc = pipeline.expand_direction(direction)
print(f'方向: {direction}')
print(f'扩展: {desc}')
print('\n=== 测试2: 属性提取 ===')
attrs = pipeline.extract_attributes(desc, name='test_char')
print(f'外观: {attrs.get("appearance", "N/A")}')
print(f'面部: {attrs.get("face", "N/A")}')
print(f'服装: {attrs.get("clothing", "N/A")}')
print(f'配件: {attrs.get("accessories", "N/A")}')
print(f'性格: {attrs.get("personality", "N/A")}')
@@ -0,0 +1,370 @@
"""
character_generator/portrait_set.py — 短剧角色参考图**套装**(5 视角 · 半身)
## 为什么需要
短剧角色锁定需要「一套 5 张同人多视角」参考图。两种做法对比:
| | 一次生成(网格图) | **分开生成(本模块)** |
|---|---|---|
| 分辨率 | 每格仅整图的 1/6(≈416px,五官糊) | **每张全分辨率** ✓ |
| 一致性 | 格间互相干扰(串表情/服装) | **以主定妆为参考图 → 五官锁定** ✓ |
| 可控性 | 一格崩了整张重来 | 单张可重生成 ✓ |
| 成本 | 1 张 | 5 张(≈¥1) |
→ **短剧级角色卡必须分开生成**。
## 核心流程(关键技巧)
```
① 生成【正面半身主定妆】
② 用户确认满意
③ 以主定妆图作为**参考图**(I2I),逐张生成其余 4 个角度
④ 5 张全部入库为角色参考图
```
「把主定妆当参考图」是五官不崩的关键 —— 纯文生图跨角度时模型会重新"想象"脸。
## 5 个视角(源自短剧角色卡通用规范)
1. front_half 正面半身 · 主定妆(直视镜头)← 基准
2. side_45 45° 侧半身(脸微转向镜头)
3. profile 纯侧面轮廓(不看镜头)
4. face_closeup 面部特写(低头再抬眼看向镜头)
5. shoulder_look 回头斜侧半身(背部 45°,头回转)
"""
from pathlib import Path
# ── 视角定义(半身构图;短剧主要拍上半身,比全身更实用)──
# ── 画风层(写实 —— 源自 shuohao 实测:不写 photorealistic,会往 CG 带)──
LOOK_REALISM = (
"A real photograph, not a render. Full-frame camera, 85mm lens at f/5.6, "
"true-to-life white balance, unretouched, no beauty filter, slight film grain. "
"Soft directional key light with gentle fill; seamless cool-grey backdrop "
"with nothing else in frame."
)
# ── 短一致性清单(派生图用;**不要**把锚点的长描述整段重复进来)──
def _detect_pronoun(character_desc: str) -> tuple:
"""按角色描述自动判断英文人称(he/his · she/her · they/their)
⚠️ bug 教训(2026-10-04):构图指令原先把人称写死成 she/her,
用户生成「男医生」时 desc 正确、但英文指令里的 she 把模型带成了女性。
"""
d = str(character_desc or "")
male = any(k in d for k in ("男", "先生", "父亲", "爸爸", "哥哥", "弟弟", "叔叔", "大爷",
"male", " man", "boy", "gentleman"))
female = any(k in d for k in ("女", "小姐", "女士", "母亲", "妈妈", "姐姐", "妹妹", "阿姨",
"female", "woman", "girl", "lady"))
if male and not female:
return "he", "his", "him"
if female and not male:
return "she", "her", "her"
return "they", "their", "them"
_CONSISTENCY = ("Keep {his} exactly the same person as in the reference image: "
"same face, same hairstyle and hair color, same top garment.")
# ── 通用负面词(5 张共用;含防侧身 / 防多视图 / 压 AI 感)──
PORTRAIT_NEGATIVE_ZH = (
# 防误侧身(实测:不写几何又不禁这些,正面会画成微侧身 20~30°)
"three-quarter view, turned body, contrapposto, "
# 防多视图(character sheet / turnaround 会让模型在一张图里画多个视图)
"character sheet, multiple views, turnaround, model sheet, "
# 压 AI 感(写实)
"CGI, plastic skin, waxy skin, doll face, over-symmetry, HDR, "
"oversaturated, flat lighting, photorealistic, "
# 通用质量
"扭曲五官,变形,多手指,畸形,模糊,水印,文字,杂乱背景,多余物体,"
"夸张滤镜,崩坏,丑化,面部变色,肢体崩坏"
)
# ── 视角定义 ──
# ★ 关键规范(shuohao-skills 实测):
# 1. **锚点写完整角色描述;派生图只写「构图指令 + 短清单 + 画风层」**
# 2. **改图模型只听第一句** —— `shot` 必须是明确的构图改动指令
# 3. 正面必须写**几何**(肩正对 / 鼻子在中线 / 两耳可见),否则会侧身
# 4. 一致性清单**按视图取层**(大头照只写脸/头发/上装 —— 写下装会把镜头拉远)
PORTRAIT_VIEWS: dict = {
"front_half": {
"label": "正面半身(主定妆)",
"order": 1,
"is_anchor": True,
"shot": ("Half-body front view: {he} faces the camera squarely, shoulders square to the lens, "
"nose on the centerline of the face, both ears equally visible, "
"looking straight into the lens with a neutral expression."),
"consistency": "", # 锚点自带完整描述,无需清单
"default_bg": "seamless cool-grey studio backdrop",
"default_light": "soft even studio lighting",
"default_expr": "neutral expression",
},
"side_45": {
"label": "45° 侧半身",
"order": 2,
"is_anchor": False,
"shot": ("Turn {his} body 45 degrees toward the image-left: shoulders now angled away, "
"the far shoulder partly hidden behind the near one, about three-quarters of "
"{his} face still visible, eyes toward the lens."),
"consistency": _CONSISTENCY,
"default_bg": "seamless cool-grey studio backdrop",
"default_light": "soft even studio lighting",
"default_expr": "calm expression",
},
"profile": {
"label": "纯侧面轮廓",
"order": 3,
"is_anchor": False,
"shot": ("Full side profile: turn {his} body 90 degrees so {he} faces the right edge of the "
"image, only the right half of {his} face is visible, {he} does NOT look at the camera."),
"consistency": _CONSISTENCY,
"default_bg": "seamless cool-grey studio backdrop",
"default_light": "soft even studio lighting, gentle rim on the profile edge",
"default_expr": "quiet, eyes lowered slightly",
},
"face_closeup": {
"label": "面部特写",
"order": 4,
"is_anchor": False,
"shot": ("Zoom in to an extreme close-up head-and-shoulders portrait, passport-photo "
"framing: top of head near the top edge, chin at the vertical center, bottom "
"edge cutting at the collarbone. {He} tilts {his} head slightly down, then lifts "
"{his} eyes into the lens."),
# ★ 大头照只写脸/头发/上装(写下装会把镜头拉远 —— 实测)
"consistency": _CONSISTENCY,
"default_bg": "seamless cool-grey studio backdrop",
"default_light": "soft even studio lighting",
"default_expr": "focused gaze",
},
"shoulder_look": {
"label": "回头斜侧半身",
"order": 5,
"is_anchor": False,
"shot": ("Rotate the camera 180 degrees around {his} so {his} back faces the lens, then {he} "
"turns {his} head back over {his} shoulder to look at the camera: visible are {his} "
"back, the back of {his} hair, the back of {his} garment, plus {his} turned face."),
"consistency": _CONSISTENCY,
"default_bg": "seamless cool-grey studio backdrop",
"default_light": "soft even studio lighting",
"default_expr": "calm expression",
},
# ── 全身(2026-10-04 补:全景 / 远景 / 走位镜头需要全身参考)──
"full_body_front": {
"label": "全身正面",
"order": 6,
"is_anchor": False,
"shot": ("Zoom out to a full-body front view: {his} entire figure from the top of {his} head to "
"the soles of {his} shoes is visible, standing naturally with arms relaxed at {his} "
"sides, feet visible, facing the lens squarely."),
"consistency": _CONSISTENCY,
"default_bg": "seamless cool-grey studio backdrop, no cast shadow on the floor",
"default_light": "soft even studio lighting, no harsh split",
"default_expr": "relaxed neutral stance",
},
"full_body_3q": {
"label": "全身 3/4 侧",
"order": 7,
"is_anchor": False,
"shot": ("Zoom out to a full-body three-quarter view: {his} whole figure from head to shoes is "
"visible, body turned about 30-45 degrees toward the image-right, head still turned "
"back toward the lens side, feet visible."),
"consistency": _CONSISTENCY,
"default_bg": "seamless cool-grey studio backdrop, no cast shadow on the floor",
"default_light": "soft front-side lighting showing the body's form",
"default_expr": "relaxed stance, eyes toward the lens",
},
}
# ── 视角顺序(生成用)──
VIEW_ORDER = sorted(PORTRAIT_VIEWS, key=lambda k: PORTRAIT_VIEWS[k]["order"])
# ════════════════════════════════════════════════════════════
# ★ 按镜头类型选参考图 —— 让套装每一张都真正发挥作用
# ════════════════════════════════════════════════════════════
# 背景(2026-10-04 用户指出):套装生成 5~7 张,但代码只取了 front 一张,
# 其余全闲置。正确做法是按镜头景别/角度选**最贴近**的那张:
# · 侧面镜头喂 profile → 侧脸更准
# · 全景/远景喂全身图 → 有全身比例可依
# · 特写喂 face_closeup → 五官细节最准
# 这样在"可灵只能吃一张图"的限制下,把一致性做到最好。
#
# 用关键词匹配(导演输出的描述很长,如「局部特写(腿部至脚趾)」)。
_SIZE_FULL = ("全景", "远景", "全身", "wide", "full", "long", "establish")
_SIZE_CLOSE = ("特写", "大特写", "close", "extreme", "macro")
_ANGLE_SIDE = ("侧", "45", "三分", "profile", "side", "three-quarter")
_ANGLE_BACK = ("背", "回头", "身后", "behind", "over-the-shoulder", "从后")
def pick_reference_for_shot(shot_size: str = "", angle: str = "", refs: dict = None) -> str:
"""按镜头景别/角度选最合适的**参考图槽位名**
refs: 角色资产的 reference_images(如 {"front": "ref_front_half.png", ...})
返回:槽位名(如 "side_45");无可用时返回 ""(调用方回退文生视频)
优先级:全身 > 特写 > 侧面 > 背面 > 半身正面(默认)
"""
refs = refs or {}
if not refs:
return ""
size = str(shot_size or "")
ang = str(angle or "")
def _pick(*keys) -> str:
for k in keys:
if refs.get(k):
return k
return ""
# ① 全景 / 远景 / 全身 → 全身图(没有则退半身)
if any(k in size for k in _SIZE_FULL):
p = _pick("full_body_front", "full_body_3q", "full", "front")
if p:
return p
# ② 特写 → 面部特写
if any(k in size for k in _SIZE_CLOSE):
p = _pick("face_closeup", "front")
if p:
return p
# ③ 侧面 / 45° → 侧视图
if any(k in ang for k in _ANGLE_SIDE):
p = _pick("side_45", "profile", "front")
if p:
return p
# ④ 背面 / 回头 → 回头斜侧
if any(k in ang for k in _ANGLE_BACK):
p = _pick("shoulder_look", "side_45", "front")
if p:
return p
# ⑤ 默认:半身正面(主定妆)
return _pick("front", "front_half") or next(iter(refs.values()), "")
def build_view_prompt(view: str, character_desc: str, bg: str = "", light: str = "",
expression: str = "", extra: str = "") -> str:
"""拼一条视角提示词(**锚点与派生图结构不同**)
锚点(front_half):
{构图几何} + {完整角色描述} + {画风层}
派生图(其余 4 张):
{构图指令} + {短一致性清单} + {画风层}
—— 只写「要改什么」,不重复角色长描述(shuohao 实测:重复会稀释改动)
规范依据:shuohao-skills/character-refs 的 prompting.md(实测定稿)
"""
v = PORTRAIT_VIEWS.get(view) or PORTRAIT_VIEWS["front_half"]
# ★ 人称按角色描述动态注入(修复"男医生被生成成女性")
he, his, him = _detect_pronoun(character_desc)
subst = {"he": he, "He": he.capitalize(), "his": his, "His": his.capitalize(), "him": him}
def _fill(s: str) -> str:
for k, v2 in subst.items():
s = s.replace("{" + k + "}", v2)
return s
look = (f"Backdrop: {bg.strip()}。" if bg else "") + LOOK_REALISM
if v["is_anchor"]:
# 锚点:构图几何 + 完整角色描述 + 画风
desc = (character_desc or "").strip().strip("。,, ")
parts = [_fill(v["shot"]), desc, look]
else:
# 派生图:构图指令 + 短清单 + 画风(不重复长描述)
parts = [_fill(v["shot"]), _fill(v["consistency"]), look]
if expression:
parts.append(f"Expression: {expression.strip().strip('。,, ')}。")
if extra:
parts.append(extra.strip().strip("。,, ") + "。")
return " ".join(p for p in parts if p)
def build_full_set(character_desc: str, bg: str = "", light: str = "",
expressions: dict = None) -> dict:
"""生成完整 5 视角提示词(不调用生成,纯拼接)
返回 {view_id: {"label", "order", "is_anchor", "prompt"}}
"""
expressions = expressions or {}
out = {}
for vid in VIEW_ORDER:
v = PORTRAIT_VIEWS[vid]
out[vid] = {
"label": v["label"],
"order": v["order"],
"is_anchor": v["is_anchor"],
"prompt": build_view_prompt(vid, character_desc, bg=bg, light=light,
expression=expressions.get(vid, "")),
}
return out
def set_summary() -> str:
"""人类可读的套装说明(供前端展示)"""
lines = ["短剧角色参考图套装(5 视角 · 半身)", ""]
for vid in VIEW_ORDER:
v = PORTRAIT_VIEWS[vid]
mark = "★基准" if v["is_anchor"] else " "
lines.append(f" {v['order']}. {mark} {v['label']:16s} {v['composition']}")
lines += [
"",
"流程:先生成「正面半身主定妆」→ 确认满意 → 以它为参考图逐张生成其余 4 张",
"(把主定妆当参考图是五官不崩的关键)",
"",
f"通用负面词:{PORTRAIT_NEGATIVE_ZH}",
]
return "\n".join(lines)
# ── 预设角色包(config/portrait_presets.yaml)──
def _presets_path() -> Path:
return Path(__file__).resolve().parent.parent.parent / "config" / "portrait_presets.yaml"
def load_presets() -> list:
"""读取预设角色包(6 套短剧人设);文件缺失/损坏时返回空列表(不抛错)"""
p = _presets_path()
if not p.exists():
return []
try:
import yaml
d = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
return d.get("presets") or []
except Exception:
return []
def get_preset(preset_id: str) -> dict:
"""按 id 取预设(未知返回空 dict)"""
for x in load_presets():
if x.get("id") == preset_id:
return x
return {}
def preset_to_set(preset_id: str) -> dict:
"""预设 → 完整 5 视角提示词(含该人设的背景/光/神态)"""
ps = get_preset(preset_id)
if not ps:
return {}
return build_full_set(ps.get("desc", ""), bg=ps.get("bg", ""),
light=ps.get("light", ""),
expressions=ps.get("expressions") or {})
# ── CLI ──
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="短剧角色参考图套装(提示词预览)")
ap.add_argument("--desc", default="24 岁年轻中国女生,黑茶色中长发,面色苍白,浅蓝条纹病号服")
ap.add_argument("--bg", default="")
ap.add_argument("--light", default="")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
if args.json:
import json
print(json.dumps(build_full_set(args.desc, args.bg, args.light), ensure_ascii=False, indent=1))
else:
print(set_summary())
print("\n" + "=" * 72)
for vid, item in build_full_set(args.desc, args.bg, args.light).items():
print(f"\n【{item['order']}. {item['label']}】{'★ 主定妆' if item['is_anchor'] else ''}")
print(f" {item['prompt']}")
@@ -0,0 +1,866 @@
"""
提示词组装器 V3 (PromptAssembler) — 模块化10层角色描述系统
架构: 10层模块,从粗到细逐层构建角色
L0 身份层 — 姓名/性别/年龄/种族/世界观
L1 面部轮廓 — 脸型/头型/下颌线/颧骨
L2 五官细节 — 眼(形/色/神)/眉/鼻/嘴/耳
L3 头发层 — 发型/长度/颜色/质地/发际线
L4 皮肤妆容 — 肤色/肤质/底妆/特殊标记/质感
L5 体型姿态 — 身高/体型/肩宽/站姿/手部
L6 服装配饰 — 上装/下装/外套/鞋/配饰
L7 神态表情 — 微表情/眼神/情绪基调/气质
L8 光线镜头 — 光型/色温/镜头/景深/氛围
L9 背景场景 — 环境/背景色/季节/天气
每个层级包含:
- build_{layer}(data) → 自然语言描述片段
- vocab_{layer} → 该层级的完整词汇/选项
- default_{layer} → 默认值
用法:
from prompt_assembler_v3 import CharacterPromptSystem, CHAR_LAYERS
system = CharacterPromptSystem()
# 逐层构建
system.set_layer("L2_face_eyes", {"shape": "丹凤眼", "color": "深褐", "spirit": "锐利"})
# 生成最终提示词
prompt = system.build_prompt(variant="front_face")
# 获取当前完整描述
desc = system.get_layers_descriptions()
# 获取某层的可用选项
options = system.get_layer_options("L2_face_eyes")
"""
from typing import Optional, Any
import json
# ═══════════════════════════════════════════════════════════
# 第零层: L0 — 核心身份
# ═══════════════════════════════════════════════════════════
L0_IDENTITY = {
"label": "核心身份",
"icon": "🆔",
"order": 0,
"fields": [
{"key": "name", "label": "角色名", "type": "text", "placeholder": "如: 阿远"},
{"key": "gender", "label": "性别", "type": "select", "options": ["男性", "女性"]},
{"key": "age", "label": "年龄", "type": "select", "options": ["少年(12-18)", "青年(18-30)", "壮年(30-45)", "中年(45-60)", "老年(60+)"]},
{"key": "ethnicity", "label": "种族/地域", "type": "select", "options": ["中国", "东亚", "东南亚", "南亚", "中东", "欧美", "非洲", "拉美"]},
{"key": "worldview", "label": "世界观/时代", "type": "select", "options": ["现代", "古风", "科幻", "奇幻", "民国", "未来", "赛博朋克"]},
],
"build": lambda d: f"{d.get('name','')},一位{d.get('age','青年').split('(')[0]}{d.get('gender','')},{d.get('ethnicity','')}人,{d.get('worldview','现代')}背景",
}
# ═══════════════════════════════════════════════════════════
# 第一层: L1 — 面部轮廓
# ═══════════════════════════════════════════════════════════
L1_FACE_SHAPE = {
"label": "面部轮廓",
"icon": "🗿",
"order": 1,
"fields": [
{"key": "shape", "label": "脸型", "type": "select",
"options": ["鹅蛋脸", "瓜子脸", "圆脸", "方脸", "国字脸", "长脸", "菱形脸", "心形脸"],
"description": "国字脸/方脸 → 稳重正气;鹅蛋脸 → 温婉;瓜子脸 → 精致秀气"},
{"key": "jaw", "label": "下颌线", "type": "select",
"options": ["柔和模糊", "清晰分明", "棱角突出", "圆润温和"]},
{"key": "cheekbone", "label": "颧骨", "type": "select",
"options": ["适中自然", "高突出", "饱满圆润", "低平"]},
{"key": "forehead", "label": "额头", "type": "select",
"options": ["饱满宽阔", "适中", "窄小", "高额头"]},
],
"build": lambda d: (
f"{d.get('shape','')}"
f"{',' + d.get('jaw','') if d.get('jaw') else ''}"
f"{',' + d.get('cheekbone','') + '颧骨' if d.get('cheekbone') else ''}"
f"{',' + d.get('forehead','') + '额头' if d.get('forehead') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第二层: L2 — 五官细节
# ═══════════════════════════════════════════════════════════
L2_FACIAL_FEATURES = {
"label": "五官细节",
"icon": "👁️",
"order": 2,
"sub_layers": {
"eyes": {
"label": "眼睛",
"icon": "👁️",
"fields": [
{"key": "shape", "label": "眼型", "type": "select",
"options": ["丹凤眼", "杏眼", "桃花眼", "圆眼", "细长眼", "下垂眼", "上挑眼", "单眼皮", "双眼皮", "内双"],
"description": "丹凤眼 → 古典东方美;杏眼 → 圆润可爱;桃花眼 → 含情脉脉"},
{"key": "color", "label": "瞳孔颜色", "type": "select",
"options": ["深褐", "浅褐", "黑色", "琥珀色", "灰色", "蓝色", "绿色"]},
{"key": "size", "label": "大小", "type": "select",
"options": ["大而有神", "适中", "细长", "小而有神"]},
{"key": "spirit", "label": "眼神", "type": "text",
"placeholder": "如: 深邃锐利、清澈温柔、沉稳内敛", "multi": True},
{"key": "detail", "label": "细节补充", "type": "text",
"placeholder": "如: 眼尾微垂、卧蚕明显、睫毛浓密"},
],
"build": lambda d: (
f"{d.get('size','')}{d.get('shape','')}"
f"{'(' + d.get('color','') + '色瞳孔' + ')' if d.get('color') else ''}"
f"{',' + d.get('spirit','') if d.get('spirit') else ''}"
f"{',' + d.get('detail','') if d.get('detail') else ''}"
),
},
"eyebrows": {
"label": "眉毛",
"icon": "🧐",
"fields": [
{"key": "shape", "label": "眉型", "type": "select",
"options": ["剑眉", "柳叶眉", "一字眉", "上挑眉", "弯眉", "粗眉", "细长眉"]},
{"key": "state", "label": "状态", "type": "select",
"options": ["舒展自然", "微微蹙起", "高挑", "平缓"]},
{"key": "color", "label": "眉色", "type": "select",
"options": ["浓黑", "深棕", "浅棕", "自然黑"]},
],
"build": lambda d: (
f"{d.get('shape','')}"
f"{',' + d.get('color','') if d.get('color') else ''}"
f"{',' + d.get('state','') if d.get('state') else ''}"
),
},
"nose": {
"label": "鼻子",
"icon": "👃",
"fields": [
{"key": "bridge", "label": "鼻梁", "type": "select",
"options": ["高挺", "挺直窄小", "中等挺拔", "低平柔和"]},
{"key": "tip", "label": "鼻头", "type": "select",
"options": ["小巧圆润", "圆润饱满", "尖翘", "略宽"]},
{"key": "wing", "label": "鼻翼", "type": "select",
"options": ["适中", "略宽", "窄小"]},
],
"build": lambda d: (
f"鼻梁{d.get('bridge','')}"
f"{',鼻头' + d.get('tip','') if d.get('tip') else ''}"
f"{',鼻翼' + d.get('wing','') if d.get('wing') else ''}"
),
},
"lips": {
"label": "嘴唇",
"icon": "👄",
"fields": [
{"key": "shape", "label": "唇型", "type": "select",
"options": ["薄唇", "厚唇", "M形唇", "饱满", "樱桃小嘴", "微笑唇"]},
{"key": "color", "label": "唇色", "type": "select",
"options": ["自然淡粉", "健康红润", "偏苍白", "暗紫红", "裸色"]},
{"key": "state", "label": "状态", "type": "select",
"options": ["自然闭合", "微张", "紧抿", "嘴角微扬", "含笑意"]},
],
"build": lambda d: (
f"{d.get('shape','')}"
f"{'(' + d.get('color','') + ')' if d.get('color') else ''}"
f"{',' + d.get('state','') if d.get('state') else ''}"
),
},
},
"build": lambda d: "", # 由子层分别构建
}
# ═══════════════════════════════════════════════════════════
# 第三层: L3 — 头发
# ═══════════════════════════════════════════════════════════
L3_HAIR = {
"label": "头发",
"icon": "💇",
"order": 3,
"fields": [
{"key": "style", "label": "发型", "type": "select",
"options": ["短发", "寸头", "中长发", "长发", "背头", "三七分", "齐刘海", "斜刘海",
"大背头", "碎发", "丸子头", "高马尾", "低马尾", "披肩发", "盘发"]},
{"key": "color", "label": "发色", "type": "select",
"options": ["黑色", "深棕", "浅棕", "亚麻色", "灰色", "银白", "渐变挑染"]},
{"key": "texture", "label": "发质", "type": "select",
"options": ["柔顺直发", "自然微卷", "波浪卷", "小卷", "粗硬", "细软", "蓬松"]},
{"key": "length", "label": "长度", "type": "select",
"options": ["板寸(3mm)", "短(3-6cm)", "及耳", "及肩", "及胸", "及腰", "过腰"]},
{"key": "detail", "label": "细节补充", "type": "text",
"placeholder": "如: 两侧收短顶部略长、发际线略高"},
],
"build": lambda d: (
f"{d.get('texture','')}{d.get('style','')}"
f"{'(' + d.get('color','') + ')' if d.get('color') else ''}"
f"{',' + d.get('length','') if d.get('length') else ''}"
f"{',' + d.get('detail','') if d.get('detail') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第四层: L4 — 皮肤与妆容
# ═══════════════════════════════════════════════════════════
L4_SKIN_MAKEUP = {
"label": "皮肤与妆容",
"icon": "✨",
"order": 4,
"fields": [
{"key": "skin_tone", "label": "肤色", "type": "select",
"options": ["冷白皮", "暖白皮", "自然偏白", "健康小麦", "蜜色", "古铜色", "深色"],
"description": "冷白→清冷;暖白→温润;小麦→阳光健康"},
{"key": "skin_texture", "label": "肤质", "type": "select",
"options": ["细腻光滑", "自然肌理", "毛孔可见", "粗糙", "哑光", "水润光泽"],
"description": "人像特写建议 '细腻光滑'+'自然肌理' 组合使用"},
{"key": "complexion", "label": "气色", "type": "select",
"options": ["红润健康", "略显苍白", "疲惫暗沉", "容光焕发", "自然均匀"]},
{"key": "makeup_base", "label": "底妆", "type": "select",
"options": ["素颜", "淡妆", "精致妆容", "烟熏妆", "裸妆", "浓妆"]},
{"key": "makeup_lip", "label": "唇妆", "type": "select",
"options": ["自然唇色", "淡裸色", "豆沙色", "正红色", "暗紫色", "水光唇"]},
{"key": "makeup_eye", "label": "眼妆", "type": "select",
"options": ["无眼妆", "自然眼线", "淡眼影", "烟熏", "猫眼眼线"]},
{"key": "blemish", "label": "特殊标记", "type": "text",
"placeholder": "如: 右眉上细疤、左颊美人痣、法令纹明显、笑纹"},
],
"build": lambda d: (
f"{d.get('skin_tone','')}"
f"{',' + d.get('skin_texture','') if d.get('skin_texture') else ''}"
f"{',' + d.get('complexion','') if d.get('complexion') else ''}"
f"{',' + d.get('makeup_base','') if d.get('makeup_base') else ''}"
f"{'(' + d.get('makeup_eye','') + ',' + d.get('makeup_lip','') + ')' if d.get('makeup_eye') or d.get('makeup_lip') else ''}"
f"{',' + d.get('blemish','') if d.get('blemish') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第五层: L5 — 体型与姿态
# ═══════════════════════════════════════════════════════════
L5_BODY = {
"label": "体型与姿态",
"icon": "🏋️",
"order": 5,
"fields": [
{"key": "height", "label": "身高", "type": "select",
"options": ["矮小(155-165cm)", "中等(165-175cm)", "中高(175-185cm)", "高大(185cm+)"]},
{"key": "build", "label": "体型", "type": "select",
"options": ["纤细瘦弱", "偏瘦", "标准匀称", "健壮", "壮硕", "丰满", "魁梧"]},
{"key": "shoulder", "label": "肩宽", "type": "select",
"options": ["窄肩", "标准", "宽肩", "厚肩"]},
{"key": "posture", "label": "气质姿态", "type": "select",
"options": ["挺拔", "微驼", "自然放松", "端庄", "慵懒", "紧绷"]},
{"key": "hands", "label": "手部", "type": "text",
"placeholder": "如: 手指修长、关节分明、指甲修剪整齐"},
],
"build": lambda d: (
f"身高{d.get('height','中等').split('(')[0]}"
f"体型{d.get('build','')}"
f"{',' + d.get('shoulder','') if d.get('shoulder') else ''}"
f"{',姿态' + d.get('posture','') if d.get('posture') else ''}"
f"{',' + d.get('hands','') if d.get('hands') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第六层: L6 — 服装与配饰
# ═══════════════════════════════════════════════════════════
L6_CLOTHING = {
"label": "服装与配饰",
"icon": "👔",
"order": 6,
"fields": [
{"key": "top", "label": "上装", "type": "text",
"placeholder": "如: 深灰色拉链运动夹克"},
{"key": "inner", "label": "内搭", "type": "text",
"placeholder": "如: 白色圆领速干内衬"},
{"key": "bottom", "label": "下装", "type": "text",
"placeholder": "如: 深蓝色牛仔裤、黑色运动短裤"},
{"key": "outerwear", "label": "外套", "type": "text",
"placeholder": "如: 深藏蓝亚麻外套"},
{"key": "shoes", "label": "鞋子", "type": "text",
"placeholder": "如: 白色跑步鞋"},
{"key": "accessories", "label": "配饰", "type": "text", "multi": True,
"placeholder": "如: 蓝色无线运动耳机(右耳)、细框金属眼镜、简约电子手表、运动护腕"},
{"key": "style_tag", "label": "穿搭风格", "type": "select",
"options": ["运动休闲", "商务正装", "商务休闲", "街头潮流", "日系清新", "简约素雅", "军事工装", "复古"]},
],
"build": lambda d: (
f"身穿{d.get('top','')}"
f"{',内搭' + d.get('inner','') if d.get('inner') else ''}"
f"{',下穿' + d.get('bottom','') if d.get('bottom') else ''}"
f"{',外披' + d.get('outerwear','') if d.get('outerwear') else ''}"
f"{',脚蹬' + d.get('shoes','') if d.get('shoes') else ''}"
f"{',佩戴' + d.get('accessories','') if d.get('accessories') else ''}"
f"{',' + d.get('style_tag','') + '风格' if d.get('style_tag') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第七层: L7 — 神态与表情
# ═══════════════════════════════════════════════════════════
L7_EXPRESSION = {
"label": "神态与表情",
"icon": "😊",
"order": 7,
"fields": [
{"key": "base_mood", "label": "情绪基调", "type": "select",
"options": ["平静从容", "温和友善", "冷峻严肃", "开朗活泼", "忧郁内敛", "自信昂扬", "恬静淡然"]},
{"key": "expression", "label": "表面表情", "type": "select",
"options": ["中性无表情", "淡淡微笑", "开怀大笑", "微笑", "皱眉沉思", "瞪大眼惊讶", "绷着脸"]},
{"key": "eye_spirit", "label": "眼神细节", "type": "text",
"placeholder": "如: 目光坚定直视前方、眼神游离若有所思、眼中带笑意、目光如炬"},
{"key": "micro_expr", "label": "微表情", "type": "text", "multi": True,
"placeholder": "如: 嘴角微微上翘、眉头轻蹙、眼角细纹、鼻翼轻动"},
{"key": "aura", "label": "气质气场", "type": "text",
"placeholder": "如: 沉稳内敛不怒自威、亲和温暖如沐春风、疏离清冷生人勿近"},
],
"build": lambda d: (
f"神色{d.get('base_mood','')}"
f"{',表情' + d.get('expression','') if d.get('expression') else ''}"
f"{',' + d.get('eye_spirit','') if d.get('eye_spirit') else ''}"
f"{',' + d.get('micro_expr','') if d.get('micro_expr') else ''}"
f"{',整体气质' + d.get('aura','') if d.get('aura') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第八层: L8 — 光线与镜头
# ═══════════════════════════════════════════════════════════
L8_LIGHTING_CAMERA = {
"label": "光线与镜头",
"icon": "📸",
"order": 8,
"fields": [
{"key": "light_type", "label": "光型", "type": "select",
"options": ["柔光箱均匀布光", "美人碟柔光", "伦勃朗光", "分割光", "侧光/塑形光",
"逆光/轮廓光", "阴天漫射光", "顶光/戏剧性"],
"description": "美人碟光 → 人像经典;伦勃朗光 → 立体感;分割光 → 戏剧性"},
{"key": "color_temp", "label": "色温", "type": "select",
"options": ["暖色调(金色)", "冷色调(青蓝)", "中性白平衡", "混合色温", "自然日光"]},
{"key": "lens", "label": "镜头焦距", "type": "select",
"options": ["广角(24mm)", "标准(50mm)", "中焦(85mm)", "长焦(135mm)", "微距"]},
{"key": "depth", "label": "景深", "type": "select",
"options": ["浅景深/背景虚化", "中等景深", "大景深/全清晰"]},
{"key": "light_detail", "label": "光线细节", "type": "text",
"placeholder": "如: 光线从右侧45°打来,在左脸形成柔和阴影"},
],
"build": lambda d: (
f"{d.get('light_type','柔光箱均匀布光')}"
f"{',' + d.get('color_temp','') if d.get('color_temp') else ''}"
f"{',' + d.get('lens','') + '镜头' if d.get('lens') else ''}"
f"{',' + d.get('depth','') if d.get('depth') else ''}"
f"{',' + d.get('light_detail','') if d.get('light_detail') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第九层: L9 — 背景与场景
# ═══════════════════════════════════════════════════════════
L9_BACKGROUND = {
"label": "背景与场景",
"icon": "🌄",
"order": 9,
"fields": [
{"key": "bg_type", "label": "背景类型", "type": "select",
"options": ["纯色背景", "影棚布景", "自然户外", "城市街景", "室内空间", "特定场景"]},
{"key": "bg_color", "label": "背景色/氛围", "type": "select",
"options": ["纯白/干净", "纯灰/专业", "纯黑/神秘", "暖色/温馨", "冷色/冷静", "渐变色"]},
{"key": "environment", "label": "具体环境", "type": "text",
"placeholder": "如: 清晨城市街道、江南古巷青砖墙、健身房器械区"},
{"key": "season", "label": "季节/天气", "type": "select",
"options": ["晴天", "阴天", "雨后", "雪天", "黄昏", "深夜", "清晨", "午后"]},
],
"build": lambda d: (
f"{d.get('environment','')}"
f"{',' + d.get('bg_color','') if d.get('bg_color') else ''}"
f"{',' + d.get('season','') if d.get('season') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 层级注册表
# ═══════════════════════════════════════════════════════════
CHAR_LAYERS = {
"L0_identity": L0_IDENTITY,
"L1_face_shape": L1_FACE_SHAPE,
"L2_facial_features": L2_FACIAL_FEATURES,
"L3_hair": L3_HAIR,
"L4_skin_makeup": L4_SKIN_MAKEUP,
"L5_body": L5_BODY,
"L6_clothing": L6_CLOTHING,
"L7_expression": L7_EXPRESSION,
"L8_lighting_camera": L8_LIGHTING_CAMERA,
"L9_background": L9_BACKGROUND,
}
# 各子层(L2的各部位)
SUB_LAYERS = {
"L2_face_eyes": L2_FACIAL_FEATURES["sub_layers"]["eyes"],
"L2_face_eyebrows": L2_FACIAL_FEATURES["sub_layers"]["eyebrows"],
"L2_face_nose": L2_FACIAL_FEATURES["sub_layers"]["nose"],
"L2_face_lips": L2_FACIAL_FEATURES["sub_layers"]["lips"],
}
# 所有可配置项合并
ALL_LAYERS = {**CHAR_LAYERS, **SUB_LAYERS}
# ═══════════════════════════════════════════════════════════
# 变体提示词模板
# ═══════════════════════════════════════════════════════════
# ── 正面面部特写(定妆照基准) ──
FRONT_FACE_TEMPLATE_ZH = (
"纯色无缝冷灰摄影棚背景,背景上除了人物什么都没有(无墙、无家具、无道具、无窗、无植物、无风景、无地面阴影)。"
"正面胸像定妆照,面向相机,中性自然表情。" +
"{L0}。{L1}。{eyes}。{eyebrows}。{nose}。{lips}。{L3}。{L4}。{L5}。{L6}。{L7}。{L8}。"
"摄影棚均匀柔光,真实皮肤质感,中性素净不加调色。比例9:16。"
)
FRONT_FACE_TEMPLATE_EN = (
"{L0}. {L1}. {eyes}. {eyebrows}. {nose}. {lips}. {L3}. {L4}. {L5}. {L6}. {L7}. {L8}. {L9}. "
"Professional portrait photography, front-facing camera, neutral expression. "
"Commercial photography quality, 8k resolution, photorealistic style, high detail. --ar 9:16"
)
# ── 全身照 ──
FULL_BODY_TEMPLATE_ZH = (
"纯色无缝冷灰摄影棚背景,背景上除了人物什么都没有(无墙、无家具、无道具、无窗、无植物、无风景、无地面阴影)。"
"全身站立定妆照,头顶到脚底完整入镜,人物居中。" +
"{L0}。{L1}。{eyes}。{L3}。{L4}。{L5}。{L6}。{L7}。"
"摄影棚均匀柔光,真实摄影质感,中性素净。比例9:16。"
)
# ── 网格定妆照 ──
# ── 全身各视角模板(★ 修复:原来 multi_angle 全部错用 front_face 模板)──
# 判据:每个视角必须有自己的构图描述,否则模型只会画同一种正面。
_BODY_BG = ("纯色无缝冷灰摄影棚背景,背景上除了人物什么都没有"
"(无墙、无家具、无道具、无窗、无植物、无风景、无地面阴影)。")
_BODY_STYLE = ("Fujifilm X100V 35mm 镜头直出,unfiltered,"
"真实皮肤质感、毛孔可见、不磨皮不塑料感,摄影棚均匀柔光。")
BODY_ANGLE_TEMPLATES = {
"front": "{desc}。全身正面站立定妆照,从头顶到脚底完整入镜(**必须拍到脚**),"
"双臂自然垂于身侧,面向相机。" + _BODY_BG + _BODY_STYLE + "比例9:16。",
"side": "{desc}。全身左侧面定妆照,从头顶到脚底完整入镜(**必须拍到脚**),"
"正左侧 profile 视角,面向画面右侧。" + _BODY_BG + _BODY_STYLE + "比例9:16。",
"right_three_quarter": "{desc}。全身右前 3/4 侧定妆照,从头顶到脚底完整入镜"
"(**必须拍到脚**),身体朝右前方约 45 度。" + _BODY_BG + _BODY_STYLE + "比例9:16。",
"right_side": "{desc}。全身右侧面定妆照,从头顶到脚底完整入镜(**必须拍到脚**),"
"正右侧 profile 视角,面向画面左侧。" + _BODY_BG + _BODY_STYLE + "比例9:16。",
"back": "{desc}。全身背面定妆照,从正后方拍摄,从头顶到脚踝完整入镜"
"(**必须拍到脚**),看不见脸。" + _BODY_BG + _BODY_STYLE + "比例9:16。",
}
GRID_STANDARD_ZH = (
"角色定妆参考表,三格并排(左:胸像正面;中:全身正面;右:全身背面),"
"三格为同一人,五官发型服装体型完全一致。" + "纯色无缝冷灰摄影棚背景,背景上除了人物什么都没有(无墙、无家具、无道具、无窗、无植物、无风景、无地面阴影)。"
"摄影棚均匀柔光,真实皮肤质感,中性素净不加调色。展示:"
"{L0}。{L1}。{eyes}。{L3}。{L6}。{L7}。比例16:9。"
)
GRID_SPORT_ZH = (
"一张写实风格的角色参考定妆照,展示{L0}。{L1}。{eyes}。{L3}。{L5}。{L6}。{L7}。"
"画面为4列×2行共8格的网格布局,格间有细灰线分隔。"
"上一行:全身正面中性站姿、全身侧面站姿、面部正面中性表情特写、面部四分之三左侧表情特写。"
"下一行:面部纯左侧轮廓特写、面部四分之三右侧表情特写、面部专注坚定眼神特写、面部发力表情特写。"
"8格人物为完全相同的同一人(五官/发型/体型高度一致)。"
"纯白无缝背景,无任何背景元素(无家具/道具/窗/户外风景),人物边界清晰可抠图。"
"真实摄影质感,保留皮肤纹理与毛孔,自然柔和光线,非插画非动漫。比例3:2。"
"负向:场景背景、环境元素、道具、窗户、风景、杂乱背景、文字、水印。"
)
# ── 场景变体 ──
SCENE_TEMPLATE_ZH = (
"{L0}。{L1}。{eyes}。{L3}。{L4}。{L5}。{L6}。{L7}。"
"{scene_desc}。{L8}。{L9}。"
"电影级摄影品质,写实风格。比例9:16"
)
# ── 表情变体 ──
EXPRESSION_TEMPLATE_ZH = (
"面部特写肖像,{L0}。{L1}。{eyes}。{eyebrows}。{nose}。{lips}。{L3}。{L4}。{L6}。"
"{expr_desc}。{L8}。"
"专业影室肖像,纯灰色背景。比例9:16"
)
# ── 负面提示词(通用) ──
NEGATIVE_PROMPT = (
"different face, inconsistent features, changed eye color, altered hair, "
"morphed facial structure, deformed face, asymmetric face, "
"extra limbs, bad anatomy, distorted, blurry, low quality, "
"cartoon, anime, illustration, painting, 3D render"
)
NEGATIVE_PROMPT_ZH = (
"面部不一致,五官改变,眼睛颜色变化,发型变化,"
"面部结构扭曲,面部不对称,多余肢体,解剖错误,"
"模糊,低质量,卡通,动漫,插画,3D渲染"
)
# ═══════════════════════════════════════════════════════════
# CharacterPromptSystem — 角色提示词系统
# ═══════════════════════════════════════════════════════════
class CharacterPromptSystem:
"""模块化10层角色提示词系统"""
def __init__(self):
# 每层的数据存储
self._layers = {}
for layer_id in CHAR_LAYERS:
self._layers[layer_id] = {}
for sub_id in SUB_LAYERS:
self._layers[sub_id] = {}
self._scene_override = ""
self._expression_override = ""
# ── 设置某层数据 ──
def set_layer(self, layer_id: str, data: dict):
"""设置某层的数据"""
if layer_id in self._layers:
self._layers[layer_id].update(data)
else:
self._layers[layer_id] = data
def get_layer(self, layer_id: str) -> dict:
"""获取某层的数据"""
return self._layers.get(layer_id, {})
def set_scene(self, scene_desc: str):
"""设置场景描述(用于场景变体)"""
self._scene_override = scene_desc
def set_expression(self, expr_desc: str):
"""设置表情描述(用于表情变体)"""
self._expression_override = expr_desc
# ── 从自然语言描述批量设置 ──
def from_natural_description(self, desc: str):
"""从自然语言描述中尝试提取各层数据(简易版)"""
# 这个功能由 attribute_extractor 完成更准确
# 这里只做兜底
self._layers["L0_identity"]["_raw"] = desc
# ── 构建提示词 ──
def build_prompt(self, variant: str = "front_face", lang: str = "zh",
mode: str = "standard", **kwargs) -> str:
"""
构建完整的生成提示词
参数:
variant: 变体类型 (front_face|full_body|grid|scene|expression)
lang: 语言
mode: 网格模式 (standard|sport)
**kwargs: 额外参数
返回:
完整提示词字符串
"""
# 先构建各层描述
layers_desc = self._build_layers_descriptions()
if variant == "front_face":
return self._build_front_face(layers_desc, lang)
elif variant == "full_body":
return self._build_full_body(layers_desc, lang)
elif variant == "multi_angle":
angle = kwargs.get("angle") or kwargs.get("expression_name") or "front"
return self._build_multi_angle(layers_desc, lang, angle)
elif variant == "grid":
return self._build_grid(layers_desc, lang, mode)
elif variant == "scene":
scene = kwargs.get("scene_desc", self._scene_override)
return self._build_scene(layers_desc, lang, scene)
elif variant == "expression":
expr = kwargs.get("expr_desc", self._expression_override)
return self._build_expression(layers_desc, lang, expr)
else:
return self._build_front_face(layers_desc, lang)
def _build_layers_descriptions(self) -> dict:
"""构建所有层的描述文本"""
result = {}
# L0 身份
result["L0"] = CHAR_LAYERS["L0_identity"]["build"](self._layers.get("L0_identity", {}))
# L1 面部轮廓
result["L1"] = CHAR_LAYERS["L1_face_shape"]["build"](self._layers.get("L1_face_shape", {}))
# L2 五官子层
for sub_id, sub_def in SUB_LAYERS.items():
result[sub_id.replace("L2_face_", "")] = sub_def["build"](self._layers.get(sub_id, {}))
# L3-L9
for layer_id in ["L3_hair", "L4_skin_makeup", "L5_body",
"L6_clothing", "L7_expression", "L8_lighting_camera", "L9_background"]:
key = layer_id.split("_")[0] # L3, L4, etc
result[key] = CHAR_LAYERS[layer_id]["build"](self._layers.get(layer_id, {}))
return result
def _build_front_face(self, d: dict, lang: str) -> str:
"""构建正面面部特写提示词"""
template = FRONT_FACE_TEMPLATE_ZH if lang == "zh" else FRONT_FACE_TEMPLATE_EN
prompt = template.format(
L0=d.get("L0", ""), L1=d.get("L1", ""),
eyes=d.get("eyes", ""), eyebrows=d.get("eyebrows", ""),
nose=d.get("nose", ""), lips=d.get("lips", ""),
L3=d.get("L3", ""), L4=d.get("L4", ""),
L5=d.get("L5", ""), L6=d.get("L6", ""),
L7=d.get("L7", ""), L8=d.get("L8", ""),
L9=d.get("L9", ""),
)
# 清理空片段留下的多余标点
return self._clean_prompt(prompt)
def _build_full_body(self, d: dict, lang: str) -> str:
"""构建全身照提示词"""
prompt = FULL_BODY_TEMPLATE_ZH.format(
L0=d.get("L0", ""), L1=d.get("L1", ""),
eyes=d.get("eyes", ""), L3=d.get("L3", ""),
L4=d.get("L4", ""), L5=d.get("L5", ""),
L6=d.get("L6", ""), L7=d.get("L7", ""),
) if lang == "zh" else ""
return self._clean_prompt(prompt)
def _build_multi_angle(self, d: dict, lang: str, angle: str) -> str:
"""构建**单视角全身**提示词(front / side / right_three_quarter / right_side / back)
★ 修复说明:原来 variant_map 把 multi_angle 映射成 front_face,
导致 5 个角度全部生成"正面特写" —— 用户看到的"同一张脸重复"就是由此而来。
现在每个角度有自己的构图描述(含"必须拍到脚"的全身边界)。
"""
tpl = BODY_ANGLE_TEMPLATES.get(angle) or BODY_ANGLE_TEMPLATES["front"]
# 人物描述(各层拼接)
desc = self._join_desc(d)
prompt = tpl.format(desc=desc)
return self._clean_prompt(prompt)
@staticmethod
def _join_desc(d: dict) -> str:
"""把各层描述拼成一句人物说明(供全身模板的 {desc} 使用)"""
bits = [d.get(k, "") for k in
("L0", "L1", "eyes", "eyebrows", "nose", "lips",
"L3", "L4", "L5", "L6")]
seen, out = set(), []
for b in bits:
b = (b or "").strip().strip("。,, ")
if b and b not in seen:
seen.add(b); out.append(b)
return ",".join(out)
def _build_grid(self, d: dict, lang: str, mode: str) -> str:
"""构建网格定妆照提示词"""
template = GRID_SPORT_ZH if mode == "sport" else GRID_STANDARD_ZH
prompt = template.format(
L0=d.get("L0", ""), L1=d.get("L1", ""),
eyes=d.get("eyes", ""), L3=d.get("L3", ""),
L5=d.get("L5", ""), L6=d.get("L6", ""),
L7=d.get("L7", ""),
)
return self._clean_prompt(prompt)
def _build_scene(self, d: dict, lang: str, scene: str) -> str:
"""构建场景变体提示词"""
scene_desc = scene or "自然场景"
prompt = SCENE_TEMPLATE_ZH.format(
L0=d.get("L0", ""), L1=d.get("L1", ""),
eyes=d.get("eyes", ""), L3=d.get("L3", ""),
L4=d.get("L4", ""), L5=d.get("L5", ""),
L6=d.get("L6", ""), L7=d.get("L7", ""),
scene_desc=scene_desc,
L8=d.get("L8", ""), L9=d.get("L9", ""),
)
return self._clean_prompt(prompt)
def _build_expression(self, d: dict, lang: str, expr: str) -> str:
"""构建表情变体提示词"""
expr_desc = expr or "中性表情"
prompt = EXPRESSION_TEMPLATE_ZH.format(
L0=d.get("L0", ""), L1=d.get("L1", ""),
eyes=d.get("eyes", ""), eyebrows=d.get("eyebrows", ""),
nose=d.get("nose", ""), lips=d.get("lips", ""),
L3=d.get("L3", ""), L4=d.get("L4", ""),
L6=d.get("L6", ""), expr_desc=expr_desc,
L8=d.get("L8", ""),
)
return self._clean_prompt(prompt)
def _clean_prompt(self, prompt: str) -> str:
"""清理提示词中的多余标点和连续空格"""
import re
# 移除连续的逗号/句号
prompt = re.sub(r'[,,]{2,}', ',', prompt)
prompt = re.sub(r'[。.]{2,}', '。', prompt)
# 移除空括号
prompt = re.sub(r'([^)]*?)', '', prompt)
prompt = re.sub(r'\([^)]*?\)', '', prompt)
# 移除开头的Punctuation
prompt = re.sub(r'^[,。、\s]+', '', prompt)
return prompt.strip()
# ── 获取当前完整自然语言描述 ──
def get_full_description(self) -> str:
"""生成完整的自然语言角色描述(用于知识库回写)"""
d = self._build_layers_descriptions()
parts = []
for key in ["L0", "L1", "eyes", "eyebrows", "nose", "lips",
"L3", "L4", "L5", "L6", "L7"]:
val = d.get(key, "")
if val:
parts.append(val)
desc = ",".join(parts)
return self._clean_prompt(desc)
def get_layer_options(self, layer_id: str) -> dict:
"""获取某层的所有可选项"""
layer = ALL_LAYERS.get(layer_id)
if not layer:
return {}
return {
"label": layer.get("label", ""),
"icon": layer.get("icon", ""),
"fields": layer.get("fields", []),
}
def get_all_layers_config(self) -> dict:
"""获取所有层的配置(供前端渲染)"""
return {
layer_id: self.get_layer_options(layer_id)
for layer_id in ALL_LAYERS
}
def get_variant_types(self) -> dict:
"""获取支持的变体类型"""
return {
"front_face": "正面面部特写",
"full_body": "全身照",
"grid": "网格定妆照",
"scene": "场景变体",
"expression": "表情变体",
}
# ═══════════════════════════════════════════════════════════
# 兼容旧接口(包装器)
# ═══════════════════════════════════════════════════════════
class PromptAssembler:
"""兼容旧版 PromptAssembler 接口"""
def __init__(self):
self._system = CharacterPromptSystem()
def assemble(self, attrs: dict, variant_type: str,
lang: str = "zh", **kwargs) -> str:
"""兼容旧版 assemble 方法"""
# 从旧式 attrs 填充系统
if attrs.get("name") or attrs.get("description"):
self._system.set_layer("L0_identity", {
"name": attrs.get("name", ""),
"gender": "男性" if "男" in attrs.get("description", "") else "女性",
})
face = attrs.get("face", {})
if face:
self._system.set_layer("L2_face_eyes", {
"shape": face.get("eyes", ""),
"spirit": "",
})
self._system.set_layer("L2_face_nose", {
"bridge": face.get("nose", ""),
})
self._system.set_layer("L2_face_lips", {
"shape": face.get("lips", ""),
})
if face.get("hair"):
self._system.set_layer("L3_hair", {"style": face["hair"]})
if face.get("skin"):
self._system.set_layer("L4_skin_makeup", {"skin_tone": face["skin"]})
clothing = attrs.get("clothing", {})
if clothing:
self._system.set_layer("L6_clothing", {
"top": clothing.get("top", ""),
"inner": clothing.get("inner", ""),
})
personality = attrs.get("personality", "")
if personality:
self._system.set_layer("L7_expression", {"base_mood": personality})
# 映射变体类型
variant_map = {
"front_face": "front_face",
"full_body": "full_body",
"multi_angle": "multi_angle", # ★ 修复:原来错映射成 front_face(5 个角度全画正面)
"expression": "expression",
"scene": "scene",
"grid": "grid",
"sport_action": "scene",
}
mapped_variant = variant_map.get(variant_type, "front_face")
mode = kwargs.get("mode", "standard")
expr = kwargs.get("expression_name", "")
scene = kwargs.get("scene_name", "")
if expr:
self._system.set_expression(expr)
if scene:
self._system.set_scene(scene)
return self._system.build_prompt(mapped_variant, lang, mode=mode)
def get_available_variants(self) -> dict:
"""兼容旧接口"""
return self._system.get_variant_types()
if __name__ == "__main__":
# 测试
system = CharacterPromptSystem()
# 逐层设置
system.set_layer("L0_identity", {"name": "阿远", "gender": "男性", "age": "青年(18-30)", "ethnicity": "中国"})
system.set_layer("L1_face_shape", {"shape": "国字脸", "jaw": "清晰分明"})
system.set_layer("L2_face_eyes", {"shape": "单眼皮", "color": "深褐", "spirit": "锐利深邃"})
system.set_layer("L2_face_eyebrows", {"shape": "剑眉", "state": "舒展自然"})
system.set_layer("L2_face_nose", {"bridge": "高挺", "tip": "圆润饱满"})
system.set_layer("L2_face_lips", {"shape": "微笑唇", "state": "嘴角微扬"})
system.set_layer("L3_hair", {"style": "短发", "color": "黑色", "detail": "两侧收短顶部略长"})
system.set_layer("L4_skin_makeup", {"skin_tone": "健康小麦", "skin_texture": "自然肌理"})
system.set_layer("L5_body", {"build": "健壮", "shoulder": "宽肩", "posture": "挺拔"})
system.set_layer("L6_clothing", {"top": "深灰色拉链运动夹克", "inner": "白色速干内衬", "accessories": "蓝色无线运动耳机(右耳)"})
system.set_layer("L7_expression", {"base_mood": "沉稳内敛", "eye_spirit": "目光坚定直视前方"})
system.set_layer("L8_lighting_camera", {"light_type": "柔光箱均匀布光", "color_temp": "暖色调(金色)"})
system.set_layer("L9_background", {"bg_type": "纯色背景", "bg_color": "纯灰/专业"})
print("=== 正面面部特写 ===")
print(system.build_prompt("front_face"))
print("\n=== 网格定妆照 ===")
print(system.build_prompt("grid", mode="standard"))
print("\n=== 完整描述 ===")
print(system.get_full_description())
print("\n=== 前端配置数据 ===")
config = system.get_all_layers_config()
for layer_id, cfg in config.items():
print(f" {layer_id}: {cfg['icon']} {cfg['label']} ({len(cfg.get('fields',[]))} 字段)")
@@ -0,0 +1,89 @@
# 角色定妆照提示词库
### 夺
- **描述**: 奇才
- **面部**: 脸型=, 眼睛=, 发型=, 肤色=
- **体型**: 体态=, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
### 测试
- **描述**: 测试角色描述
- **面部**: 脸型=, 眼睛=, 发型=, 肤色=
- **体型**: 体态=, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
### 测试角色
- **描述**: 测试方向描述
- **面部**: 脸型=, 眼睛=, 发型=, 肤色=
- **体型**: 体态=, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
### 测试角色2
- **描述**: 测试方向描述
- **面部**: 脸型=, 眼睛=, 发型=, 肤色=
- **体型**: 体态=, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
### 中国
- **描述**: 【身份】女性、青年、中国、张三
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景
- **面部**: 脸型=, 眼睛=, 发型=【身份】女性、青年、中国、张三
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景, 肤色=健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景
- **体型**: 体态=标准, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
### 台湾
- **描述**: 【身份】青年、中国、少女
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景
- **面部**: 脸型=, 眼睛=, 发型=【身份】青年、中国、少女
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景, 肤色=健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景
- **体型**: 体态=标准, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
### test
- **描述**: 【身份】男性
【面部】标准
- **面部**: 脸型=, 眼睛=, 发型=, 肤色=
- **体型**: 体态=标准, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
@@ -0,0 +1,361 @@
"""
character_generator/quality_check.py — 角色参考图「生成后质检」
## 为什么需要
实测发现:角色参考图若带场景背景,图生视频时模型会把背景一起"绑定"到角色上
→ 生成的画面环境错乱、跨镜不一致。**参考图必须是干净背景的定妆照**。
实测(2026-10-03,用户角色库):
害羞美女患者 边缘标准差 47.1 ❌ 有场景
麻醉医生_帅气男 边缘标准差 52.3 ❌ 有场景
找回的2张生成图 58.4 / 51.2 ❌ 有场景
中国 16.8 较干净
## 判据
取图片**四条边缘**的像素,统计颜色标准差:
· 纯色背景 → 边缘高度一致 → 标准差小(< 12)
· 带场景 → 边缘颜色杂乱 → 标准差大(> 25)
无需 AI 模型,纯本地计算(PIL),零成本。
## 用法
from character_generator.quality_check import check_reference_quality
r = check_reference_quality("/path/to/ref.png")
# {"ok": bool, "score": 0-100, "edge_std": float, "issues": [...], "suggestion": str}
"""
from pathlib import Path
try:
from PIL import Image, ImageStat
except ImportError: # pragma: no cover
Image = None
# 阈值(实测校准)
EDGE_CLEAN = 12.0 # 标准差 < 12 → 干净(纯色背景)
EDGE_ACCEPT = 25.0 # < 25 → 可接受;≥ 25 → 判定有场景
MIN_SIDE = 512 # 短边最小像素(定妆照清晰度下限)
def check_reference_quality(path: str) -> dict:
"""检查角色参考图是否合格(背景是否干净 + 分辨率)
返回:
ok bool 是否合格
score int 0-100(越高越干净)
edge_std float 边缘颜色标准差(越小越干净)
width/height int
issues list[str]
suggestion str 不合格时的修改建议
"""
p = Path(path)
if not p.exists():
return {"ok": False, "score": 0, "edge_std": -1, "issues": [f"文件不存在: {path}"],
"suggestion": "重新生成或重新上传参考图"}
if Image is None:
return {"ok": True, "score": 60, "edge_std": -1,
"issues": ["未安装 Pillow,跳过背景检测(pip install pillow)"],
"suggestion": ""}
try:
im = Image.open(p).convert("RGB")
except Exception as e:
return {"ok": False, "score": 0, "edge_std": -1,
"issues": [f"图片无法解码: {e}"], "suggestion": "重新上传标准 jpg/png"}
w, h = im.size
# ⚠️ 用**四角**采样(而非四条边):
# 半身像/人像的人物常顶到上下边缘,用整边采样必然采到人物 → 误判"背景脏"。
# 四角是纯背景概率最高的区域(实测:竖屏半身像整边 29.2 → 四角 6~12)
band = max(6, min(w, h) // 18)
pad = max(2, band // 6)
corners = [
(pad, pad, pad + band, pad + band), # 左上
(w - pad - band, pad, w - pad, pad + band), # 右上
(pad, h - pad - band, pad + band, h - pad), # 左下
(w - pad - band, h - pad - band, w - pad, h - pad), # 右下
]
stds = []
for box in corners:
try:
st = ImageStat.Stat(im.crop(box))
stds.append(sum(st.stddev) / 3.0)
except Exception:
continue
edge_std = round(sum(stds) / len(stds), 1) if stds else -1.0
# 四角平均色的通道离散度(辅助判断"是否纯色背景")
try:
mean_of_edges = [0.0, 0.0, 0.0]
for box in corners:
m = ImageStat.Stat(im.crop(box)).mean
for i in range(3):
mean_of_edges[i] += m[i] / len(corners)
chan_spread = round(max(mean_of_edges) - min(mean_of_edges), 1)
except Exception:
chan_spread = 0.0
issues: list[str] = []
if min(w, h) < MIN_SIDE:
issues.append(f"分辨率偏低({w}×{h},短边建议 ≥ {MIN_SIDE}px)")
if edge_std >= EDGE_ACCEPT:
issues.append(f"背景不干净(边缘颜色标准差 {edge_std},≥{EDGE_ACCEPT} 判定含场景元素)")
elif edge_std >= EDGE_CLEAN:
issues.append(f"背景不够纯净(边缘标准差 {edge_std},建议 < {EDGE_CLEAN})")
# 打分:边缘越干净分越高
if edge_std < 0:
score = 60
else:
score = int(max(0, min(100, 100 - (edge_std / EDGE_ACCEPT) * 100))) if edge_std < EDGE_ACCEPT \
else int(max(0, 40 - (edge_std - EDGE_ACCEPT)))
ok = not any(i.startswith("背景不干净") for i in issues) and min(w, h) >= MIN_SIDE
suggestion = ""
if not ok:
if edge_std >= EDGE_ACCEPT:
suggestion = (
"参考图带场景背景 → 图生视频时模型会把背景绑定到角色上。"
"建议:用「标准定妆照」模式重新生成(纯白无缝背景 + 多视角网格),"
"或在生成器的「🌄 背景与场景」里把环境/季节清空、背景类型选「纯色背景」。"
)
else:
suggestion = "建议提高分辨率或改用纯色背景的定妆照。"
return {
"ok": ok,
"score": score,
"edge_std": edge_std,
"chan_spread": chan_spread,
"width": w,
"height": h,
"issues": issues,
"suggestion": suggestion,
}
def is_clean_reference(path: str) -> bool:
"""便捷判定:参考图是否干净可用"""
return bool(check_reference_quality(path).get("ok"))
# ════════════════════════════════════════════════════════════
# ★ 参考图「白底清理」(后处理)—— 消除场景背景
# ════════════════════════════════════════════════════════════
# 外网参考(muse-character-sheet / style-consistency-ai):
# "每个姿势都要跑一遍白底清理(ComfyUI-RMBG)" ——
# 后处理抠图换底,比指望模型自己画干净背景**可靠得多**。
#
# 实现:rembg(本地 U2Net)+ 纯色底合成,零 API 成本。
# 模型(~176MB)首次自动下载;不可用时优雅降级(不阻塞流程)。
_BG_COLORS = {
"cool_grey": (132, 138, 145), # 研究结论:冷灰优于纯白(白底会削平高光)
"white": (255, 255, 255),
"light_grey": (225, 227, 230),
}
_rembg_available = None
_rembg_error = ""
def _check_rembg() -> tuple:
"""检查 rembg 是否可用(惰性 + 结果缓存)"""
global _rembg_available, _rembg_error
if _rembg_available is not None:
return _rembg_available, _rembg_error
try:
from rembg import remove # noqa: F401
_rembg_available = True
except Exception as e:
_rembg_available = False
_rembg_error = f"rembg 不可用: {e}(pip install rembg onnxruntime)"
return _rembg_available, _rembg_error
# 模型文件的最小字节数(判断下载是否完整)
_MODEL_REQ = {"u2net": 50_000_000, "isnet-general-use": 40_000_000, "u2netp": 3_000_000}
def pick_rembg_model() -> str:
"""选一个**已就绪**的抠图模型(大模型优先,回退小模型)
· u2net ≈176MB(效果好)
· u2netp ≈4.7MB(快,效果略差)—— 网络受限时先下小的即可用起来
返回 "" 表示没有可用模型(调用方跳过清理)。
"""
home = Path.home() / ".u2net"
for name in ("u2net", "isnet-general-use", "u2netp"):
f = home / f"{name}.onnx"
try:
if f.exists() and f.stat().st_size >= _MODEL_REQ.get(name, 1_000_000):
return name
except Exception:
continue
return ""
def clean_reference_background(src_path: str, dst_path: str = "",
bg: str = "cool_grey", keep_alpha: bool = False) -> dict:
"""把参考图抠出来合成到纯色背景(消除场景背景)
这是**后处理**方案 —— 比指望模型生成干净背景更可靠。
⚠️ 首次调用会下载 U2Net 模型(~176MB),耗时较长;之后走缓存。
参数:
bg cool_grey(推荐)| white | light_grey
keep_alpha 保留透明通道(不合成背景,输出 PNG)
返回:
{ok, output, before, after, improved, skipped, reason}
· skipped=True 表示环境不具备(**不阻塞流程**,调用方直接用原图即可)
"""
ok_rb, err = _check_rembg()
if not ok_rb:
return {"ok": False, "skipped": True, "reason": err, "output": src_path}
model_name = pick_rembg_model()
if not model_name:
return {"ok": False, "skipped": True,
"reason": "抠图模型未就绪(需 u2net.onnx 或 u2netp.onnx 放入 ~/.u2net/)",
"output": src_path, "model": ""}
src = Path(src_path)
if not src.exists():
return {"ok": False, "skipped": True, "reason": "源文件不存在", "output": src_path}
dst = Path(dst_path) if dst_path else src.with_name(f"{src.stem}_clean.png")
before = check_reference_quality(str(src))
try:
from rembg import remove, new_session
from PIL import Image
im = Image.open(src).convert("RGB")
_session = new_session(model_name) # ★ 用选中的模型(u2net / u2netp)
cut = remove(im, session=_session) # → RGBA
if keep_alpha:
cut.save(dst)
else:
color = _BG_COLORS.get(bg) or _BG_COLORS["cool_grey"]
canvas = Image.new("RGB", cut.size, color)
canvas.paste(cut, (0, 0), cut if cut.mode in ("RGBA", "LA") else None)
canvas.save(dst)
after = check_reference_quality(str(dst))
improved = (before.get("edge_std", 999) - after.get("edge_std", 999))
return {
"ok": True, "output": str(dst),
"before": {"edge_std": before.get("edge_std"), "score": before.get("score"),
"ok": before.get("ok")},
"after": {"edge_std": after.get("edge_std"), "score": after.get("score"),
"ok": after.get("ok")},
"improved": round(improved, 1),
}
except Exception as e:
return {"ok": False, "skipped": False, "reason": f"抠图失败: {str(e)[:200]}",
"output": src_path, "before": {"edge_std": before.get("edge_std")}}
# ── CLI:批量体检角色库 ──
if __name__ == "__main__":
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from assets_manager import AssetStore
st = AssetStore("characters")
items = st.list()
print(f"=== 角色参考图质检({len(items)} 个角色)===\n")
bad = 0
for it in items:
cid = it.get("id")
refs = it.get("reference_images") or {}
if not refs:
print(f" ⚠️ {cid:16s} 无参考图")
bad += 1
continue
for slot, fname in refs.items():
p = Path(st.kind_dir(cid)) / Path(str(fname)).name
r = check_reference_quality(str(p))
mark = "✅" if r["ok"] else "❌"
print(f" {mark} {cid:16s} [{slot:8s}] 分 {r['score']:3d} | 边缘标准差 {r['edge_std']:5.1f} | {r['width']}x{r['height']}")
for i in r["issues"]:
print(f" · {i}")
if not r["ok"]:
bad += 1
print(f"\n不合格 {bad} 张 —— 建议重新生成(定妆照模式)")
# ── 网格定妆照专用质检(★ 按格检查)────────────────────────────
# 为什么单写一个:整图边缘检测对**多格图无效** ——
# 格子越多,图片边缘越必然包含相邻格的内容,导致误判为"脏背景"。
# 实测教训(2026-10-03):三格版 14.2 ✅ / 四格版 50.1 ❌,
# 两者提示词质量相近,差异主要来自"格数影响了边缘采样"。
def check_sheet_quality(path: str, grid: tuple = None) -> dict:
"""网格定妆照质检:**逐格检查背景**(每格取四角),而非整图边缘
⚠️ **只适用于真正的网格图**(多格并排)。判据与方法必须配套:
· 单张人像图 → 用 check_reference_quality()(整图边缘)
· 网格图 → 用本函数(按格检查)
两者不可混用来比较不同格式的方案(实测教训,2026-10-03)。
grid: (cols, rows) —— **强烈建议显式传入**;不传会按宽高比猜(可能猜错,
把单图当成网格切开,导致误判为"干净")
返回 {ok, score, grid, median_cell_std, worst_cell_std, per_cell, issues}
"""
p = Path(path)
if not p.exists() or Image is None:
return {"ok": False, "score": 0, "median_cell_std": -1, "worst_cell_std": -1,
"per_cell": [], "issues": ["文件不存在或缺少 Pillow"]}
try:
im = Image.open(p).convert("RGB")
except Exception as e:
return {"ok": False, "score": 0, "median_cell_std": -1, "worst_cell_std": -1,
"per_cell": [], "issues": [f"无法解码: {e}"]}
w, h = im.size
if grid is None:
ar = w / max(1, h)
if ar >= 1.7:
grid = (4, 1)
elif ar >= 1.4:
grid = (3, 1)
elif ar >= 1.15:
grid = (2, 1)
else:
grid = (2, 2)
cols, rows = grid
cw, ch = w // cols, h // rows
band = max(3, min(cw, ch) // 40)
per_cell = []
for r in range(rows):
for c in range(cols):
x0, y0 = c * cw, r * ch
pad = max(2, band // 2)
corners = [
(x0 + pad, y0 + pad, x0 + pad + band, y0 + pad + band),
(x0 + cw - pad - band, y0 + pad, x0 + cw - pad, y0 + pad + band),
(x0 + pad, y0 + ch - pad - band, x0 + pad + band, y0 + ch - pad),
(x0 + cw - pad - band, y0 + ch - pad - band, x0 + cw - pad, y0 + ch - pad),
]
stds = []
for box in corners:
try:
st = ImageStat.Stat(im.crop(box))
stds.append(sum(st.stddev) / 3.0)
except Exception:
continue
cell_std = round(sum(stds) / len(stds), 1) if stds else -1.0
per_cell.append({"cell": f"r{r+1}c{c+1}", "edge_std": cell_std})
valid = [c["edge_std"] for c in per_cell if c["edge_std"] >= 0]
srt = sorted(valid)
med = srt[len(srt) // 2] if srt else -1.0
worst = max(valid) if valid else -1.0
ok = med < EDGE_ACCEPT
score = int(max(0, min(100, 100 - (med / EDGE_ACCEPT) * 100))) if med >= 0 else 0
issues = []
if not ok:
issues.append(f"多数格的背景不干净(各格中位标准差 {med},阈值 <{EDGE_ACCEPT})")
return {"ok": ok, "score": score, "grid": grid,
"median_cell_std": med, "worst_cell_std": worst, "per_cell": per_cell,
"issues": issues}
@@ -0,0 +1,351 @@
"""
变体生成器 (VariantGenerator) — 按变体类型生成角色图像
使用 Kling API 批量生成各类变体图像:
- front_face: 正面高清面部特写
- full_body: 全身正面
- multi_angle: 多角度全身(front/side/right_three_quarter/right_side/back)
- expression: 表情变体(neutral/smile/focused/laugh/serious)
- scene: 场景变体
- sport_action: 运动姿态
- grid: 网格定妆照(标准2×3/运动4×2)
每张生成结果以 {role_name}_{variant_type}_{variant_option}.png 命名和缓存
"""
import json
import hashlib
import time
import os
import sys
from pathlib import Path
from typing import Optional
path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, path)
from lib.config import load_config
from lib.logger import get_logger
from character_generator.prompt_assembler import PromptAssembler
logger = get_logger('variant_generator')
environ = os.environ.get
home = os.path.expanduser('~')
# 缓存目录
CACHE_DIR = environ(
'AVE_CACHE_DIR',
os.path.join(home, 'workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait'),
)
os.makedirs(CACHE_DIR, exist_ok=True)
# Kling API 基础 URL
KLING_BASE = 'https://api-beijing.klingai.com'
class VariantGenerator:
"""使用 Kling API 批量生成角色变体图像。"""
def __init__(self):
self._assembler = PromptAssembler()
self._cfg = load_config()
# ══════════════════════════════════════════════════
# 主入口
# ══════════════════════════════════════════════════
def generate(
self,
attrs: dict,
variant_type: str,
variant_option: str,
lang: str = 'zh',
force: bool = False,
seed: int = 0,
) -> dict:
"""生成指定变体类型的图像。
Args:
attrs: 结构化属性字典
variant_type: 变体类型 (front_face, full_body, multi_angle, expression, ...)
variant_option: 变体选项(如 angle_name, expression_name 等)
lang: 语言
force: 强制重新生成
seed: 固定种子
Returns:
{"path": str, "url": str, "seed": int, "prompt": str, "cached": bool}
"""
# 组装提示词
prompt = self._assembler.build(variant_type, variant_option, attrs)
# 生成缓存 key
opt_suffix = variant_option.replace(' ', '_')
cache_key = f'{attrs.get("name", "char")}_{variant_type}_{opt_suffix}'
output_path = os.path.join(CACHE_DIR, cache_key)
Path(output_path).mkdir(parents=True, exist_ok=True)
filename = f'{cache_key}_{seed if seed else "rand"}.png'
filepath = os.path.join(output_path, filename)
# 如果缓存命中且不强制,直接返回
if not force and os.path.exists(filepath):
logger.info(f' 缓存命中: {filepath}')
return {'path': filepath, 'url': '', 'seed': seed, 'prompt': prompt, 'cached': True}
# 调用 Kling API
result = self._generate_kling(prompt, filepath, seed)
result['prompt'] = prompt
return result
# ══════════════════════════════════════════════════
# 批量生成
# ══════════════════════════════════════════════════
def generate_all_angles(
self,
attrs: dict,
lang: str = 'zh',
force: bool = False,
seed: int = 0,
) -> dict:
"""生成所有角度的全身图。"""
angles = ('front', 'side', 'right_three_quarter', 'right_side', 'back')
results = {}
for angle in angles:
results[angle] = self.generate(attrs, 'multi_angle', angle, lang, force, seed)
return results
def generate_all_expressions(
self,
attrs: dict,
lang: str = 'zh',
force: bool = False,
seed: int = 0,
) -> dict:
"""生成所有表情变体。"""
expressions = ('neutral', 'smile', 'focused', 'laugh', 'serious')
results = {}
for expr in expressions:
results[expr] = self.generate(attrs, 'expression', expr, lang, force, seed)
return results
def generate_baseline_portrait(
self,
attrs: dict,
lang: str = 'zh',
force: bool = False,
seed: int = 0,
) -> dict:
"""生成正面基准肖像(用于 face_anchor)。"""
return self.generate(attrs, 'front_face', 'baseline', lang, force, seed)
def generate_grid_sheet(
self,
attrs: dict,
mode: str = 'standard',
lang: str = 'zh',
force: bool = False,
seed: int = 0,
ref_image: str = '',
prompt: str = '',
) -> dict:
"""生成网格定妆照。
Args:
attrs: 结构化属性
mode: 网格模式 (standard=2x3, sport=4x2)
lang: 语言
force: 强制重新生成
seed: 固定种子
ref_image: 参考图路径(可选)
prompt: 手动提示词(可选,为空则自动组装)
"""
if not prompt:
prompt = self._assembler.build('grid', mode, attrs, lang=lang)
name = attrs.get('name', 'char')
output_path = os.path.join(CACHE_DIR, 'grid', name)
Path(output_path).mkdir(parents=True, exist_ok=True)
filename = f'grid_{name}_{mode}_{seed if seed else "rand"}.png'
filepath = os.path.join(output_path, filename)
if not force and os.path.exists(filepath):
logger.info(f' 网格缓存命中: {filepath}')
return {'path': filepath, 'url': '', 'seed': seed, 'prompt': prompt, 'cached': True}
result = self._generate_kling(prompt, filepath, seed, ref_image)
result['prompt'] = prompt
return result
# ══════════════════════════════════════════════════
# 辅助
# ══════════════════════════════════════════════════
def _get_option_key(self, variant_type: str) -> str:
"""将变体类型映射到配置中的选项 key。"""
mapping = {
'multi_angle': 'angles',
'expression': 'expressions',
'scene': 'scenes',
'sport_action': 'actions',
'outfit': 'outfits',
'grid': 'grid_modes',
}
return mapping.get(variant_type, variant_type)
# ══════════════════════════════════════════════════
# Kling API 底层
# ══════════════════════════════════════════════════
def _generate_kling(
self,
prompt: str,
output_path: str,
seed: int = 0,
ref_image: str = '',
) -> dict:
"""调用 Kling API 生成图像。
支持 T2I(无 ref_image)和 I2I(有 ref_image)。
Args:
prompt: 生成提示词
output_path: 输出文件路径
seed: 固定种子(0 = 随机)
ref_image: 参考图路径(可选,走 I2I)
Returns:
{"path": str, "seed": int, "filename": str}
失败时返回 {"failed": True, "error": str}
"""
import httpx
# 加载 Kling 配置
kling_cfg = self._cfg.get('kling', {})
ak = kling_cfg.get('access_key', environ('KLING_ACCESS_KEY', ''))
sk = kling_cfg.get('secret_key', environ('KLING_SECRET_KEY', ''))
if not ak or not sk:
raise ValueError('缺少 Kling API 配置 (kling.access_key / kling.secret_key)')
# 生成 JWT token
import jwt
now = int(time.time())
token = jwt.encode(
{'iss': ak, 'exp': now + 1800, 'nbf': now},
sk,
algorithm='HS256',
)
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json',
}
# 判断是否用参考图(I2I)
use_ref = bool(ref_image and os.path.exists(ref_image))
if use_ref:
with open(ref_image, 'rb') as f:
import base64
img_b64 = base64.b64encode(f.read()).decode('utf-8')
payload = {
'model': 'kling-v1',
'prompt': prompt,
'image': img_b64,
'strength': 0.65,
'aspect_ratio': '9:16',
'n': 1,
}
else:
payload = {
'model': 'kling-v3',
'prompt': prompt,
'aspect_ratio': '9:16',
'n': 1,
}
if seed:
payload['seed'] = seed
# 提交任务
for attempt in range(3):
try:
resp = httpx.post(
f'{KLING_BASE}/v1/images/generations',
headers=headers,
json=payload,
timeout=30,
)
if resp.status_code == 429:
wait = 15 * (attempt + 1)
logger.warning(f' 429 限流,等待 {wait}s...')
time.sleep(wait)
continue
resp.raise_for_status()
data = resp.json()
task_id = data.get('data', {}).get('task_id', '')
if not task_id:
raise RuntimeError(f'Kling 提交失败: {data}')
break
except Exception as e:
if attempt == 2:
raise
time.sleep(5)
else:
raise RuntimeError('Kling API 提交失败(重试耗尽)')
# 轮询任务状态
for i in range(40):
time.sleep(5)
try:
q = httpx.get(
f'{KLING_BASE}/v1/images/generations/{task_id}',
headers=headers,
timeout=30,
)
q.raise_for_status()
status_data = q.json()['data']
task_status = status_data.get('task_status', '')
if task_status == 'succeed':
images = status_data.get('task_result', {}).get('images', [])
if not images:
raise RuntimeError('Kling 生成成功但无图像')
img_url = images[0].get('url', '')
returned_seed = images[0].get('seed', seed)
# 下载图像
img_resp = httpx.get(img_url, timeout=30)
img_resp.raise_for_status()
Path(os.path.dirname(output_path)).mkdir(parents=True, exist_ok=True)
with open(output_path, 'wb') as f:
f.write(img_resp.content)
logger.info(f' ✅ Kling 生成完成: {os.path.basename(output_path)} [seed={returned_seed}]')
return {
'path': output_path,
'seed': returned_seed,
'filename': os.path.basename(output_path),
}
elif task_status == 'failed':
reason = status_data.get('fail_reason', '未知错误')
raise RuntimeError(f'Kling 生成失败: {reason}')
except Exception as e:
logger.warning(f' 轮询异常: {e}')
time.sleep(5)
raise TimeoutError('Kling 生成超时(200s)')
if __name__ == '__main__':
gen = VariantGenerator()
print('变体生成器初始化完成')
available = gen.get_available_variants()
print(f'可用变体: {list(available.keys())}')