- 自定义模式:分析模型输出 delta 唯一改动指令,生图模板 custom_image_prompt.md({delta} 占位符),不再使用负向提示词;generate_design 按 custom_mode 分支,Pinterest 模式保留原创化指令,两模式互不影响
- 多模态分析 response_format 三级回退(json_schema → json_object → none),兼容 DeepSeek
- 模板导出:details 扩展列(细节1/2/3)、target_audience 扩展列(适用人群1)、固定值风格1=休闲/风格2=运动
- 童装特征库更新 + 标题模板外部化 + 图源映射增强
727 lines
40 KiB
Python
727 lines
40 KiB
Python
"""节点 6.5:产品图生成(product)。
|
||
|
||
在 prompt_build 之后、compose 之前运行(prompt_build → product → compose):
|
||
热点提示词 → SPU/颜色选品 → basemap 底图 → 纯印花设计稿 → 模特试穿合成图。
|
||
产物写入 output/<country>/product/(底图拷贝 / *_design.png / *_model / *_composite.png / products.json)。
|
||
|
||
模板选择按 SPU.mark 驱动:
|
||
mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1=模特实拍 / 图2=纯印花设计 / 图3=平铺底图)
|
||
mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计 → 平铺服装图)
|
||
|
||
配置(config.yaml product 段):
|
||
enabled 开关(默认 true)
|
||
db_path SPU/SKU 数据库(默认 db/spu_sku.db,相对路径按运行根解析)
|
||
basemap_dir 底图目录(默认 basemap)
|
||
material_library_dir 模特图库(默认 material_library)
|
||
model_category 模特品类子目录(T-shirt);为空/无图时取 material_library 第一个有图子目录
|
||
brief_index 用第几个 safe 简报的提示词(0=第一个)
|
||
spu_code / sku_code 指定款号/颜色编码(留空自动选第一个有本地底图的)
|
||
spu_tasks [{"spu": "DG004", "skus": "DG004-BL01,..."}] 多款号批量选品(优先于 spu_code)
|
||
spu_count 款号数量上限(0=不限;取任务清单前 N 个)
|
||
spu_per_color true=每颜色一个 SPU 块;false=单 SPU 下挂所有颜色 SKU 变体
|
||
backend 图像后端:openai(真生图,需 key) / mock(占位) / 留空=跳过生成仅存底图
|
||
|
||
缺底图/模特图时跳过对应步骤并提示,不中断流水线;多款号逐个处理,单个失败不影响其它。
|
||
"""
|
||
import json
|
||
import random
|
||
import shutil
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from graph.paths import project_root, runtime_root
|
||
from graph.product import (
|
||
find_basemap,
|
||
first_available_sku,
|
||
list_colors,
|
||
list_spus,
|
||
)
|
||
from graph.validate import ThreadSafeErrors, with_fallback
|
||
|
||
|
||
_USED_LOCK = threading.Lock() # used_designs.json 并发写锁
|
||
|
||
|
||
def _next_img_idx(prod_dir: Path, prefix: str) -> int:
|
||
"""货号续号:扫 prod_dir 已有 {prefix}{数字}* 文件,返回下一个起始序号(不覆盖旧产物)。"""
|
||
max_n = -1
|
||
try:
|
||
if prod_dir.exists():
|
||
for f in prod_dir.iterdir():
|
||
m = re.match(rf"{re.escape(prefix)}(\d+)", f.stem)
|
||
if m:
|
||
max_n = max(max_n, int(m.group(1)))
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return max_n + 1
|
||
|
||
MODEL_WEAR_PROMPT = (
|
||
"你是一个专业的电商AI视觉合成工具,执行“高保真印花与色彩移植/印花替换”:把图2的印花设计印到图3的衣服底图上,"
|
||
"并让图1的模特穿上这件带有图2印花设计的图3底图衣服。\n"
|
||
"【图片角色,按提交顺序,不要弄反】\n"
|
||
"第一张图(图1)=模特实拍图(基底,要被替换衣服图案和颜色的目标区域,保留原有背景/人物/光影);\n"
|
||
"第二张图(图2)=纯印花设计稿(要印上去的图案内容,忽略其背景环境与无关元素,保留图案原始线条与色号);\n"
|
||
"第三张图(图3)=平铺衣服底图(只提取衣服本身的底色与面料材质;忽略平铺图的背景、桌面、环境、场景阴影等一切与衣服无关的元素,只保留衣服面料的颜色、质感和纹理)。\n"
|
||
"最终效果为图1的模特穿着一件“颜色为图3底色、印有图2图案”的衣服。\n"
|
||
"【执行规则】\n"
|
||
"1.底色锁定:从图3平铺衣服中提取衣服底色与面料属性,该底色在最终合成中必须100%保持不变,"
|
||
"严禁偏色、混入图1原衣服颜色或图2背景色。\n"
|
||
"2.印花提取与叠加:从图2中精准提取纯印花图案主体,保留原始线条、色号、比例关系;"
|
||
"将印花叠加到图3底色衣服上,形成“图3底色+图2印花”的合成面料。\n"
|
||
"3.印花尺寸适配:印花的整体尺寸必须与衣服(图3)的面料面积成合理比例——"
|
||
"居中印在胸/背/衣身的主体区域,占衣身面积约30%-45%,四周保留自然留白与衣摆、领口、肩线余量;"
|
||
"严禁印花过大(撑满整件衣服、溢出领口袖口下摆)或过小(占比低于20%)。\n"
|
||
"4.主体识别与遮罩:识别图1模特的服装穿着区域,忽略皮肤、头发、背景、配饰;"
|
||
"将该区域视为“空白画布”,用上述合成面料(图3底色+图2印花)完整覆盖。图1原有衣服颜色与图案全部清除。\n"
|
||
"5.精准贴合:合成面料严格跟随图1衣服的立体结构——有褶皱、身体扭转时印花相应变形;"
|
||
"印花与新底色须“沉入”褶皱中,保留布料原有明暗纹理与物理属性,杜绝“贴纸感”与“平面涂色感”。\n"
|
||
"6.光影融合:提取图1的环境光方向,调整合成面料的亮度/对比度与环境光匹配;"
|
||
"图3底色在阴影区须自然变暗,在高光区须有布料反光;印花色彩受环境光影响产生相应明暗变化,但色号本身不偏移。\n"
|
||
"7.纯净输出:仅输出一张最终合成图;严禁文字/水印/额外装饰;"
|
||
"图1原本的背景、人物、构图及光影结构100%不变,仅替换图1衣服上的印花图案与衣服底色。"
|
||
)
|
||
|
||
# 平铺图专属三图合成提示词(mark 配置 flat_dir 平铺图文件夹):图1平铺实拍 + 图2印花 + 图3底图
|
||
FLAT_LAY_PROMPT = (
|
||
"你是一个专业的电商AI视觉合成工具,执行“高保真印花与色彩移植/印花替换”:把图2的印花设计"
|
||
"印到图3底色的面料上,替换图1平铺衣服原有的底色与图案,输出一张“图3底色+图2印花”的平铺服装商品图。"
|
||
"全程无人参与。\n"
|
||
"【图片角色,按提交顺序】\n"
|
||
"图1=衣服平铺实拍图(基底图:提供衣服版型、轮廓、褶皱、光影、拍摄背景与构图,"
|
||
"最终输出必须与图1同角度、同摆放);\n"
|
||
"图2=纯印花设计稿;\n"
|
||
"图3=平铺衣服底图(只取衣服本身的底色与面料材质,忽略平铺图背景/桌面/场景,只保留面料颜色与质感)。\n"
|
||
"TASK: 把图2的印花设计印到图3底色的面料上,替换图1平铺衣服原有的底色与图案,"
|
||
"输出一张“图3底色+图2印花”的平铺服装商品图。全程无人参与。\n"
|
||
"【执行规则】\n"
|
||
"0.禁止人物:输出图中严禁出现任何人体、模特、人台/假人、头颈、手臂或“穿着效果”,"
|
||
"必须保持图1的纯平铺俯拍商品图形式,衣服平放在原背景上。\n"
|
||
"1.底色锁定:从图3提取衣服底色与面料,最终合成中必须100%保持不变,严禁偏色。\n"
|
||
"2.印花提取:从图2精准提取纯印花图案(线条/色号/比例),叠加到图3底色上形成合成面料。\n"
|
||
"3.印花尺寸适配:印花整体尺寸与衣服面料面积成合理比例,居中印在胸/背/衣身主体区域,"
|
||
"占衣身面积约30%-45%,四周留白,严禁过大撑满整件或过小(低于20%)。\n"
|
||
"4.主体遮罩:识别图1中衣服本体的完整区域(领口到下摆、含袖子;忽略背景/桌面/无关物品),"
|
||
"用合成面料完整覆盖,彻底清除原衣服的颜色与图案;图1中衣服以外的物品保持原样。\n"
|
||
"5.精准贴合:合成面料严格跟随图1衣服的平铺形态,领口/袖口/下摆/侧缝等版型结构清晰保留,"
|
||
"褶皱/翻折/堆叠处印花随之自然变形,杜绝“贴纸感”与“平面涂色感”。\n"
|
||
"6.光影融合:按图1拍摄光线方向调整亮度/对比度,印花随褶皱产生明暗变化但色号不偏移;"
|
||
"衣服投影与图1保持一致。\n"
|
||
"7.纯净输出:仅输出一张最终合成平铺图;图1的背景/桌面/构图/角度/光影100%不变,"
|
||
"仅替换衣服的底色与印花;画面中不得出现任何人像、肢体或人台。"
|
||
)
|
||
|
||
|
||
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" → 模特三图提示词(优先读 prompts/model_prompt.md,缺失/留空回退内置 MODEL_WEAR_PROMPT);
|
||
kind="flat" → 平铺三图提示词(优先读 prompts/flat_prompt.md,缺失/留空回退内置 FLAT_LAY_PROMPT)。
|
||
"""
|
||
if kind == "flat":
|
||
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]:
|
||
"""选定 SKU:显式指定优先;否则第一个有本地底图的;再无则第一个颜色(便于模板导出)。"""
|
||
if sku_code:
|
||
return sku_code
|
||
s = first_available_sku(db_path, basemap_root, spu_code)
|
||
if s:
|
||
return s
|
||
if colors:
|
||
return colors[0]["sku_code"]
|
||
return None
|
||
|
||
|
||
def _template_out_path(prod_dir: Path, chosen_sku: str) -> Path:
|
||
"""模板输出路径:默认 {sku}_已填写.xlsx;若文件被其它程序占用(如已打开),自动换名加序号,避免导出失败。"""
|
||
base = prod_dir / f"{chosen_sku}_已填写.xlsx"
|
||
try:
|
||
with open(base, "ab"):
|
||
pass
|
||
return base
|
||
except OSError:
|
||
pass
|
||
for i in range(2, 100):
|
||
cand = prod_dir / f"{chosen_sku}_已填写_{i}.xlsx"
|
||
if not cand.exists():
|
||
return cand
|
||
return prod_dir / f"{chosen_sku}_已填写_{int(time.time())}.xlsx"
|
||
|
||
|
||
def _is_fatal_50x(e) -> bool:
|
||
"""致命图像服务错误(503 / No available compatible accounts)→ 不重试,提前终止。"""
|
||
try:
|
||
from graph.pinterest_pipeline import PinterestPipeline
|
||
return PinterestPipeline.is_fatal_503(e)
|
||
except Exception: # noqa: BLE001
|
||
return False
|
||
|
||
|
||
def _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs):
|
||
"""图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。
|
||
|
||
致命 503(账户不可用)重试无效 → 直接抛出,交由调用方终止任务。
|
||
"""
|
||
last = None
|
||
for i in range(attempts):
|
||
try:
|
||
return fn(*args, **kwargs)
|
||
except Exception as e: # noqa: BLE001
|
||
if _is_fatal_50x(e):
|
||
raise
|
||
last = e
|
||
if i < attempts - 1:
|
||
time.sleep(backoff[i])
|
||
print(f"[product] 图像合成重试 {attempts} 次均失败: {last}")
|
||
return None
|
||
|
||
|
||
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", category_path="",
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
|
||
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
|
||
img_code: 货号(前缀+3位计数);本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹
|
||
(按货号命名,包含该货号对应的所有图片)。
|
||
on_503: 致命图像服务错误(503/账户不可用)回调(供调用方提前终止任务)。
|
||
model_kind: 图源类型 "model"(模特图)/ "flat"(平铺图),决定用哪个合成提示词。
|
||
返回 result dict;内部异常已兜底,不中断。
|
||
"""
|
||
# 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里
|
||
prod_dir = prod_dir / img_code
|
||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
def _fatal(e) -> bool:
|
||
"""致命图像服务错误(503/账户不可用)→ 通知 on_503 并返回 True(调用方应立即终止)。"""
|
||
try:
|
||
from graph.pinterest_pipeline import PinterestPipeline
|
||
if PinterestPipeline.is_fatal_503(e):
|
||
if on_503 is not None:
|
||
on_503()
|
||
return True
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return False
|
||
|
||
colors = list_colors(db_path, spu["code"])
|
||
valid_codes = {c["sku_code"] for c in colors}
|
||
if sku_code:
|
||
sku_codes = [s.strip() for s in sku_code.split(",") if s.strip() and s.strip() in valid_codes]
|
||
if not sku_codes:
|
||
print(f"[product] 颜色 {sku_code!r} 均不在款号 {spu['code']} 下,可选: {[c['sku_code'] for c in colors]}")
|
||
return None
|
||
else:
|
||
first = _resolve_sku(db_path, basemap_root, spu["code"], "", colors)
|
||
if first is None:
|
||
print(f"[product] 款号 {spu['code']} 无颜色数据,可选: {[c['sku_code'] for c in colors]}")
|
||
return None
|
||
sku_codes = [first]
|
||
|
||
# 模板模式自动判定(UI 不再选择):单色=每色一SPU;多色=单SPU多色;CLI --single-spu 显式覆盖
|
||
spu_per_color = pcfg.get("spu_per_color")
|
||
if spu_per_color is None:
|
||
spu_per_color = len(sku_codes) <= 1
|
||
else:
|
||
spu_per_color = bool(spu_per_color)
|
||
|
||
chosen_sku = sku_codes[0] # 生图用第一个颜色
|
||
tag = f"[product/{img_code or chosen_sku}]" # 日志前缀用货号(DG000),失败/进度一眼定位
|
||
basemap_img = find_basemap(basemap_root, spu["code"], chosen_sku)
|
||
if basemap_img is None:
|
||
print(f"{tag} 底图缺失({basemap_root}/{spu['code']}/{chosen_sku}/),将跳过印花/模特,仅导出模板")
|
||
|
||
result: Dict[str, Any] = {
|
||
"spu_code": spu["code"],
|
||
"sku_code": chosen_sku,
|
||
"color": next((c["color"] for c in colors if c["sku_code"] == chosen_sku), ""),
|
||
"topic": brief.get("topic", ""),
|
||
"art_style": brief.get("art_style", ""),
|
||
"color_palette": brief.get("color_palette", ""),
|
||
"basemap": str(basemap_img) if basemap_img else "",
|
||
"composite_prompt": brief.get("composite_prompt", ""),
|
||
"markup_percent": pcfg.get("markup_percent", 0), # 加价%(后续定价用)
|
||
}
|
||
|
||
# 拷贝底图(图3):命名 = 货号 + SKU code(如 DG003_DG004-BL01_basemap.jpg),
|
||
# 同一 SKU 多个设计(数量 N)时底图文件也各自独立,不覆盖、不混淆
|
||
if basemap_img is not None:
|
||
base_copy = prod_dir / f"{img_code}_{chosen_sku}_basemap{basemap_img.suffix}"
|
||
shutil.copy2(basemap_img, base_copy)
|
||
result["basemap_copy"] = str(base_copy)
|
||
print(f"{tag} 底图: {base_copy}")
|
||
|
||
if ib is None:
|
||
print(f"{tag} 未配置 product.backend(openai/mock),跳过印花/模特生成。")
|
||
elif basemap_img is None:
|
||
print(f"{tag} 无底图,跳过印花/模特生成。")
|
||
else:
|
||
# 5) 纯印花设计稿(图2):直接用 compose 节点生成的共享设计稿(designs/ 已有,不拷贝)
|
||
if shared_design and Path(shared_design).exists():
|
||
design_path = shared_design
|
||
result["design_path"] = design_path
|
||
result["design_from"] = "compose"
|
||
print(f"{tag} 设计稿(来自 compose 节点,designs/ 已有): {design_path}")
|
||
else:
|
||
design_path = str(prod_dir / f"{img_code}_design.png")
|
||
try:
|
||
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
|
||
prompt = ensure_rebrand_hint(brief, sanitize_image_prompt(brief.get("image_prompt", "")))
|
||
ib.generate(prompt, design_path,
|
||
brief.get("composite_negative", ""),
|
||
size=design_size) # 设计稿尺寸按 config compose.design_size
|
||
# 全局 MD5 去重:生成了设计后,把 MD5 加入全局过滤(对所有国家生效);重复 → 跳过该产品
|
||
from graph.pinterest import design_md5_ok
|
||
if not design_md5_ok(design_path):
|
||
print(f"{tag} 设计稿 MD5 全局重复,跳过该产品: {design_path}")
|
||
return None
|
||
result["design_path"] = design_path
|
||
result["design_from"] = "product"
|
||
print(f"{tag} 纯印花设计稿已生成(product 节点): {design_path}")
|
||
except Exception as e: # noqa: BLE001
|
||
if _fatal(e):
|
||
print(f"{tag} 图像服务 503,终止: {e}")
|
||
return None
|
||
errors.append({"node": "product", "type": type(e).__name__, "message": f"设计稿生成失败: {e}", "trace": ""})
|
||
print(f"{tag} 设计稿生成失败: {e}")
|
||
|
||
# 6) 模板选择按 SPU.mark 决定:
|
||
# mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1模特 + 图2印花设计 + 图3底图)
|
||
# mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计)
|
||
# mark==1 统一只做三合一:无图(模特/平铺)时跳过合成,不再回退两图合成(印花+底图)
|
||
if int(spu.get("mark") or 0) == 1:
|
||
kind_label = "平铺" if model_kind == "flat" else "模特"
|
||
print(f"{tag} SPU {spu['code']} mark=1,图源={kind_label} → 使用三图合成模板(图1{kind_label}+图2印花+图3底图)")
|
||
if model_img is not None:
|
||
# 任务级图源分配(pipeline 预分配:mark=1 在有图的模特/平铺文件夹间随机抽)
|
||
model_copy = prod_dir / f"{img_code}_{model_kind}{model_img.suffix}"
|
||
shutil.copy2(model_img, model_copy)
|
||
result["model_path"] = str(model_copy)
|
||
result["model_folder"] = model_img.parent.name
|
||
result["model_kind"] = model_kind
|
||
print(f"{tag} {kind_label}图(任务级分配,{model_img.parent.name}/): {model_copy}")
|
||
else:
|
||
print(f"{tag} material_library 无{kind_label}图,mark=1 统一只做三合一,跳过合成")
|
||
else:
|
||
print(f"{tag} SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt(底图+印花)")
|
||
|
||
# 7) 合成:
|
||
# mark=1 有图(模特/平铺)→ 三图合成(图1=模特或平铺 / 图2=印花设计 / 图3=底图)
|
||
# mark=1 无图 → 跳过合成(统一只做三合一,不做印花+底图两图合成)
|
||
# mark!=1 无图 → 两图合成平铺服装图(图3=底图 + 图2=印花设计)
|
||
if "design_path" not in result:
|
||
print(f"{tag} 无设计稿,跳过合成")
|
||
elif model_img is not None:
|
||
composite_path = str(prod_dir / f"{img_code}_composite.png")
|
||
try:
|
||
# 三图合成:按图源类型选提示词(优先读 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()
|
||
ib.print(wear_prompt, str(model_img), composite_path,
|
||
brief.get("composite_negative", ""),
|
||
extra_images=[design_path, str(basemap_img)], # 图2印花, 图3底图
|
||
size=compose_size) # 合成图尺寸按 config compose.size
|
||
result["composite_path"] = composite_path
|
||
print(f"{tag} {kind_label}三图合成图已生成(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||
except Exception as e: # noqa: BLE001
|
||
# 合成失败 → 带退避重试(网关超载/超时常见,重试 3 次)
|
||
if _fatal(e):
|
||
print(f"{tag} 图像服务 503,终止: {e}")
|
||
return None
|
||
print(f"{tag} 三图合成失败,退避重试…: {e}")
|
||
try:
|
||
retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path,
|
||
brief.get("composite_negative", ""),
|
||
extra_images=[design_path, str(basemap_img)], size=compose_size)
|
||
if retried is not None:
|
||
result["composite_path"] = composite_path
|
||
print(f"{tag} 三图合成重试成功(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||
else:
|
||
errors.append({"node": "product", "type": type(e).__name__, "message": f"模特合成失败(重试仍失败): {e}", "trace": ""})
|
||
print(f"{tag} 三图合成重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||
return None
|
||
except Exception as e2: # noqa: BLE001
|
||
if _fatal(e2):
|
||
print(f"{tag} 图像服务 503(重试时),终止: {e2}")
|
||
return None
|
||
raise
|
||
else:
|
||
# mark=1 无模特图 → 统一只做三合一,不做印花+底图两图合成
|
||
if int(spu.get("mark") or 0) == 1:
|
||
print(f"{tag} mark=1 无模特图,跳过合成(统一只做三合一)")
|
||
else:
|
||
printed_path = str(prod_dir / f"{img_code}_printed.png")
|
||
try:
|
||
# 两图合成(无模特,mark!=1):用平铺印图文案(wearable_prompt),回退旧 composite_prompt
|
||
flat_prompt = (brief.get("wearable_prompt") or "").strip() or brief.get("composite_prompt", "")
|
||
ib.print(flat_prompt, str(basemap_img), printed_path,
|
||
brief.get("composite_negative", ""),
|
||
extra_images=[design_path], # 图2印花
|
||
size=compose_size) # 合成图尺寸按 config compose.size
|
||
result["printed_path"] = printed_path
|
||
print(f"{tag} 平铺服装图已生成(无模特,底图+印花): {printed_path}")
|
||
except Exception as e: # noqa: BLE001
|
||
if _fatal(e):
|
||
print(f"{tag} 图像服务 503,终止: {e}")
|
||
return None
|
||
print(f"{tag} 平铺服装图失败,退避重试…: {e}")
|
||
try:
|
||
retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path,
|
||
brief.get("composite_negative", ""),
|
||
extra_images=[design_path], size=compose_size)
|
||
if retried is not None:
|
||
result["printed_path"] = printed_path
|
||
print(f"{tag} 平铺服装图重试成功: {printed_path}")
|
||
else:
|
||
errors.append({"node": "product", "type": type(e).__name__, "message": f"平铺服装图生成失败(重试仍失败): {e}", "trace": ""})
|
||
print(f"{tag} 平铺服装图重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||
return None
|
||
except Exception as e2: # noqa: BLE001
|
||
if _fatal(e2):
|
||
print(f"{tag} 图像服务 503(重试时),终止: {e2}")
|
||
return None
|
||
raise
|
||
|
||
# 7.2) 多色:单 SPU 多色时每个颜色再执行一次三合一(用各自底图),轮播图按颜色路由
|
||
color_composites: List[Dict[str, Any]] = []
|
||
if model_img is not None and result.get("composite_path"):
|
||
# 首色主图始终记录(单色/多色都走模板填充)
|
||
color_composites.append({"sku_code": chosen_sku, "color": result.get("color", ""),
|
||
"composite_path": result["composite_path"]})
|
||
for sc in sku_codes[1:]:
|
||
bm = find_basemap(basemap_root, spu["code"], sc)
|
||
if bm is None:
|
||
print(f"{tag} 颜色 {sc} 无底图,跳过该色三合一")
|
||
continue
|
||
cp = str(prod_dir / f"{img_code}_{str(sc).split('-')[-1]}_composite.png")
|
||
try:
|
||
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
|
||
col = next((c["color"] for c in colors if c["sku_code"] == sc), sc)
|
||
color_composites.append({"sku_code": sc, "color": col, "composite_path": cp})
|
||
print(f"{tag} 颜色 {sc}({col})三合一已生成: {cp}")
|
||
except Exception as e: # noqa: BLE001
|
||
if _fatal(e):
|
||
print(f"{tag} 颜色 {sc} 三合一遇 503,终止: {e}")
|
||
return None
|
||
errors.append({"node": "product", "type": type(e).__name__,
|
||
"message": f"颜色 {sc} 三合一失败: {e}", "trace": ""})
|
||
result["color_composites"] = color_composites
|
||
|
||
# 7.5) 多模态标题生成:合成图/平铺图/设计稿 → 中英双语 SEO 标题(按国家路由模板)
|
||
if title_backend is not None:
|
||
title_img = (result.get("composite_path") or result.get("printed_path")
|
||
or result.get("design_path"))
|
||
if title_img:
|
||
t = title_backend.generate_title(title_img, country=country, category_path=category_path)
|
||
if t.get("en_title") or t.get("cn_title") or t.get("ja_title") or t.get("es_title"):
|
||
result["en_title"] = t.get("en_title", "")
|
||
result["cn_title"] = t.get("cn_title", "")
|
||
result["ja_title"] = t.get("ja_title", "")
|
||
result["es_title"] = t.get("es_title", "")
|
||
print(f"{tag} 标题已生成: EN={t.get('en_title','')[:50]}... "
|
||
f"CN={t.get('cn_title','')[:30]}... JA={t.get('ja_title','')[:30]}... "
|
||
f"ES={t.get('es_title','')[:30]}...")
|
||
|
||
return result
|
||
|
||
|
||
@with_fallback("product")
|
||
def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||
config = state["config"]
|
||
pcfg = config.get("product") or {}
|
||
stats = dict(state.get("stats") or {})
|
||
if not pcfg.get("enabled", True):
|
||
return {"product": [], "stats": stats}
|
||
|
||
country = state["country"]
|
||
briefs = state.get("briefs") or []
|
||
output_dir = Path(state["output_dir"]) # 本次任务产物(时间戳文件夹)
|
||
cache_dir = Path(state.get("cache_dir") or output_dir) # 缓存/去重(根目录)
|
||
errors = list(state.get("errors") or [])
|
||
|
||
# 路径解析:相对路径 → 优先运行根(exe 旁自定义数据),其次数据根(打包=_MEIPASS 内置)
|
||
def _abs(key: str, default: str) -> Path:
|
||
p = Path(pcfg.get(key, default))
|
||
if p.is_absolute():
|
||
return p
|
||
for root in (runtime_root(), project_root()):
|
||
cand = root / p
|
||
if cand.exists():
|
||
return cand
|
||
return project_root() / p
|
||
|
||
db_path = _abs("db_path", "db/spu_sku.db")
|
||
basemap_root = _abs("basemap_dir", "basemap")
|
||
material_root = _abs("material_library_dir", "material_library")
|
||
category = pcfg.get("model_category", "T-shirt")
|
||
brief_index = int(pcfg.get("brief_index", 0))
|
||
spu_code = (pcfg.get("spu_code") or "").strip()
|
||
sku_code = (pcfg.get("sku_code") or "").strip()
|
||
spu_tasks = pcfg.get("spu_tasks") or []
|
||
spu_count = int(pcfg.get("spu_count") or 0)
|
||
|
||
# 标题生成用:模版「类目」完整路径(如 服装、鞋靴和珠宝饰品>女童时尚>…>女童T恤),
|
||
# 注入标题提示词 {category_path} 占位符;模板未配置/读取失败为空串
|
||
category_path = ""
|
||
try:
|
||
_tp = str(pcfg.get("template_path") or "").strip()
|
||
if _tp:
|
||
from graph.seed_shot import read_template_category
|
||
category_path = read_template_category(_tp)
|
||
except Exception as _e: # noqa: BLE001
|
||
print(f"[product] 读取模版类目路径失败(标题 category_path 留空): {_e}")
|
||
|
||
# 1) 选简报(优先 safe)
|
||
safe = [b for b in briefs if b.get("risk_level") == "safe"] or briefs
|
||
if not safe:
|
||
print("[product] 无可用简报,跳过产品图生成")
|
||
return {"product": [], "stats": stats}
|
||
brief = safe[brief_index] if brief_index < len(safe) else safe[0]
|
||
|
||
# 2) 图像后端
|
||
backend_name = (pcfg.get("backend") or "").strip()
|
||
ib = None
|
||
if backend_name:
|
||
from graph.backends import get_image_backend
|
||
ib = get_image_backend(backend_name)
|
||
if ib is not None:
|
||
ib.bind_config(config.get("compose") or {}) # 复用 compose.api_key/model/size
|
||
|
||
# 3) 构造款号工作清单:spu_tasks(多款号)优先;否则 spu_code / 自动第一个
|
||
spus = list_spus(db_path)
|
||
worklist: List[tuple] = [] # (spu, skus, brief) —— 每个款号可绑定自己的热点简报
|
||
by_topic = {str(b.get("topic", "")).lower(): b for b in briefs}
|
||
if not spus:
|
||
print(f"[product] db 无 SPU 数据({db_path}),跳过")
|
||
return {"product": [], "stats": stats}
|
||
if spu_tasks:
|
||
for ti, t in enumerate(spu_tasks):
|
||
code = (t.get("spu") or t.get("spu_code") or "").strip()
|
||
spu = next((s for s in spus if s["code"] == code), None)
|
||
if spu is None:
|
||
print(f"[product] 任务款号 {code} 不在 db,跳过(可选: {[s['code'] for s in spus][:12]})")
|
||
continue
|
||
tb = None
|
||
tp = (t.get("topic") or "").strip()
|
||
if tp:
|
||
tb = by_topic.get(tp.lower())
|
||
if tb is None:
|
||
print(f"[product] 任务热点「{tp}」不在简报中,回退按序号分配")
|
||
if tb is None:
|
||
# 未指定热点(完整流水线):按任务序号取不同简报,避免多个产品用同一个
|
||
idx = min(ti, len(safe) - 1) if safe else brief_index
|
||
tb = safe[idx] if safe else None
|
||
if tb is None:
|
||
print(f"[product] 无可用简报,跳过任务 {code}")
|
||
continue
|
||
worklist.append((spu, (t.get("skus") or "").strip(), tb))
|
||
if not worklist:
|
||
print("[product] 任务清单无有效款号,跳过产品图生成")
|
||
return {"product": [], "stats": stats}
|
||
elif spu_code:
|
||
spu = next((s for s in spus if s["code"] == spu_code), None)
|
||
if spu is None:
|
||
print(f"[product] 款号 {spu_code} 不在 db,可选: {[s['code'] for s in spus][:12]}")
|
||
return {"product": [], "stats": stats}
|
||
worklist.append((spu, sku_code, brief))
|
||
else:
|
||
spu = next((s for s in spus if first_available_sku(db_path, basemap_root, s["code"])), None)
|
||
if spu is None:
|
||
print(f"[product] 没有任何款号存在本地底图({basemap_root}/<款号>/<SKU.code>/)")
|
||
return {"product": [], "stats": stats}
|
||
worklist.append((spu, sku_code, brief))
|
||
|
||
# 4) 不再按 spu_count 截断:worklist 已是扩展后的完整任务(数量 N = 每款设计数),
|
||
# 全部任务进入队列处理(并发 5)。
|
||
|
||
prod_dir = output_dir / "product"
|
||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 4.1) 任务级图源分配(与 Pinterest 模式一致):按 config.product.mark_dirs 可配置映射,
|
||
# 在「模特图/平铺图」两个有图的文件夹间随机抽图,每张携带 kind(model/flat)
|
||
# → product_node 用对应提示词合成(模特三图 / 平铺三图)
|
||
model_assign: Dict[int, Any] = {}
|
||
mark_dirs = pcfg.get("mark_dirs") or {}
|
||
try:
|
||
from graph.product import build_mark_sources
|
||
sources = build_mark_sources(material_root, mark_dirs, category)
|
||
except Exception: # noqa: BLE001
|
||
sources = {"model": [], "flat": []}
|
||
source_pool = []
|
||
for kind in ("model", "flat"):
|
||
for p in sources.get(kind) or []:
|
||
source_pool.append((p, kind))
|
||
if source_pool:
|
||
for _i in range(len(worklist)):
|
||
img, kind = random.choice(source_pool) # 每任务随机抽一张(含 kind)
|
||
model_assign[_i] = {"img": img, "kind": kind}
|
||
print(f"[product] 任务级图源分配:{len(model_assign)} 个产品任务(mark_dirs 模特/平铺图随机抽,含 kind)")
|
||
else:
|
||
print("[product] material_library 无任何可用图(模特/平铺均无)→ 跳过合成,仅导出模板")
|
||
|
||
# 5) compose 节点生成的共享设计稿(图2):每个任务用自己的热点简报设计(tb.design_path),
|
||
# 一个货号对应一个设计(多颜色共用该设计),不再所有产品共用第一个
|
||
designs_dir = output_dir / "designs"
|
||
designs_dir.mkdir(parents=True, exist_ok=True)
|
||
designs_map = {str(d.get("topic", "")).strip().lower(): d.get("path", "")
|
||
for d in (state.get("designs") or []) if isinstance(d, dict)}
|
||
for _d in (state.get("briefs") or []):
|
||
if isinstance(_d, dict) and _d.get("design_path"):
|
||
designs_map.setdefault(str(_d.get("topic", "")).strip().lower(), _d.get("design_path"))
|
||
|
||
def _resolve_design(tb) -> Optional[str]:
|
||
"""任务绑定的简报 → 该热点自己的设计稿路径(按货号命名拷贝到 designs/)。"""
|
||
topic = str(tb.get("topic", "")).strip().lower()
|
||
src = designs_map.get(topic) or tb.get("design_path")
|
||
if not src or not Path(src).exists():
|
||
return None
|
||
return src
|
||
|
||
# 6) 逐个款号处理(每款号用自己绑定的热点简报)
|
||
title_backend = None
|
||
ls_cfg = config.get("llm_screen") or {}
|
||
if (ls_cfg.get("provider") or "") not in ("", "mock"):
|
||
try:
|
||
from graph.llms import get_backend as _glb
|
||
_tb = _glb(ls_cfg.get("provider"))
|
||
_tb.bind_config(ls_cfg)
|
||
if _tb.has_key:
|
||
title_backend = _tb
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"[product] 标题后端初始化失败: {e}")
|
||
|
||
results: List[Dict[str, Any]] = []
|
||
prefix = str(pcfg.get("code_prefix") or "DG").strip()
|
||
# 并发:默认每个 SPU 一个独立线程(任务数即并发数,提速);
|
||
# config.product.concurrency 显式配置可覆盖(如限流时设 3-5);
|
||
# 默认并发上限 5:全量并发(任务数)会压垮图像网关(10 并发 → 全部超时),
|
||
# 超出上限的任务在线程池排队,逐批处理
|
||
concurrency = int(pcfg.get("concurrency") or 0) or min(len(worklist), 5)
|
||
print(f"[product] 并发 {concurrency}(每 SPU 一线程,上限 {concurrency})处理 {len(worklist)} 个产品任务")
|
||
|
||
_safe_errors = ThreadSafeErrors()
|
||
|
||
def _run_one(wi: int, spu, skus, tb):
|
||
"""并发执行单个产品:返回 (result or None, img_code)。失败由 _process_spu 内部兜底。"""
|
||
idx = start_idx + wi # 实际货号序号(start_idx 起自动续号)
|
||
img_code = f"{prefix}{idx:03d}" # 货号:图片按此命名(DG000_design.png…)
|
||
try:
|
||
# 每个任务用自己的热点设计(designs_map),并拷贝为货号命名(designs/DG000_design.png)
|
||
design_src = _resolve_design(tb)
|
||
design_path = None
|
||
if design_src:
|
||
design_path = str(designs_dir / f"{img_code}_design.png")
|
||
try:
|
||
shutil.copy2(design_src, design_path)
|
||
except Exception: # noqa: BLE001
|
||
design_path = design_src
|
||
tb = dict(tb)
|
||
tb["design_path"] = design_path
|
||
_src = model_assign.get(wi) or {}
|
||
r = _process_spu(db_path, basemap_root, material_root, category, prod_dir,
|
||
tb, ib, spu, skus, pcfg, _safe_errors, design_path, title_backend,
|
||
country, img_code=img_code,
|
||
model_img=_src.get("img"), # 按任务序号取独立随机图源
|
||
model_kind=_src.get("kind", "model"),
|
||
category_path=category_path,
|
||
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:
|
||
r["img_code"] = img_code
|
||
return r, img_code
|
||
except Exception as e: # noqa: BLE001 # 单产品任何异常都不拖垮整体
|
||
print(f"[product/{img_code}] 产品处理异常(跳过该产品): {e}")
|
||
return None, img_code
|
||
|
||
import concurrent.futures
|
||
# 货号自动续号:任务一开始全部按序分配(start_idx 起),不覆盖已生成的产物
|
||
start_idx = _next_img_idx(prod_dir, prefix)
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
|
||
futures = [ex.submit(_run_one, i, spu, skus, tb)
|
||
for i, (spu, skus, tb) in enumerate(worklist)]
|
||
for f in concurrent.futures.as_completed(futures):
|
||
r, img_code = f.result()
|
||
if r:
|
||
results.append(r)
|
||
try:
|
||
_record_used(cache_dir, r) # (热点-风格) 去重记录 → 缓存根目录
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
# 合并并发线程收集的错误(线程安全收集器 → 主线程统一追加)
|
||
if len(_safe_errors):
|
||
errors.extend(list(_safe_errors))
|
||
|
||
results.sort(key=lambda x: x.get("img_code", "")) # 按货号排序,模板/清单顺序稳定
|
||
_write_products(prod_dir, results)
|
||
stats["product"] = {
|
||
"spus": [r.get("spu_code") for r in results],
|
||
"skus": [r.get("sku_code") for r in results],
|
||
"topic": brief.get("topic", ""),
|
||
"count": len(results),
|
||
"composite": sum(1 for r in results if r.get("composite_path")),
|
||
"printed": sum(1 for r in results if r.get("printed_path")),
|
||
"templates": sum(1 for r in results if r.get("template_path")),
|
||
"output_dir": str(prod_dir),
|
||
}
|
||
return {"product": results, "stats": stats, "errors": errors}
|
||
|
||
|
||
def _record_used(output_dir: Path, r: Dict[str, Any]):
|
||
"""记录已用 (热点-风格-配色),供后续去重:output/<国家>/used_designs.json。"""
|
||
topic = r.get("topic", "")
|
||
if not topic:
|
||
return
|
||
with _USED_LOCK: # 并发下 used_designs.json 读写互斥
|
||
p = output_dir / "used_designs.json"
|
||
used = []
|
||
if p.exists():
|
||
try:
|
||
used = json.loads(p.read_text(encoding="utf-8")).get("used", []) or []
|
||
except Exception:
|
||
used = []
|
||
entry = {
|
||
"topic": topic,
|
||
"art_style": r.get("art_style", ""),
|
||
"color_palette": r.get("color_palette", ""),
|
||
"spu_code": r.get("spu_code", ""),
|
||
"sku_code": r.get("sku_code", ""),
|
||
"date": time.strftime("%Y-%m-%d"),
|
||
}
|
||
# 同 (topic, art_style) 已记录则跳过,避免去重记录重复堆积
|
||
if any(str(u.get("topic", "")).strip().lower() == str(entry["topic"]).strip().lower()
|
||
and str(u.get("art_style", "")).strip().lower() == str(entry["art_style"]).strip().lower()
|
||
for u in used):
|
||
return
|
||
used.append(entry)
|
||
p.write_text(json.dumps(
|
||
{"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "used": used},
|
||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
|
||
def _write_products(prod_dir: Path, products: List[Dict[str, Any]]):
|
||
(prod_dir / "products.json").write_text(
|
||
json.dumps({"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "products": products},
|
||
ensure_ascii=False, indent=2), encoding="utf-8")
|