Files
pod_trend_agent/graph/seed_shot.py
T
3218485270 d68cc3b9e3 v106-v109 童装支持 + 标题模板外部化 + 模板导出增强
- 新增男童/女童检测(gender_from_category 优先判童装)与童装场景图生成
  (configs/kids_features.yaml:模特/场景/服装风格,同一商品固定同一组)
- 童装 SPU 字段映射:kids_type→SPU商品属性-类型、kids_age→适用年龄段、
  target_audience 按性别映射、kids_type_map 女童「上衣」→「针织上衣」
- 标题生成提示词外部化:prompts/title_prompt_{1,2,3}.md + config.yaml 路由表
- 模板多站点匹配:经营站点可配多个,命中任意一个即匹配
- 种草图生成失败自动重试(seed_shot.retries,换场景/模特/风格)
- 模板导出新增 Preview 文件夹(成功产品 _composite.oss.jpg + result.xlsx)
- 修复 SKU 尺码未按从小到大排序(_size_rank 支持单一年龄码 6Y/10Y)
2026-09-01 17:24:23 +08:00

247 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""种草图(Seed Shot)生成。
- 模板:configs/seed_shot_templates.yaml(可自定义,占位符 [商品名称]/[材质]/[模特特征])
- 模特特征:configs/model_features.yaml(可自定义,随机取一条)
- 生成:以 product 合成图(图1)为参考,img2img 生成 N 张种草图(保留衣服外观、换场景/模特)
- 占位替换:[商品名称]→cn_title(缺省回退 topic);[材质]→SPU.material[模特特征]→随机
"""
import random
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from graph.paths import project_root
def _load_yaml(rel: str) -> Dict[str, Any]:
for root in (project_root(),):
p = root / rel
if p.exists():
try:
return yaml.safe_load(p.read_text(encoding="utf-8")) or {}
except Exception as e: # noqa: BLE001
print(f"[seed_shot] 读取 {rel} 失败: {e}")
return {}
def load_templates() -> List[Dict[str, str]]:
"""种草图提示词模板列表(无配置时给内置兜底)。"""
data = _load_yaml("configs/seed_shot_templates.yaml")
tpls = data.get("seed_shot_templates") or []
if not tpls:
tpls = [{
"name": "default",
"prompt": (
"【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、"
"印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、"
"重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。"
"全身动态抓拍构图,行走在阳光斑驳的城市林荫道上,微微低头微笑,凸显[材质]的透气与百搭。"
"徕卡Q2摄影质感,高对比度色彩,35mm镜头,f/1.7大光圈,8k分辨率。"
),
}]
return [{"name": str(t.get("name", "default")), "prompt": str(t.get("prompt", ""))}
for t in tpls if t.get("prompt")]
_KIDS_FALLBACK = {
"model_features": {
"boy_kids": ["6岁亚洲小男孩,黑色短发,白净肌肤,圆脸大眼睛,活泼可爱"],
"girl_kids": ["6岁亚洲小女孩,黑色长直发,白净肌肤,圆脸大眼睛,甜美可爱"],
},
"scene_features": {
"boy_kids": ["阳光明媚的公园草坪,儿童游乐设施虚化背景,明亮自然光"],
"girl_kids": ["阳光明媚的公园草坪,花朵与秋千虚化背景,明亮自然光"],
},
"style_features": {
"boy_kids": ["童趣休闲风,活泼明快,适合日常玩耍"],
"girl_kids": ["童趣甜美风,活泼可爱,适合日常玩耍"],
},
}
def _load_kids_features(section: str, gender: str) -> List[str]:
"""从 kids_features.yaml 读取指定 sectionmodel_features/scene_features/style_features)的 gender 特征列表。
支持两种条目格式:字符串直接作为特征;dict 取 prompt 字段(场景特征为完整独立提示词时用)。"""
data = _load_yaml("configs/kids_features.yaml")
kf = (data.get("kids_features") or {}).get(section) or {}
feats: List[str] = []
for f in kf.get(gender, []):
if isinstance(f, dict):
p = f.get("prompt")
if p and str(p).strip():
feats.append(str(p).strip())
elif f and str(f).strip():
feats.append(str(f).strip())
return feats
def load_model_features(gender: Optional[str] = None) -> List[str]:
"""模特特征列表(无配置时给内置兜底)。
gender: "male"/"female" 时只返回对应性别;"boy_kids"/"girl_kids" 时从 kids_features.yaml 读取童装模特;
None/其他 返回全部(指定性别组为空时回退全部)。"""
if gender in ("boy_kids", "girl_kids"):
feats = _load_kids_features("model_features", gender)
if feats:
return feats
return list(_KIDS_FALLBACK["model_features"].get(gender, []))
data = _load_yaml("configs/model_features.yaml")
mf = data.get("model_features") or []
feats: List[str] = []
if isinstance(mf, dict):
if gender and gender in mf:
feats = [str(f) for f in mf[gender] if str(f).strip()]
if not feats:
feats = [str(f) for g in mf.values() for f in g if str(f).strip()]
else:
feats = [str(f) for f in mf if str(f).strip()]
if not feats:
feats = ["20岁清新少女,素颜通透感", "25岁都市职场女性,干练气质"]
return feats
def load_scene_features(gender: Optional[str] = None) -> List[str]:
"""场景特征列表(童装专用,从 kids_features.yaml 读取;无配置给内置兜底)。"""
if gender in ("boy_kids", "girl_kids"):
feats = _load_kids_features("scene_features", gender)
if feats:
return feats
return list(_KIDS_FALLBACK["scene_features"].get(gender, []))
return []
def read_template_category(template_path: str) -> str:
"""读取模版「类目」表头对应的值(如 服装、鞋靴和珠宝饰品>男士时尚>男装>男装上衣、T恤、衬衫>男装T恤)。
遍历所有 sheet(类目表头可能在「模版」等 sheet),找到即返回其下一行同列值。"""
try:
import openpyxl
wb = openpyxl.load_workbook(template_path, data_only=True, read_only=True)
try:
for ws in wb.worksheets:
rows = [r for r in ws.iter_rows(min_row=1, max_row=5, values_only=True)]
for ri, row in enumerate(rows):
for ci, v in enumerate(row):
if v is not None and str(v).strip() == "类目":
if ri + 1 < len(rows):
val = rows[ri + 1][ci]
return str(val or "").strip()
finally:
wb.close()
except Exception as e: # noqa: BLE001
print(f"[seed_shot] 读取模版类目失败: {e}")
return ""
def gender_from_category(category: str) -> Optional[str]:
"""类目含「男童」→ boy_kids;含「女童」→ girl_kids;含「男」→ male;含「女」→ female;都不含 → None。
顺序必须先判「男童/女童」再判「男/女」,因为「男童」也含「男」、「女童」也含「女」。"""
if "男童" in category:
return "boy_kids"
if "女童" in category:
return "girl_kids"
if "男" in category:
return "male"
if "女" in category:
return "female"
return None
def load_style_features(gender: Optional[str] = None) -> List[str]:
"""服装风格列表(童装从 kids_features.yaml 读取;无配置回退 style_features.yaml 全量)。"""
if gender in ("boy_kids", "girl_kids"):
feats = _load_kids_features("style_features", gender)
if feats:
return feats
feats = list(_KIDS_FALLBACK["style_features"].get(gender, []))
if feats:
return feats
data = _load_yaml("configs/style_features.yaml")
feats = [str(f) for f in (data.get("style_features") or []) if str(f).strip()]
if not feats:
feats = ["极简基础款风格,干净纯粹,无过多繁复装饰",
"日系City Boy/Girl风,微宽松版型,注重舒适度与层次感"]
return feats
def render_prompt(template_prompt: str, cn_title: str, material: str, model_feature: str,
style_feature: str = "", scene_feature: str = "") -> str:
"""占位替换:[商品名称]/[材质]/[模特特征]/[服装风格]/[场景]
模板含 [场景] 占位符时替换;不含时若提供了场景描述则追加到提示词末尾(童装场景图生效)。"""
out = template_prompt.replace("[商品名称]", (cn_title or "").strip() or "这件衣服")
out = out.replace("[材质]", (material or "").strip() or "面料")
out = out.replace("[模特特征]", (model_feature or "").strip() or "模特")
out = out.replace("[服装风格]", (style_feature or "").strip() or "日常休闲风")
scene = (scene_feature or "").strip()
if "[场景]" in template_prompt:
out = out.replace("[场景]", scene or "明亮干净的室内摄影棚")
elif scene:
out = f"{out} 场景:{scene}"
return out
def generate_seed_shots(image_backend, base_image: str, cn_title: str, material: str,
count: int, out_dir: str, negative: str = "",
size: str = "1536x2048", prefix: str = "",
gender: Optional[str] = None, retries: int = 3) -> List[str]:
"""生成 count 张种草图(img2img,图1=合成图)。返回产物路径列表。
size: 种草图统一 1536x2048(与合成图一致)。
prefix: 货号前缀(对应产品货号,命名 {prefix}_{随机4位}.png,不覆盖旧文件)。
gender: "male"/"female" 时只从对应性别模特特征随机;"boy_kids"/"girl_kids" 时从 kids_features.yaml
读取童装模特/场景/服装风格;None 全部随机。
retries: 单张失败重试次数(图像 API 偶发安全拦截 400 时自动换一组场景/模特/风格重试)。
占位符 [商品名称]/[材质]/[模特特征]/[服装风格]/[场景] 均随机组合(模板/模特/服装风格/场景各随机取一条)。
童装(boy_kids/girl_kids):场景特征为完整独立提示词,直接作为模板使用(不套用 seed_shot_templates.yaml),
且同一商品全套图固定同一组 [模特特征]+[场景]+[服装风格],系列图调性统一(失败重试时换一组)。"""
templates = load_templates()
features = load_model_features(gender=gender)
style_features = load_style_features(gender=gender)
scene_features = load_scene_features(gender=gender)
is_kids = gender in ("boy_kids", "girl_kids")
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
paths: List[str] = []
# 童装:全套图固定同一组 模特+场景+服装风格(系列图调性统一;失败重试时换一组)
kids_feat = random.choice(features) if is_kids else ""
kids_style = random.choice(style_features) if is_kids else ""
kids_scene = random.choice(scene_features) if (is_kids and scene_features) else ""
for i in range(count):
# 命名:{货号}_{随机4位}.png(按货号命名,随机4位避免自增序号/覆盖)
while True:
rand4 = f"{random.randint(0, 9999):04d}"
out_path = str(out / f"{prefix}_{rand4}.png" if prefix
else out / f"seed_{rand4}.png")
if not Path(out_path).exists():
break
last_err: Optional[Exception] = None
for attempt in range(1, retries + 1):
if is_kids and kids_scene:
# 童装:场景完整提示词直接作为模板,仅填占位符,不追加任何其他模板
prompt = render_prompt(kids_scene, cn_title, material, kids_feat, kids_style)
tpl_name = "kids_scene"
feat, style_feat = kids_feat, kids_style
else:
tpl = random.choice(templates)
feat = random.choice(features)
style_feat = random.choice(style_features)
scene_feat = random.choice(scene_features) if scene_features else ""
prompt = render_prompt(tpl["prompt"], cn_title, material, feat, style_feat, scene_feat)
tpl_name = tpl["name"]
try:
image_backend.print(prompt, base_image, out_path, negative, size=size)
paths.append(out_path)
print(f"[seed_shot] 已生成种草图 {i + 1}/{count}: {out_path}"
f"(模板={tpl_name},模特={feat[:14]}…,风格={style_feat[:14]}…)")
break
except Exception as e: # noqa: BLE001
last_err = e
print(f"[seed_shot] 种草图 {i + 1}{attempt}/{retries} 次失败: {e}")
# 重试换一组(童装换场景/模特/风格;成人换模板/特征)
if is_kids and scene_features:
kids_scene = random.choice(scene_features)
kids_feat = random.choice(features)
kids_style = random.choice(style_features)
else:
print(f"[seed_shot] 种草图 {i + 1} 重试 {retries} 次仍失败: {last_err}")
return paths