feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 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:
@@ -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',[]))} 字段)")
|
||||
+89
@@ -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())}')
|
||||
Reference in New Issue
Block a user