v89-v91 模板增强 + 图源映射 + 多模态提示词可配置化

- 图源映射统一:热点采集与 Pinterest 模式均走 config.product.mark_dirs 配置,按任务序号随机抽模特图/平铺图
- 商品产地固定:统一为「中国大陆」+「产地省份=广东省」(不再读站点/字典映射)
- 模板 SKU 字段检测:按建议售价同一套路检测 SKU分类/SKU数量/SKU数量单位,必填时填入单品/1/件
- 多模态分析提示词可配置:prompts/pinterest_analyze_system.md + user.md,支持国家覆盖,不丢文件回退内置
- 自定义图片模式:新增 pinterest_custom_load_node,图片数量硬校验,选品清单 ≤ 有效图片数
- 模板导出优化:写入前按货号末 3 位升序排序,不再产生空白 xlsx
- 修复 v90 project review 10 项(503 致命终止、线程安全、原子写入等)
This commit is contained in:
2026-08-28 16:24:50 +08:00
parent 2a96ec0870
commit 71a48e4ed5
40 changed files with 780 additions and 200 deletions
+2
View File
@@ -4,6 +4,7 @@ from .fetch_node import fetch_node
from .filter_node import filter_node
from .oss_upload_node import oss_upload_node
from .pinterest_analyze_node import pinterest_analyze_node
from .pinterest_custom_load_node import pinterest_custom_load_node
from .pinterest_scrape_node import pinterest_scrape_node
from .pinterest_search_node import pinterest_search_node
from .product_node import product_node
@@ -29,4 +30,5 @@ __all__ = [
"pinterest_search_node",
"pinterest_scrape_node",
"pinterest_analyze_node",
"pinterest_custom_load_node",
]
+5
View File
@@ -114,6 +114,11 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
if batch_size <= 0:
batch_size = max_designs # 自动:一次最多分析 max_designs 张(每张图→1条简报)
need = batch_size
# 自定义模式:本地图池是唯一且有限的图源,最多分析「选品清单总数」张即可,
# 避免多余分析(超出的图源在简报达标后由自定义路由结束,不浪费配额)。
if bool(state.get("custom_mode")):
_t = int(state.get("pinterest_target") or 1) or 1
need = max(0, min(need, _t))
# 1) 图池取未消费图片(md5 不在 used_images);无 → 返回空,路由触发搜索
pool = load_image_pool(output_dir, country)
+82
View File
@@ -0,0 +1,82 @@
"""Pinterest 参考模式自定义节点:加载本地图片文件夹 → 校验数量 → 注册进图池(pinterest_custom_load)。
仅自定义模式(pinterest.mode=custom)使用,替代 pinterest_search + pinterest_scrape
直接把 pinterest.custom_image_dir 内的有效图片(jpg/jpeg/png/webp)注册进图池,
再复用 pinterest_analyze 直接送多模态分析,沿用 Pinterest 后续所有步骤
(分析→设计→三合一→OSS→种草图→模板导出)。
数量校验(硬校验,不满足则不启动分析):
1. 文件夹必须有有效图片(>0);
2. 选品清单总数(product.spu_tasks 展开后,state.pinterest_target)必须 ≤ 有效图片数 ——
「选品清单不得大于有效图片数」。
每次运行把图池重建为该文件夹的图片集(自定义模式唯一图源),并清空已消费拉黑
used_images),保证用户每次重新上传/选择文件夹的所有有效图片都会被重新多模态分析。
"""
from typing import Any, Dict
from graph.pinterest import (
image_md5,
list_valid_images,
save_image_pool,
save_used_images,
)
from graph.validate import with_fallback
@with_fallback("pinterest_custom_load")
def pinterest_custom_load_node(state: Dict[str, Any]) -> Dict[str, Any]:
config = state["config"]
output_dir = state["output_dir"]
country = state["country"]
pcfg = config.get("pinterest") or {}
folder = str(pcfg.get("custom_image_dir") or "").strip()
images = list_valid_images(folder)
valid_n = len(images)
target = int(state.get("pinterest_target") or 1)
errors = list(state.get("errors") or [])
stats = dict(state.get("stats") or {})
# —— 硬校验 1:必须有有效图片 ——
if valid_n == 0:
msg = (f"自定义图片文件夹「{folder or '(未填写)'}」中没有有效图片"
f"(请填写 pinterest.custom_image_dir,文件夹内应有 jpg/jpeg/png/webp 图片)")
errors.append({"node": "pinterest_custom_load", "type": "ValidationError",
"message": msg, "trace": ""})
stats["pinterest_custom"] = {"folder": folder, "valid_images": 0, "target": target, "ok": False}
print(f"[pinterest_custom_load] ❌ {msg}")
return {"errors": errors, "stats": stats}
# —— 硬校验 2:选品清单总数 ≤ 有效图片数 ——
if target > valid_n:
msg = (f"选品清单数量({target})大于自定义图片有效数量({valid_n}):"
f"选品清单不得大于有效图片数,请补充图片或减少选品")
errors.append({"node": "pinterest_custom_load", "type": "ValidationError",
"message": msg, "trace": ""})
stats["pinterest_custom"] = {"folder": folder, "valid_images": valid_n, "target": target, "ok": False}
print(f"[pinterest_custom_load] ❌ {msg}")
return {"errors": errors, "stats": stats}
# —— 每次运行重建图池为该文件夹图片集 + 清空已消费拉黑 → 所有有效图片都被重新分析 ——
pool = {"updated_at": "", "images": []}
seen_md5: set = set()
for f in images:
m = str(image_md5(str(f)) or "").strip().lower()
if not m or m in seen_md5:
continue
seen_md5.add(m)
pool["images"].append({"path": str(f), "md5": m, "term": "custom"})
save_image_pool(output_dir, country, pool)
save_used_images(output_dir, country, set())
stats["pinterest_custom"] = {
"folder": folder, "valid_images": valid_n,
"target": target, "loaded": len(pool["images"]), "ok": True,
}
print(f"[pinterest_custom_load] 自定义图源:{folder} → 有效图片 {valid_n} 张(选品清单 {target}),"
f"已注册进图池,直接进入多模态分析")
return {"custom_mode": True, "pinterest_custom_folder": folder,
"custom_load_ok": True, "errors": errors, "stats": stats}
+7 -4
View File
@@ -33,6 +33,8 @@ def pinterest_search_node(state: Dict[str, Any]) -> Dict[str, Any]:
provider = str(pcfg.get("provider") or "openai").strip().lower()
search_mode = str(pcfg.get("search_mode") or "direct").strip().lower()
_raw_suffix = str(pcfg.get("search_term_suffix") if pcfg.get("search_term_suffix") is not None else " t-shirt design").strip()
suffix = _raw_suffix if _raw_suffix else "" # 配置留空则后缀为空(不追加),不再回退默认
want = int(pcfg.get("search_terms_per_run", 1)) # 每次搜索词数量
seed_sample = int(pcfg.get("seed_sample", 40))
max_used_in_prompt = int(pcfg.get("max_used_terms_in_prompt", 100))
@@ -57,9 +59,9 @@ def pinterest_search_node(state: Dict[str, Any]) -> Dict[str, Any]:
fresh = [s for s in pool if s.lower() not in used_set]
if not fresh:
fresh = pool # 库内词全部用过 → 允许复用(词库有限)
terms = [f"{s} t-shirt design" if "t-shirt design" not in s.lower() else s
terms = [f"{s} {suffix}".strip() if suffix and suffix not in s.lower() else s
for s in random.sample(fresh, min(want, len(fresh)))]
print(f"[pinterest_search] direct 模式:国家种子词库随机抽 {len(terms)} 个 + t-shirt design{country}")
print(f"[pinterest_search] direct 模式:国家种子词库随机抽 {len(terms)} 个 + 后缀「{suffix}{country}")
else:
used_llm = merge_used(used, attempted)
if max_used_in_prompt > 0:
@@ -79,7 +81,8 @@ def pinterest_search_node(state: Dict[str, Any]) -> Dict[str, Any]:
if llm is not None and hasattr(llm, "generate_pinterest_terms"):
try:
ctx = {"country": country, "seeds": seeds, "used_terms": used_llm, "count": want}
ctx = {"country": country, "seeds": seeds, "used_terms": used_llm, "count": want,
"search_term_suffix": suffix}
res = llm.generate_pinterest_terms(ctx)
terms = [str(t).strip() for t in (res.get("search_terms") or []) if str(t).strip()]
print(f"[pinterest_search] LLM 生成搜索词 {len(terms)} 个({country},已用词注入 {len(used_llm)}")
@@ -89,7 +92,7 @@ def pinterest_search_node(state: Dict[str, Any]) -> Dict[str, Any]:
# 3) 兜底:LLM 无结果 → 种子词池随机抽样
if not terms:
terms = [f"{s} t-shirt design" if "t-shirt design" not in s.lower() else s
terms = [f"{s} {suffix}".strip() if suffix and suffix not in s.lower() else s
for s in random.sample(seeds, min(want, len(seeds)))]
print(f"[pinterest_search] 兜底:从种子词池取 {len(terms)}")
+86 -32
View File
@@ -83,6 +83,49 @@ MODEL_WEAR_PROMPT = (
"图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 _active_prompt(kind: str, prompts: Optional[Dict] = None) -> str:
"""返回当前图源应使用的合成提示词。
kind="model" → 模特三图提示词(配置覆盖优先生效,否则内置 MODEL_WEAR_PROMPT);
kind="flat" → 平铺三图提示词(配置覆盖优先生效,否则内置 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)
def _resolve_sku(db_path, basemap_root, spu_code: str, sku_code: str, colors=None) -> Optional[str]:
"""选定 SKU:显式指定优先;否则第一个有本地底图的;再无则第一个颜色(便于模板导出)。"""
@@ -144,13 +187,15 @@ 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,
on_503=None, model_kind="model", prompts=None,
) -> 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"(平铺图),决定用哪个合成提示词。
prompts: {"model": str, "flat": str} 可配置提示词覆盖(来自 config.product.mark_dirs);缺省用内置。
返回 result dict;内部异常已兜底,不中断。
"""
# 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里
@@ -253,40 +298,43 @@ def _process_spu(
# 6) 模板选择按 SPU.mark 决定:
# mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1模特 + 图2印花设计 + 图3底图)
# mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计)
# mark==1 统一只做三合一:无模特图时跳过合成,不再回退两图合成(印花+底图)
# mark==1 统一只做三合一:无图(模特/平铺)时跳过合成,不再回退两图合成(印花+底图)
if int(spu.get("mark") or 0) == 1:
print(f"{tag} SPU {spu['code']} mark=1 → 使用三图合成模板(图1模特+图2印花+图3底图)")
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:
# 任务级模特分配(product_node 预分配:一个 SPU 一个模特,SPU 数>模特数循环兜底
model_copy = prod_dir / f"{img_code}_model{model_img.suffix}"
# 任务级图源分配(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
print(f"{tag} 模特图(任务级分配,{model_img.parent.name}/: {model_copy}")
result["model_kind"] = model_kind
print(f"{tag} {kind_label}图(任务级分配,{model_img.parent.name}/: {model_copy}")
else:
print(f"{tag} material_library 无模特图,mark=1 统一只做三合一,跳过合成")
print(f"{tag} material_library 无{kind_label}图,mark=1 统一只做三合一,跳过合成")
else:
print(f"{tag} SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt(底图+印花)")
# 7) 合成:
# 有模特图 → 三图合成(图1=模特 / 图2=印花设计 / 图3=底图)
# mark=1 无模特图 → 跳过合成(统一只做三合一,不做印花+底图两图合成)
# mark!=1 无模特图 → 两图合成平铺服装图(图3=底图 + 图2=印花设计)
# 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:
# 三图合成:优先用简报的 composite_prompt(模板化三图文案),回退内置 MODEL_WEAR_PROMPT
wear_prompt = (brief.get("composite_prompt") or "").strip() or MODEL_WEAR_PROMPT
print(f"{tag} 三图合成提交中(3 参考图 img2img,网关处理约 2-6 分钟,请耐心等待)…")
# 三图合成:按图源类型选提示词(可配置覆盖优先,否则内置 MODEL_WEAR/FLAT_LAY
wear_prompt = _active_prompt(model_kind, prompts)
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} 三图模特合成图已生成(耗时 {int(time.time()-t0)}s: {composite_path}")
print(f"{tag} {kind_label}三图合成图已生成(耗时 {int(time.time()-t0)}s: {composite_path}")
except Exception as e: # noqa: BLE001
# 合成失败 → 带退避重试(网关超载/超时常见,重试 3 次)
if _fatal(e):
@@ -359,7 +407,7 @@ def _process_spu(
continue
cp = str(prod_dir / f"{img_code}_{str(sc).split('-')[-1]}_composite.png")
try:
ib.print(MODEL_WEAR_PROMPT, str(model_img), cp,
ib.print(_active_prompt(model_kind, prompts), str(model_img), cp,
brief.get("composite_negative", ""),
extra_images=[design_path, str(bm)], # 图2印花, 图3该色底图
size=compose_size) # 合成图尺寸按 config compose.size
@@ -493,25 +541,28 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
prod_dir = output_dir / "product"
prod_dir.mkdir(parents=True, exist_ok=True)
# 4.1) 任务级模特分配(按 spu.mark 映射模特目录 → 过滤非 3:4 图片 → 每个任务随机抽取):
# 每个产品任务(含同一 SPU 的多个款)都独立随机抽一个模特,保证同款多产品模特不重复
# 4.1) 任务级图源分配(与 Pinterest 模式一致):按 config.product.mark_dirs 可配置映射,
# 在「模特图/平铺图」两个有图的文件夹间随机抽图,每张携带 kind(model/flat
# → 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_model_map, find_model_images_for_mark
mark_map = build_mark_model_map(db_path, material_root)
from graph.product import build_mark_sources
sources = build_mark_sources(material_root, mark_dirs, category)
except Exception: # noqa: BLE001
mark_map = {}
for _i, (_spu, _skus, _tb) in enumerate(worklist):
mark = str(_spu.get("mark") or "").strip() or "1"
folder = mark_map.get(mark, category)
try:
pool_imgs = find_model_images_for_mark(db_path, material_root, mark, folder)
except Exception: # noqa: BLE001
pool_imgs = []
if pool_imgs:
model_assign[_i] = random.choice(pool_imgs) # 过滤后随机抽(按任务序号)
if model_assign:
print(f"[product] 任务级模特分配:{len(model_assign)} 个产品任务(按 mark 过滤 3:4 后随机抽取)")
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, "prompts": prompts_cfg}
print(f"[product] 任务级图源分配:{len(model_assign)} 个产品任务(mark_dirs 模特/平铺图随机抽,含 kind)")
else:
print("[product] material_library 无任何可用图(模特/平铺均无)→ 跳过合成,仅导出模板")
# 5) compose 节点生成的共享设计稿(图2):每个任务用自己的热点简报设计(tb.design_path),
# 一个货号对应一个设计(多颜色共用该设计),不再所有产品共用第一个
@@ -571,10 +622,13 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
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=model_assign.get(wi), # 按任务序号取独立随机模特
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: