v110-v112 自定义模式完善 + 模板导出增强 + 多模态兼容优化

- 自定义模式:分析模型输出 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=运动
- 童装特征库更新 + 标题模板外部化 + 图源映射增强
This commit is contained in:
2026-09-03 18:28:39 +08:00
parent d68cc3b9e3
commit 5ab5cf6586
40 changed files with 1967 additions and 149 deletions
+22 -12
View File
@@ -125,7 +125,8 @@ def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
seed: Optional[int] = None,
on_400=None,
on_503=None,
size: str = "1024x1024") -> Optional[str]:
size: str = "1024x1024",
custom_mode: bool = False) -> Optional[str]:
"""生成单张纯印花设计稿(图2)。返回设计稿路径;失败 / 全局 MD5 重复返回 None。
out_stem: 输出文件名主干(不含扩展名),最终文件 = {out_stem}_design.png。
@@ -146,16 +147,24 @@ def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
ref_images = [str(p) for p in (brief.get("ref_images") or []) if str(p)]
if ref_images and hasattr(ib, "print"):
try:
# 图生图:以爬取图为参考,按分析简报生成原创设计(不复制原图)
ref_prompt = img_prompt + (
" Create an ORIGINAL, non-copying flat print design inspired ONLY by "
"the reference image's style and mood. Do NOT reproduce the reference "
"image, its characters, logos, or any text.")
out_path = ib.print(
ref_prompt, ref_images[0], out_path,
brief.get("composite_negative", ""),
extra_images=ref_images[1:] or None,
size=size, seed=seed) # 设计稿尺寸按 config compose.design_size
if custom_mode:
# 自定义模式:按 image_prompt 图生图(模板已内置防复制/防服装约束,不追加原创化指令)
out_path = ib.print(
img_prompt, ref_images[0], out_path,
brief.get("composite_negative", ""),
extra_images=ref_images[1:] or None,
size=size, seed=seed) # 设计稿尺寸按 config compose.design_size
else:
# Pinterest 模式:追加原创化指令(防止复制原图,保持原有行为)
ref_prompt = img_prompt + (
" Create an ORIGINAL, non-copying flat print design inspired ONLY by "
"the reference image's style and mood. Do NOT reproduce the reference "
"image, its characters, logos, or any text.")
out_path = ib.print(
ref_prompt, ref_images[0], out_path,
brief.get("composite_negative", ""),
extra_images=ref_images[1:] or None,
size=size, seed=seed) # 设计稿尺寸按 config compose.design_size
except Exception as e: # noqa: BLE001
print(f"[compose] 图生图(参考图)失败,回退文生图 {brief.get('topic','')}: {e}")
_notify_400(on_400, e)
@@ -255,7 +264,8 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
"""单张设计稿生成(并发线程内调用,每设计一线程)。"""
out_path = generate_design(ib, b, design_dir, f"{country}_{i:02d}",
_safe_errors, seed=_seed,
size=compose_cfg.get("design_size", "1024x1024"))
size=compose_cfg.get("design_size", "1024x1024"),
custom_mode=bool(state.get("custom_mode")))
if out_path is None:
return i, b, None, None
return i, b, out_path, None
+3 -3
View File
@@ -63,9 +63,9 @@ def oss_upload_node(state: Dict[str, Any]) -> Dict[str, Any]:
print(f"[oss] 货号计数已达上限 999,停止上传后续图片({src}")
break
try:
code = f"{prefix}{seq:03d}" # 货号:前缀 + 3 位计数(000 起)
code = str(r.get("img_code") or "") or f"{prefix}{seq:03d}" # 货号:优先用 product 生成的 img_code,缺省自增
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
key = build_oss_key(country, ts, code, _gen_rand4())
key = build_oss_key(country, ts, code, _gen_rand4(), local=compressed)
url = upload_to_oss(oss_cfg, compressed, key)
if url:
r[f"{kind}_url"] = url
@@ -91,7 +91,7 @@ def oss_upload_node(state: Dict[str, Any]) -> Dict[str, Any]:
try:
code = f"{prefix}{seq:03d}"
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
key = build_oss_key(country, ts, code, _gen_rand4())
key = build_oss_key(country, ts, code, _gen_rand4(), local=compressed)
url = upload_to_oss(oss_cfg, compressed, key)
if url:
cc["url"] = url
+1
View File
@@ -75,6 +75,7 @@ def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str,
"design_category": classify(term),
"concept": f"围绕「{term}」的原创印花设计",
"image_prompt": str(b.get("image_prompt") or "").strip(),
"delta": str(b.get("delta") or "").strip(),
"ref_images": [str(p) for p in (b.get("ref_images") or []) if str(p)],
"source_md5": str(b.get("source_md5") or "").strip().lower(),
"brief_id": str(b.get("brief_id") or "").strip(),
+14 -2
View File
@@ -195,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",
on_503=None, model_kind="model", category_path="",
) -> Optional[Dict[str, Any]]:
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
@@ -434,7 +434,7 @@ def _process_spu(
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)
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", "")
@@ -482,6 +482,17 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
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:
@@ -634,6 +645,7 @@ 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"),
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:
+33 -18
View File
@@ -39,9 +39,9 @@ REVIEW_REBRAND_HINT = (
"a generic, non-infringing homage in the same mood, clearly distinct from the original."
)
# —— 自定义模式(custom)生图模板:固定前缀 + 分析模型 image_prompt + 固定负向 ——
# 自定义模式分析模型产出的是「新设计描述」,生图时套用这套固定模板(含防复制/防服装约束),
# 负向用固定文本(写入 composite_negativecompose 生图时作为负向参数传给图像后端)
# —— 自定义模式(custom)生图模板:固定前缀 + 分析模型 image_prompt + delta 改动指令 ——
# 自定义模式分析模型产出的是「新设计描述 + 唯一改动指令 delta」,生图时套用这套固定模板
# (含防复制/防服装约束),不再使用负向提示词——约束已全部内置进模板
CUSTOM_IMAGE_PROMPT_TEMPLATE = (
"Use the attached bestseller product photo only as loose inspiration for "
"overall mood, theme, era and style genre — do NOT reproduce, trace, "
@@ -53,11 +53,6 @@ CUSTOM_IMAGE_PROMPT_TEMPLATE = (
"base the new design on THOSE observed traits. Ignore the model, background "
"and photo quality. Then: {image_prompt}"
)
CUSTOM_NEGATIVE_PROMPT = (
"copy of reference artwork, lookalike of the bestseller print, characters, "
"mascots, likenesses, logos, trademarks, watermark, photorealistic shirt, "
"apparel, product mockup, model, garment, hanger, busy background"
)
# 图像生成策略敏感词 → 安全等效描述(生成设计稿前清洗 motif,
# 避免 gpt-image 等内容策略频繁拦截导致"生图限制多")
@@ -84,6 +79,18 @@ def _safe_motif(motif: str) -> str:
return motif
def _read_custom_prompt_md(filename: str) -> str:
"""读取 prompts/<filename>(自定义模式生图模板),优先运行根 exe 旁、回退数据根;无/空返回空串。"""
from graph.paths import project_root, runtime_root
for base in (runtime_root(), project_root()):
p = base / "prompts" / filename
if p.exists():
t = p.read_text(encoding="utf-8").strip()
if t:
return t
return ""
@with_fallback("prompt_build")
def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
screened: List[Dict[str, Any]] = state.get("screened") or []
@@ -101,8 +108,11 @@ def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
print_suffix = (pp.get("print_suffix") or "").strip() or PINTEREST_PRINT_SUFFIX
spelling_rule = (pp.get("spelling_rule") or "").strip() or SPELLING_RULE
review_rebrand_hint = (pp.get("review_rebrand_hint") or "").strip() or REVIEW_REBRAND_HINT
custom_ip_tpl = (pp.get("image_prompt_template") or "").strip() or CUSTOM_IMAGE_PROMPT_TEMPLATE
custom_neg = (pp.get("negative_prompt") or "").strip() or CUSTOM_NEGATIVE_PROMPT
# 自定义模式生图模板优先读 prompts/custom_image_prompt.md(可编辑),
# 其次 config.custom.prompt_pieces,最后回退代码内置默认。
# 自定义模式不再使用负向提示词(约束已内置进模板),不再读取 custom_negative_prompt.md。
custom_ip_tpl = _read_custom_prompt_md("custom_image_prompt.md") \
or (pp.get("image_prompt_template") or "").strip() or CUSTOM_IMAGE_PROMPT_TEMPLATE
briefs: List[Dict[str, Any]] = []
for r in screened:
@@ -122,15 +132,20 @@ def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
prompts = assemble_prompts(motif, art_style, palette, composition, tpls, country)
llm_ip = (r.get("image_prompt") or "").strip()
if r.get("source") == "pinterest" and llm_ip:
delta = (r.get("delta") or "").strip()
if r.get("source") == "pinterest":
if bool(state.get("custom_mode")):
# —— 自定义模式:固定模板(前缀 + 分析 image_prompt),负向用固定文本 ——
# 模板含防复制/防服装约束,不再追加 print_suffix;负向写入 composite_negative
# compose 生图时作为负向参数传给图像后端。
prompts["image_prompt"] = custom_ip_tpl.replace("{image_prompt}", llm_ip)
prompts["composite_negative"] = custom_neg
print(f"[prompt] 自定义模式按固定模板拼 image_prompt(含固定负向): 「{r['topic']}")
else:
# —— 自定义模式:固定模板(前缀 + 分析 image_prompt + delta 改动指令),负向留空 ——
# 新分析模板只产出 delta(无 image_prompt),生图模板也只引用 {delta};
# 只要 delta 或 image_prompt 任一存在即可装配,不再使用负向提示词
# (约束已全部内置进模板),composite_negative 置空避免后端追加 Negative 段。
if llm_ip or delta:
prompts["image_prompt"] = (
custom_ip_tpl.replace("{delta}", delta).replace("{image_prompt}", llm_ip)
)
prompts["composite_negative"] = ""
print(f"[prompt] 自定义模式按固定模板拼 image_prompt(含 delta 改动指令,无负向): 「{r['topic']}")
elif llm_ip:
# —— Pinterest 参考模式:跳过四要素模板,按 3 段结构拼 image_prompt ——
# ① image_prompt(分析模型产出) + 固定输出形态后缀 PINTEREST_PRINT_SUFFIX
# ② 拼写锁定句 SPELLING_RULE(仅当 LLM image_prompt 已含引号文字段时)
+5 -3
View File
@@ -164,7 +164,7 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
try:
compressed = compress_for_oss(pth, str(Path(pth).with_suffix(".oss.jpg")))
url = upload_to_oss(oss_cfg, compressed,
build_oss_key(country, ts, code, _gen_rand4()))
build_oss_key(country, ts, code, _gen_rand4(), local=compressed))
if url:
urls.append(url)
r["seed_shot_urls"] = urls
@@ -175,8 +175,10 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
return {"spu_code": r.get("spu_code"), "sku_code": r.get("sku_code"),
"paths": paths, "urls": urls}
# 并发:每个产品一个独立线程默认);config.seed_shot.concurrency 可覆盖
seed_concurrency = int((config.get("seed_shot") or {}).get("concurrency") or 0) or len(products) or 1
# 并发:每个产品一个独立线程默认上限 5(与 product 一致,避免多产品压垮图像网关),
# config.seed_shot.concurrency 可显式覆盖(含 0/留空→默认 5)
seed_concurrency = int((config.get("seed_shot") or {}).get("concurrency") or 0) or 5
seed_concurrency = min(seed_concurrency, len(products)) if products else 1
if len(products) > 1:
print(f"[seed_shot] 并发 {seed_concurrency} 生成种草图({len(products)} 个产品)")
with concurrent.futures.ThreadPoolExecutor(max_workers=seed_concurrency) as _ex:
+3 -1
View File
@@ -101,7 +101,8 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
from graph.template_export import (export_products, _resolve_component_map,
_resolve_season_map, _resolve_pattern_map,
_resolve_target_audience_map, _resolve_kids_type_map)
_resolve_target_audience_map, _resolve_kids_type_map,
_resolve_kids_pattern_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)
@@ -159,6 +160,7 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
pattern_map=_resolve_pattern_map(config),
target_audience_map=_resolve_target_audience_map(config),
kids_type_map=_resolve_kids_type_map(config),
kids_pattern_map=_resolve_kids_pattern_map(config),
)
for r in products:
if (r.get("composite_path") or r.get("printed_path")) and (r.get("en_title") or "").strip():