"""种草图(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 读取指定 section(model_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