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)
This commit is contained in:
2026-09-01 17:24:23 +08:00
parent 3dc594cf57
commit d68cc3b9e3
20 changed files with 826 additions and 136 deletions
+21 -16
View File
@@ -113,18 +113,26 @@ FLAT_LAY_PROMPT = (
)
def _active_prompt(kind: str, prompts: Optional[Dict] = None) -> str:
def _read_prompt_md(filename: str) -> str:
"""读取 prompts/<filename> 提示词文件:优先运行根(exe 旁,可编辑),回退数据根;找不到/空返回空串。"""
for base in (runtime_root(), project_root()):
p = base / "prompts" / filename
if p.exists():
text = p.read_text(encoding="utf-8").strip()
if text:
return text
return ""
def _active_prompt(kind: str) -> str:
"""返回当前图源应使用的合成提示词。
kind="model" → 模特三图提示词(配置覆盖优先生效,否则内置 MODEL_WEAR_PROMPT);
kind="flat" → 平铺三图提示词(配置覆盖优先生效,否则内置 FLAT_LAY_PROMPT)。
kind="model" → 模特三图提示词(优先读 prompts/model_prompt.md,缺失/留空回退内置 MODEL_WEAR_PROMPT);
kind="flat" → 平铺三图提示词(优先读 prompts/flat_prompt.md,缺失/留空回退内置 FLAT_LAY_PROMPT)。
"""
cfgs = prompts or {}
if kind == "flat":
return (str(cfgs.get("flat_prompt") or "").strip()
or FLAT_LAY_PROMPT)
return (str(cfgs.get("model_prompt") or "").strip()
or MODEL_WEAR_PROMPT)
return _read_prompt_md("flat_prompt.md") or FLAT_LAY_PROMPT
return _read_prompt_md("model_prompt.md") or MODEL_WEAR_PROMPT
def _resolve_sku(db_path, basemap_root, spu_code: str, sku_code: str, colors=None) -> Optional[str]:
@@ -187,7 +195,7 @@ def _process_spu(
db_path, basemap_root, material_root, category, prod_dir, brief, ib,
spu, sku_code, pcfg, errors, shared_design=None, title_backend=None, country="",
img_code="", model_img=None, design_size="1024x1024", compose_size="1536x2048",
on_503=None, model_kind="model", prompts=None,
on_503=None, model_kind="model",
) -> Optional[Dict[str, Any]]:
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
@@ -195,7 +203,6 @@ def _process_spu(
(按货号命名,包含该货号对应的所有图片)。
on_503: 致命图像服务错误(503/账户不可用)回调(供调用方提前终止任务)。
model_kind: 图源类型 "model"(模特图)/ "flat"(平铺图),决定用哪个合成提示词。
prompts: {"model": str, "flat": str} 可配置提示词覆盖(来自 config.product.mark_dirs);缺省用内置。
返回 result dict;内部异常已兜底,不中断。
"""
# 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里
@@ -324,8 +331,8 @@ def _process_spu(
elif model_img is not None:
composite_path = str(prod_dir / f"{img_code}_composite.png")
try:
# 三图合成:按图源类型选提示词(可配置覆盖优先,否则内置 MODEL_WEAR/FLAT_LAY
wear_prompt = _active_prompt(model_kind, prompts)
# 三图合成:按图源类型选提示词(优先读 prompts/*.md,否则内置 MODEL_WEAR/FLAT_LAY
wear_prompt = _active_prompt(model_kind)
kind_label = "平铺" if model_kind == "flat" else "模特"
print(f"{tag} {kind_label}三图合成提交中(3 参考图 img2img,网关处理约 2-6 分钟,请耐心等待)…")
t0 = time.time()
@@ -407,7 +414,7 @@ def _process_spu(
continue
cp = str(prod_dir / f"{img_code}_{str(sc).split('-')[-1]}_composite.png")
try:
ib.print(_active_prompt(model_kind, prompts), str(model_img), cp,
ib.print(_active_prompt(model_kind), str(model_img), cp,
brief.get("composite_negative", ""),
extra_images=[design_path, str(bm)], # 图2印花, 图3该色底图
size=compose_size) # 合成图尺寸按 config compose.size
@@ -546,7 +553,6 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
# → product_node 用对应提示词合成(模特三图 / 平铺三图)
model_assign: Dict[int, Any] = {}
mark_dirs = pcfg.get("mark_dirs") or {}
prompts_cfg = (mark_dirs.get("1") or {})
try:
from graph.product import build_mark_sources
sources = build_mark_sources(material_root, mark_dirs, category)
@@ -559,7 +565,7 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
if source_pool:
for _i in range(len(worklist)):
img, kind = random.choice(source_pool) # 每任务随机抽一张(含 kind
model_assign[_i] = {"img": img, "kind": kind, "prompts": prompts_cfg}
model_assign[_i] = {"img": img, "kind": kind}
print(f"[product] 任务级图源分配:{len(model_assign)} 个产品任务(mark_dirs 模特/平铺图随机抽,含 kind)")
else:
print("[product] material_library 无任何可用图(模特/平铺均无)→ 跳过合成,仅导出模板")
@@ -628,7 +634,6 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
country, img_code=img_code,
model_img=_src.get("img"), # 按任务序号取独立随机图源
model_kind=_src.get("kind", "model"),
prompts=_src.get("prompts"),
design_size=str((config.get("compose") or {}).get("design_size") or "1024x1024"),
compose_size=str((config.get("compose") or {}).get("size") or "1536x2048"))
if r:
+4 -2
View File
@@ -103,7 +103,8 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
category = read_template_category(tp)
gender = gender_from_category(category)
if gender:
print(f"[seed_shot] 类目「{category[:30]}…」含{'' if gender == 'male' else ''} → 固定 {gender} 模特")
label = {"male": "", "female": "", "boy_kids": "男童", "girl_kids": "女童"}.get(gender, gender)
print(f"[seed_shot] 类目「{category[:30]}…」检测到 {label} → 固定 {gender} 模特")
elif category:
print(f"[seed_shot] 类目「{category[:30]}…」无男/女 → 男女模特随机")
@@ -141,7 +142,8 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
continue
generated = generate_seed_shots(ib, base, cn, material, n, str(shot_dir),
r.get("composite_negative", ""),
size=size, prefix=pfx, gender=gender)
size=size, prefix=pfx, gender=gender,
retries=int(ss_cfg.get("retries", 3)))
paths.extend(generated)
if not paths:
return None
+37 -1
View File
@@ -29,6 +29,38 @@ def _template_out_path(prod_dir: Path, tpl_name: str) -> Path:
return prod_dir / f"{tpl_name}_已填写_{int(time.time())}.xlsx"
def _build_preview(output_dir: Path, products: List[Dict[str, Any]]) -> None:
"""生成 Preview 文件夹:仅成功导入模板的产品(template_path 已设置),
复制其 _composite.oss.jpg(压缩版合成图)到 Preview,并生成 result.xlsx
(货号 | 中文标题 | 英文标题 三列)。"""
import shutil
from openpyxl import Workbook
preview_dir = output_dir / "Preview"
preview_dir.mkdir(parents=True, exist_ok=True)
rows: List[tuple] = []
for r in products:
if not r.get("template_path"):
continue
code = str(r.get("img_code") or r.get("oss_code") or "")
comp = r.get("composite_path") or r.get("printed_path")
if comp:
oss_file = Path(comp).with_suffix(".oss.jpg")
if oss_file.exists():
dst = preview_dir / oss_file.name
if not dst.exists():
shutil.copy2(oss_file, dst)
rows.append((code, r.get("cn_title") or "", r.get("en_title") or ""))
if rows:
wb = Workbook()
ws = wb.active
ws.title = "result"
ws.append(["货号", "中文标题", "英文标题"])
for row in rows:
ws.append(list(row))
wb.save(str(preview_dir / "result.xlsx"))
print(f"[template] Preview 已生成({len(rows)} 个成功产品): {preview_dir}")
@with_fallback("template_export")
def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
products: List[Dict[str, Any]] = state.get("product") or []
@@ -68,7 +100,8 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
break
from graph.template_export import (export_products, _resolve_component_map,
_resolve_season_map, _resolve_pattern_map)
_resolve_season_map, _resolve_pattern_map,
_resolve_target_audience_map, _resolve_kids_type_map)
tdir = (pcfg.get("template_dir") or "").strip() or str(Path(tp).parent)
prod_dir = output_dir / "product"
prod_dir.mkdir(parents=True, exist_ok=True)
@@ -124,12 +157,15 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
component_map=_resolve_component_map(config),
season_map=_resolve_season_map(config),
pattern_map=_resolve_pattern_map(config),
target_audience_map=_resolve_target_audience_map(config),
kids_type_map=_resolve_kids_type_map(config),
)
for r in products:
if (r.get("composite_path") or r.get("printed_path")) and (r.get("en_title") or "").strip():
r["template_path"] = str(out)
exported.append(str(out))
print(f"[template] 商品上传模板已生成({len(batch)} 个产品一次合并): {out}")
_build_preview(output_dir, products)
except Exception as e: # noqa: BLE001
errors.append({"node": "template_export", "type": type(e).__name__,
"message": f"模板批量导出失败: {e}", "trace": ""})