diff --git a/README.md b/README.md index 0e8a71b..a159ba5 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,38 @@ START → pinterest_init → pinterest_search → pinterest_scrape → pinterest `scrape_concurrency`(爬图并发,必须=1)、`err400_limit`(400 超限放弃当前种子词)。 **每个节点都用 `graph/validate.with_fallback` 包裹**:任何未预料异常都被捕获、记入 -`state['errors']`、返回最小更新,整图继续往下走,单点故障不中断流水线。 +`state['errors']`、返回最小更新,整图继续往下走,单点故障不中断流水线。致命图像服务错误(503 / +"No available compatible accounts")除外——`with_fallback` 不再静默吞掉,会同步标记流水线终止, +让路由短路到 `pinterest_finalize → template_export` 提前收尾合成模板。 + +### 模式 B-2:自定义图片模式(Custom) + +无需采集/搜索图片,把用户本地图片文件夹里的**有效图片**直接送进多模态分析,沿用 Pinterest +后续所有步骤(分析 → 设计稿 → 三合一 → OSS → 种草图 → 模板导出)。 + +``` +START → pinterest_init → pinterest_custom_load → pinterest_analyze + → [pinterest_custom_route 循环:图池有未消费图 → 继续分析;图池空/简报达标 → 结束] + → pinterest_finalize → template_export → END +``` + +| 节点 | 职责 | +|---|---| +| `pinterest_custom_load` | 读取 `pinterest.custom_image_dir` 文件夹 → 统计有效图片 → **数量硬校验** → 每次重建图池 + 清空已消费拉黑 | + +**启用方式**(两种任选): +- 配置 `config.yaml` 的 `pinterest.mode: "custom"` 并填 `pinterest.custom_image_dir: "<文件夹路径>"`; +- 或直接在 UI 选择「**自定义图片**」流程 →「📁 选图片文件夹…」选择文件夹后运行(自动写入配置)。 + +**数量硬校验(必过)**: +- 必须先选定上传的文件夹(UI 未选会弹窗报错,任务不启动); +- 文件夹内所有有效图片(`jpg/jpeg/png/webp`,递归)数量必须 `> 0`; +- **选品清单总数(`product.spu_tasks` 展开后)不得大于有效图片数**。不满足则在 + UI 弹窗 / `run_pinterest_ref` 提前抛错 / `custom_load` 节点记录错误,三重拦截,任务不进入分析。 + +**行为差异**:自定义模式每次运行把图池重建为该文件夹的图片集(唯一图源)并清空已消费 md5 拉黑, +保证用户每次重新上传/选择的所有有效图片都会被重新多模态分析;分析批上限按「选品清单总数」截断, +避免对多余图片浪费配额。`pinterest_custom_route` 只在 wait / analyze / done 间流转,从不 search。 ## 目录结构 @@ -82,6 +113,8 @@ pod_trend_agent/ ├── prompts// # ★ 每个国家不同的提示词单独文件夹 │ ├── system_prompt.md # 该国 LLM 系统提示(补充段,叠加到默认规则) │ └── aesthetics.yaml # 审美 hint + 风格-配色 extra 规则 + 额外黑名单 +├── prompts/pinterest_analyze_system.md # 多模态分析系统提示词(Pinterest/自定义模式),可配置 +├── prompts/pinterest_analyze_user.md # 多模态分析用户提示词,可配置 ├── graph/ │ ├── state.py # AgentState(共享状态) │ ├── validate.py # with_fallback 兜底 + 数据校验 @@ -134,6 +167,23 @@ pod_trend_agent/ 每完成一个产品立即追加落盘到 `output///products_pending.jsonl`(中断/崩溃也不丢已 完成产品),全部完成后统一合并写模板。 +## 多模态分析提示词(可配置) + +Pinterest 参考模式与自定义图片模式的多模态分析提示词已**外部化为配置**,无需改代码即可调优: + +- `prompts/pinterest_analyze_system.md`——多模态分析**系统提示词**(POD T 恤设计分析师角色 + + 禁止抄袭/违禁项/输出 JSON 结构等规则); +- `prompts/pinterest_analyze_user.md`——**用户提示词**("Analyze the attached image and produce + one ORIGINAL T-shirt print design brief...")。 + +**加载优先级**(`graph/llms/openai_compat_backend.py`): +1. `prompts/<国家>/pinterest_analyze_*.md`(国家专属覆盖); +2. `prompts/pinterest_analyze_*.md`(全局配置,先查运行根 exe 旁再查内置); +3. 内置默认常量(兜底,向前兼容)。 + +直接编辑这两份文件即可自定义分析与生图引导规则;`pinterest_analyze_system.md` 里可调整 +`image_prompt` 二段式结构 / `negative_prompt` / `suitable_for_print` 判定标准等约束。 + ## 生成可靠性 / 错误处理 - **致命 503(图像服务不可用,如 "No available compatible accounts")**:端到端识别 @@ -145,8 +195,11 @@ pod_trend_agent/ - **yunfei / 标准 OpenAI 网关适配**:`compose.execution_mode` 与 `background` 参数默认不再传入—— yunfei 等网关不认识它们会返回空 body;`base_url` 需带 `/v1` 前缀(如 `https://img.yunfei.best/v1`),否则请求错误路径得到空响应。 -- **模特分配**:`material_library/<品类>` 内的合格模特图(3:4 比例过滤)按**任务序号**独立随机, - 同一 SPU 的多款不再共用同一张模特。每张种草图随机取不同模特特征。 +- **图源映射(可配置)**:`config.product.mark_dirs` 定义 SPU.mark → `material_library` 下的 + `model_dir`(模特图)/ `flat_dir`(平铺图)两个图源文件夹及对应合成提示词。热点采集与 Pinterest + 两种模式统一走该映射:在「有图的文件夹」间按**任务序号**独立随机抽图,抽到模特图用模特三图提示词 + (图1模特+图2印花+图3底图),抽到平铺图用平铺三图提示词(图1平铺实拍+图2印花+图3底图)。 + 同一 SPU 的多款不再共用同一张图;某类目录无图则只用另一类。每张种草图随机取不同模特特征。 ## 模特性别分组 @@ -180,7 +233,8 @@ pod_trend_agent/ - **胸围识别**:识别「基码表-胸围(cm)」「胸围全围(cm)」等含「胸围」的列,全部填 `sku.bust` 值; - **申报价格**:模糊匹配所有含「申报价格」的列,统一按加价后价格填写(`price × (1+markup_percent%)`); - **款式来源**:SPU 行「款式来源」统一填「现货款」; -- **商品产地**:国家简称映射正式名称(如「沙特」→「沙特阿拉伯」,`_COUNTRY_NAME_MAP` 可扩展); +- **商品产地**:所有国家统一填「中国大陆」;新增「产地省份」精确匹配列,统一填「广东省」(不再读站点/做字典映射); +- **SKU 分类 / SKU 数量 / SKU 数量单位**:按「建议售价」同一套路检测——关键词模糊匹配定位列,读取列头下一行备注判断是否必填(含「非必填」才跳过),检测到且为必填时统一填固定值:`SKU分类=单品`、`SKU数量=1`、`SKU数量单位=件`; - **详情图文**:由全部主图 + 种草图组成(不再拼接 img_url_2); - 规格类型2 = `{size}*{color}`;币种=CNY;发货仓1~N 取模板顶头按「、」分隔,库存均 200; - 模板文件被占用(打开中)时自动换名 `_已填写_N.xlsx`,不中断导出; @@ -220,7 +274,7 @@ python ui_app.py python ui_app.py --self-test ``` -UI 功能:国家多选、**流程选择(热点采集 / Pinterest 参考模式)**、LLM 后端选择、种子上限、 +UI 功能:国家多选、**流程选择(热点采集 / Pinterest 参考模式 / 自定义图片模式)**、LLM 后端选择、种子上限、 **SPU/颜色选品**、运行(后台线程 + 实时日志)、结果表格(双击看完整提示词)、打开产物目录。 ## 打包 diff --git a/basemap/C1717/C1717-BLUE JEAN/平铺正面.jpg b/basemap/C1717/C1717-BLUE JEAN/平铺正面.jpg new file mode 100644 index 0000000..d600cd3 Binary files /dev/null and b/basemap/C1717/C1717-BLUE JEAN/平铺正面.jpg differ diff --git a/basemap/C1717/C1717-Black/平铺正面.jpg b/basemap/C1717/C1717-Black/平铺正面.jpg new file mode 100644 index 0000000..1ec183d Binary files /dev/null and b/basemap/C1717/C1717-Black/平铺正面.jpg differ diff --git a/basemap/C1717/C1717-ESPRESSO/平铺正面 (2).jpg b/basemap/C1717/C1717-ESPRESSO/平铺正面 (2).jpg new file mode 100644 index 0000000..827fbd5 Binary files /dev/null and b/basemap/C1717/C1717-ESPRESSO/平铺正面 (2).jpg differ diff --git a/basemap/C1717/C1717-RED/平铺正面 (3).jpg b/basemap/C1717/C1717-RED/平铺正面 (3).jpg new file mode 100644 index 0000000..e5e94b0 Binary files /dev/null and b/basemap/C1717/C1717-RED/平铺正面 (3).jpg differ diff --git a/basemap/C1717/C1717-White/平铺正面 (1).jpg b/basemap/C1717/C1717-White/平铺正面 (1).jpg new file mode 100644 index 0000000..f180c59 Binary files /dev/null and b/basemap/C1717/C1717-White/平铺正面 (1).jpg differ diff --git a/basemap/DEHM003/DEHM003-BL01/黑前面1.jpg b/basemap/DEHM003/DEHM003-BL01/黑前面1.jpg new file mode 100644 index 0000000..200d510 Binary files /dev/null and b/basemap/DEHM003/DEHM003-BL01/黑前面1.jpg differ diff --git a/basemap/DEHM003/DEHM003-WH01/白1.jpg b/basemap/DEHM003/DEHM003-WH01/白1.jpg new file mode 100644 index 0000000..a29da7e Binary files /dev/null and b/basemap/DEHM003/DEHM003-WH01/白1.jpg differ diff --git a/basemap/DG013/DG013-BL01/C00A7010.jpg b/basemap/DG013/DG013-BL01/C00A7010.jpg new file mode 100644 index 0000000..df2cd75 Binary files /dev/null and b/basemap/DG013/DG013-BL01/C00A7010.jpg differ diff --git a/basemap/DG013/DG013-GR02/C00A6995.jpg b/basemap/DG013/DG013-GR02/C00A6995.jpg new file mode 100644 index 0000000..1176ea3 Binary files /dev/null and b/basemap/DG013/DG013-GR02/C00A6995.jpg differ diff --git a/basemap/DG013/DG013-NG01/C00A7000.jpg b/basemap/DG013/DG013-NG01/C00A7000.jpg new file mode 100644 index 0000000..38ba144 Binary files /dev/null and b/basemap/DG013/DG013-NG01/C00A7000.jpg differ diff --git a/basemap/DG013/DG013-RE01/C00A6991.jpg b/basemap/DG013/DG013-RE01/C00A6991.jpg new file mode 100644 index 0000000..6e2854f Binary files /dev/null and b/basemap/DG013/DG013-RE01/C00A6991.jpg differ diff --git a/basemap/DG013/DG013-VT01/C00A7005.jpg b/basemap/DG013/DG013-VT01/C00A7005.jpg new file mode 100644 index 0000000..42a9dbf Binary files /dev/null and b/basemap/DG013/DG013-VT01/C00A7005.jpg differ diff --git a/basemap/JSA002/JSA002-BE01/52428e584b2b905371cef01fba3a07a2.jpg b/basemap/JSA002/JSA002-BE01/52428e584b2b905371cef01fba3a07a2.jpg new file mode 100644 index 0000000..d62ec17 Binary files /dev/null and b/basemap/JSA002/JSA002-BE01/52428e584b2b905371cef01fba3a07a2.jpg differ diff --git a/basemap/JSA002/JSA002-GR04/23d938e50175e00cb15f0b4db82a98ff.jpg b/basemap/JSA002/JSA002-GR04/23d938e50175e00cb15f0b4db82a98ff.jpg new file mode 100644 index 0000000..3ae34c8 Binary files /dev/null and b/basemap/JSA002/JSA002-GR04/23d938e50175e00cb15f0b4db82a98ff.jpg differ diff --git a/basemap/JSA002/JSA002-LIGHT PINK/7df97161a196d6202c97d6bd251704c5.jpg b/basemap/JSA002/JSA002-LIGHT PINK/7df97161a196d6202c97d6bd251704c5.jpg new file mode 100644 index 0000000..75f2dd4 Binary files /dev/null and b/basemap/JSA002/JSA002-LIGHT PINK/7df97161a196d6202c97d6bd251704c5.jpg differ diff --git a/basemap/JSA002/JSA002-RED/0893ab42dc02a1cfeec7ce30cd027de5.jpg b/basemap/JSA002/JSA002-RED/0893ab42dc02a1cfeec7ce30cd027de5.jpg new file mode 100644 index 0000000..792a939 Binary files /dev/null and b/basemap/JSA002/JSA002-RED/0893ab42dc02a1cfeec7ce30cd027de5.jpg differ diff --git a/basemap/JSA002/JSA002-Yellow Haze/5453ad06f9041b35016546adc0bf3d82.jpg b/basemap/JSA002/JSA002-Yellow Haze/5453ad06f9041b35016546adc0bf3d82.jpg new file mode 100644 index 0000000..ad22958 Binary files /dev/null and b/basemap/JSA002/JSA002-Yellow Haze/5453ad06f9041b35016546adc0bf3d82.jpg differ diff --git a/config.yaml b/config.yaml index aacaba8..287a57d 100644 --- a/config.yaml +++ b/config.yaml @@ -39,8 +39,13 @@ seed_provider_cfg: # 按需搜索:每次只生成 1 个搜索词,用完(爬取成功)才标记已用;简报不足时循环再搜,直到满足 SPU 数量。 pinterest: enabled: true + mode: "scrape" # Pinterest 模式:scrape=在线爬图引用(原流程);custom=自定义本地图片文件夹直接送多模态分析 + # custom 模式:无需采集/搜索,把 custom_image_dir 文件夹内所有有效图片直接进多模态分析,沿用 Pinterest 后续所有步骤 + custom_image_dir: "" # 自定义模式图片文件夹路径(必须填写;文件夹内所有有效图片 jpg/jpeg/png/webp 都会进入分析) + # 数量校验:有效图片数必须 ≥ 选品清单(spu_tasks 展开后)总数,否则任务不启动 provider: openai # 搜索词/图片分析用 LLM 提供商(openai / mock) - search_mode: direct # 搜索词生成方式:direct=跳过LLM,直接搜「种子词 t-shirt design」;llm=LLM按需生成搜索词 + search_mode: direct # 搜索词生成方式:direct=跳过LLM,直接搜「种子词+后缀」;llm=LLM按需生成搜索词 + search_term_suffix: " t-shirt design" # Pinterest 搜索词自动追加的自定义后缀(可编辑;留空=不追加),保证 Pinterest 返回真正的印花图 search_terms_per_run: 1 # 每次搜索词数量(direct 模式=从种子池随机取 N 个直接拼后缀;llm 模式=每次生成 1 个) max_search_rounds: 0 # 搜索轮次上限(0=自动:目标 SPU 数×2,至少 5;防网络故障无限循环) seed_sample: 40 # 每次从国家种子池随机抽取多少个种子词给 LLM @@ -57,7 +62,7 @@ pinterest: headless: false # 爬取时是否无头(false=显示 Chrome 窗口,首次需手动登录) proxy: "" # 图片下载代理(空=自动探测系统代理/VPN,默认走代理;配了如 http://127.0.0.1:7890 则用指定代理) login_check: true # 爬取前静态检测 Pinterest 登录态(只读 .chrome_session cookies,不启动 Chrome;未登录则跳过本轮爬取并告警) - login_wait: false # 运行时检测到未登录时:false=跳过并告警(不阻塞);true=弹出 Chrome 等待手动登录 + login_wait: true # 运行时检测到未登录时:false=跳过并告警(不阻塞);true=弹出 Chrome 等待手动登录 # 跨源融合权重(按 source 标签,无需和为 1) # 已下调 gt_trending(泛国家热点只作微弱信号),主力偏向 style+related(可印花型词)。 @@ -205,7 +210,19 @@ product: db_path: "db/spu_sku.db" basemap_dir: "basemap" material_library_dir: "material_library" - model_category: "T-shirt" # 模特目录优先品类;为空/无图时取 material_library 第一个有图的子目录;SPU.mark==1 才启用模特试穿 + model_category: "T-shirt" # 备用模特目录优先品类;为空/无图时取 material_library 第一个有图的子目录;SPU.mark==1 才启用试穿/平铺合成 + # 三方关系映射:SPU.mark → material_library 下的图文件夹 + 对应提示词。 + # 每个 mark 可配置两个图源(模特图文件夹 / 平铺图文件夹),运行时在「有图的文件夹」间随机抽图: + # - 抽到 model_dir(模特图)→ 用 model_prompt(模特三图:图1模特+图2印花+图3底图) + # - 抽到 flat_dir(平铺图) → 用 flat_prompt(平铺三图:图1平铺实拍+图2印花+图3底图) + # model_prompt/flat_prompt 留空则用内置默认提示词(MODEL_WEAR_PROMPT / FLAT_LAY_PROMPT)。 + # 该映射完全可配置、不写死在代码里。 + mark_dirs: + "1": + model_dir: "模特图" # 模特图文件夹名(material_library 下) + flat_dir: "平铺图" # 平铺图文件夹名(material_library 下) + model_prompt: "" # 留空=内置模特三图提示词 MODEL_WEAR_PROMPT + flat_prompt: "" # 留空=内置平铺三图提示词 FLAT_LAY_PROMPT brief_index: 0 # 用第几个 safe 简报的提示词生成(0=第一个) spu_code: "" # 款号(留空自动选第一个有本地底图的) sku_code: "" # 颜色编码(留空自动选该款第一个有底图的);多个颜色用逗号分隔(如 "DG015-VT01,DG015-DARK HEATHER") diff --git a/db/spu_sku.db b/db/spu_sku.db index ad851cb..f208454 100644 Binary files a/db/spu_sku.db and b/db/spu_sku.db differ diff --git a/graph/agent.py b/graph/agent.py index 0bb34bf..12c4d00 100644 --- a/graph/agent.py +++ b/graph/agent.py @@ -117,18 +117,57 @@ def _pinterest_route(state: Dict[str, Any]) -> str: return "search" -def build_pinterest_graph(): +def _pinterest_custom_route(state: Dict[str, Any]) -> str: + """自定义模式图池路由:只消耗本地图片池,从不搜索/采集。 + + 简报池还有在途 → wait(等待后台消化);简报达标 → done; + 简报池空闲 + 图池有未消费图片 → analyze;图池空 → done(自定义模式不采集)。 + 致命 503(图像服务不可用)→ 立即终止,直接收尾合成模板。 + """ + pipe = state.get("pinterest_pipeline") + if pipe is not None and hasattr(pipe, "is_fatal_503_aborted") and pipe.is_fatal_503_aborted(): + print("[pinterest_custom_route] ⛔ 图像服务 503 已终止任务,停止分析,直接收尾合成模板") + return "done" + + target = int(state.get("pinterest_target") or 1) or 1 + briefs = state.get("briefs") or [] + if len(briefs) >= target: + print(f"[pinterest_custom_route] 简报已达目标 {len(briefs)}/{target},结束") + return "done" + if pipe is not None and hasattr(pipe, "pending_count") and pipe.pending_count() > 0: + return "wait" + try: + from graph.pinterest import load_image_pool, load_used_images, pool_unused_images + pool = load_image_pool(str(state.get("output_dir") or ""), state.get("country") or "") + used = load_used_images(str(state.get("output_dir") or ""), state.get("country") or "") + unused = pool_unused_images(pool, used) + except Exception: # noqa: BLE001 + unused = [] + if unused: + print(f"[pinterest_custom_route] 图池还有 {len(unused)} 张未消费图片,继续分析(简报 {len(briefs)}/{target})") + return "analyze" + print(f"[pinterest_custom_route] 图池已空,简报 {len(briefs)}/{target},结束(自定义模式不采集)") + return "done" + + +def build_pinterest_graph(custom_mode: bool = False): """Pinterest 参考模式图(按需搜索循环 + 简报池并发生成): pinterest_init(建简报池)→ pinterest_search → pinterest_scrape → pinterest_analyze → [pinterest_route] 简报池还有在途 → wait(等待后台消化)→ 回到路由; 简报池空闲 + 图池有图 → analyze;图池空 + 简报不足 → search;达标 → pinterest_finalize (排空简报池、后台并发生成 设计→三合一→OSS→种草图)→ template_export + + custom_mode=True:自定义模式,不搜索不采集 —— + pinterest_init → pinterest_custom_load(本地文件夹校验+入库)→ pinterest_analyze + → [pinterest_custom_route] wait / analyze / done(图池空或简报达标即结束,从不 search)。 + 其余下游流程(analyze→设计→三合一→OSS→种草图→模板)与普通 Pinterest 模式完全一致。 """ from graph.nodes import ( pinterest_analyze_node, pinterest_scrape_node, pinterest_search_node, ) + from graph.nodes import pinterest_custom_load_node from graph.nodes.pinterest_finalize_node import pinterest_finalize_node from graph.nodes.pinterest_init_node import pinterest_init_node @@ -149,23 +188,34 @@ def build_pinterest_graph(): builder = StateGraph(AgentState) builder.add_node("pinterest_init", pinterest_init_node) - builder.add_node("pinterest_search", pinterest_search_node) - builder.add_node("pinterest_scrape", pinterest_scrape_node) builder.add_node("pinterest_analyze", pinterest_analyze_node) builder.add_node("pinterest_wait", _pinterest_wait) builder.add_node("pinterest_finalize", pinterest_finalize_node) builder.add_node("template_export", template_export_node) - builder.add_edge("__start__", "pinterest_init") - builder.add_edge("pinterest_init", "pinterest_search") - builder.add_edge("pinterest_search", "pinterest_scrape") - builder.add_edge("pinterest_scrape", "pinterest_analyze") - builder.add_conditional_edges("pinterest_analyze", _pinterest_route, { - "analyze": "pinterest_analyze", # 简报池空闲 + 图池有未消费图片 → 补分析(不搜索) - "search": "pinterest_search", # 简报池空闲 + 图池空 + 简报不足 → 新一轮搜索 - "wait": "pinterest_wait", # 简报池还有在途 → 等待后台消化 - "done": "pinterest_finalize", # 简报达标/轮次耗尽 → 收尾(排空简报池) - }) + if custom_mode: + builder.add_node("pinterest_custom_load", pinterest_custom_load_node) + builder.add_edge("__start__", "pinterest_init") + builder.add_edge("pinterest_init", "pinterest_custom_load") + builder.add_edge("pinterest_custom_load", "pinterest_analyze") + builder.add_conditional_edges("pinterest_analyze", _pinterest_custom_route, { + "analyze": "pinterest_analyze", + "wait": "pinterest_wait", + "done": "pinterest_finalize", + }) + else: + builder.add_node("pinterest_search", pinterest_search_node) + builder.add_node("pinterest_scrape", pinterest_scrape_node) + builder.add_edge("__start__", "pinterest_init") + builder.add_edge("pinterest_init", "pinterest_search") + builder.add_edge("pinterest_search", "pinterest_scrape") + builder.add_edge("pinterest_scrape", "pinterest_analyze") + builder.add_conditional_edges("pinterest_analyze", _pinterest_route, { + "analyze": "pinterest_analyze", # 简报池空闲 + 图池有未消费图片 → 补分析(不搜索) + "search": "pinterest_search", # 简报池空闲 + 图池空 + 简报不足 → 新一轮搜索 + "wait": "pinterest_wait", # 简报池还有在途 → 等待后台消化 + "done": "pinterest_finalize", # 简报达标/轮次耗尽 → 收尾(排空简报池) + }) builder.add_edge("pinterest_wait", "pinterest_analyze") builder.add_edge("pinterest_finalize", "template_export") builder.add_edge("template_export", END) @@ -231,14 +281,39 @@ def run_pinterest_ref( project_root: Path, output_root: Optional[Path] = None, task_timestamp: Optional[str] = None, + custom_image_dir: Optional[str] = None, ) -> Dict[str, Any]: """Pinterest 参考模式入口:独立于 Google Trends 的完整流程。 种子词 → LLM 搜索词(json_schema + 动态注入防重复)→ 爬图 → LLM 分析图片 → 设计简报 → 设计稿 → 产品图 → 上传 → 种草图 → 模板导出。 参数语义与 run_country 一致(project_root=数据根,output_root=产物根)。 + + custom_image_dir 非空 或 config.pinterest.mode=="custom" → 进入自定义模式: + 不搜索不采集,直接把指定文件夹的有效图片送多模态分析,沿用 Pinterest 后续所有步骤。 + 数量硬校验(有效图片数 ≥ 选品清单总数)在启动前执行,不满足直接抛错。 """ - compiled = build_pinterest_graph() + target = len((global_config.get("product") or {}).get("spu_tasks") or []) \ + or int((global_config.get("product") or {}).get("spu_count") or 0) or 1 + pinterest_cfg = global_config.get("pinterest") or {} + mode = str(pinterest_cfg.get("mode") or "scrape").strip().lower() + custom_dir = str(custom_image_dir or "").strip() \ + or str(pinterest_cfg.get("custom_image_dir") or "").strip() + custom_mode = bool(custom_dir) or (mode == "custom") + + if custom_mode: + if not custom_dir: + raise ValueError( + "自定义模式需要填写 pinterest.custom_image_dir(选择上传的图片文件夹)," + "当前为空,无法启动") + from graph.pinterest import validate_custom_images + ok, valid_n, msg = validate_custom_images(custom_dir, target) + if not ok: + raise ValueError(f"自定义模式数量校验未通过:{msg}(选品清单总数 {target})") + global_config.setdefault("pinterest", {})["custom_image_dir"] = custom_dir + print(f"[run_pinterest_ref] 自定义模式启用:{custom_dir} 有效图片 {valid_n} 张 ≥ 选品清单 {target}") + + compiled = build_pinterest_graph(custom_mode=custom_mode) cc = build_country_config(global_config, country, project_root) prompts_dir = project_root / "prompts" / country cache_dir = (output_root or project_root) / "output" / country diff --git a/graph/llms/mock_backend.py b/graph/llms/mock_backend.py index cc24945..43aa9e0 100644 --- a/graph/llms/mock_backend.py +++ b/graph/llms/mock_backend.py @@ -152,19 +152,22 @@ class MockBackend: seeds = [str(s).strip() for s in (context.get("seeds") or []) if str(s).strip()] used = {str(u).strip().lower() for u in (context.get("used_terms") or [])} count = int(context.get("count", 10)) + suffix = str(context.get("search_term_suffix") or "").strip() pool = [s for s in seeds if s.lower() not in used] random.shuffle(pool) terms = pool[:count] # 不足时用「种子词 + 风格词」组合补足(视觉导向,避免与已用重复) - style_tail = ["t-shirt design", "graphic tee", "print art", "vintage tee", "flat design"] + style_tail = ["graphic tee", "print art", "vintage tee", "flat design"] i = 0 while len(terms) < count and pool: combo = f"{pool[i % len(pool)]} {style_tail[(i // len(pool)) % len(style_tail)]}" if combo.lower() not in used and combo not in terms: terms.append(combo) i += 1 - # 自动追加 " t-shirt design":让 Pinterest 返回真正的 T 恤印花图(更适合作印花设计参考) - terms = [f"{t} t-shirt design" if "t-shirt design" not in t.lower() else t for t in terms] + # 自动追加自定义后缀(config.pinterest.search_term_suffix,默认 t-shirt design): + # 让 Pinterest 返回真正的 T 恤印花图(更适合作印花设计参考) + if suffix: + terms = [f"{t} {suffix}" if suffix not in t.lower() else t for t in terms] return {"search_terms": terms} def analyze_pinterest_images(self, image_paths, term="", country="", on_400=None): diff --git a/graph/llms/openai_compat_backend.py b/graph/llms/openai_compat_backend.py index f9b8f5f..d2b118d 100644 --- a/graph/llms/openai_compat_backend.py +++ b/graph/llms/openai_compat_backend.py @@ -17,7 +17,7 @@ import requests # 模型调用一律直连:用户常开 VPN(系统代理),LLM 网关多为国内/自建,走代理会被拦截或变慢。 # 仅请求级 proxies=NO_PROXY 直连,不设置进程级 NO_PROXY 环境变量(避免影响 Google Trends 等外部采集)。 from .base import LLMBackend -from graph.paths import runtime_root +from graph.paths import project_root, runtime_root # 直连策略:忽略环境代理(用户挂 VPN 时代理会拦截国内/自建网关的请求) NO_PROXY = {"http": None, "https": None} @@ -260,7 +260,7 @@ that are DIRECTLY usable as reference for a t-shirt print design. RULES: - Generate EXACTLY the requested number of search terms (usually 1 per call). -- Every term MUST be a "t-shirt design" style query: think of it as if the user typed " t-shirt design" on +- Every term MUST be a "__SUFFIX__" style query: think of it as if the user typed "__SUFFIX__" on Pinterest, so the scraped images are actual t-shirt graphics / flat print artworks, NOT lifestyle photos, scenery, architecture, food plates, or anything that cannot become a clean chest print. - Terms MUST be suitable for a SHORT-SLEEVE T-SHIRT PRINT: a flat, graphic, print-ready concept (illustration, mascot, @@ -309,7 +309,7 @@ def build_pinterest_term_user_prompt(context: Dict[str, Any]) -> str: f"Generate {count} new, diverse, non-overlapping Pinterest search term(s) " f"that are suitable for a SHORT-SLEEVE T-SHIRT PRINT design " f"(flat, graphic, print-ready motif that works as a chest print). " - f"Each term should read like \" t-shirt design\" so Pinterest returns " + f"Each term should read like \"__SUFFIX__\" so Pinterest returns " f"actual t-shirt graphics / flat print artwork as reference.", ] return "\n".join(lines) @@ -394,7 +394,35 @@ PINTEREST_ANALYZE_SCHEMA = { } -def build_pinterest_analyze_user_prompt() -> str: +def _pinterest_analyze_prompt_file(country: str, filename: str) -> str: + """定位多模态分析提示词文件:优先运行根 prompts(exe 旁,可编辑),回退数据根 prompts。 + + 支持国家覆盖(prompts//)优先于全局(prompts/)。 + 找不到则返回空串,由调用方回退内置默认。 + """ + for base in (runtime_root(), project_root()): + if country: + p = base / "prompts" / country / filename + if p.exists(): + return p.read_text(encoding="utf-8").strip() + p = base / "prompts" / filename + if p.exists(): + return p.read_text(encoding="utf-8").strip() + return "" + + +def resolve_pinterest_analyze_system_prompt(country: str = "") -> str: + """多模态分析系统提示词(可配置):命中 prompts/pinterest_analyze_system.md(国家覆盖优先), + 否则回退内置 PINTEREST_ANALYZE_SYSTEM_PROMPT。""" + text = _pinterest_analyze_prompt_file(country, "pinterest_analyze_system.md") + return text if text else PINTEREST_ANALYZE_SYSTEM_PROMPT + + +def build_pinterest_analyze_user_prompt(country: str = "") -> str: + """多模态分析用户提示词(可配置):命中 prompts/pinterest_analyze_user.md 否则内置默认。""" + text = _pinterest_analyze_prompt_file(country, "pinterest_analyze_user.md") + if text: + return text return ( "Analyze the attached image and produce one ORIGINAL T-shirt print design brief " "that captures its visual vibe without copying it." @@ -583,15 +611,21 @@ class OpenAICompatBackend(LLMBackend): max_used = int((cfg or {}).get("max_used_terms_in_prompt", 100) or 100) if max_used > 0: ctx["used_terms"] = used[-max_used:] + # 后缀占位符:读 ctx 配置(允许为空=不追加后缀);仅在确有后缀时替换占位 + suffix = str(ctx.get("search_term_suffix") or "").strip() + sys_prompt = PINTEREST_TERM_SYSTEM_PROMPT.replace("__SUFFIX__", f" {suffix}" if suffix else "") + user_prompt = build_pinterest_term_user_prompt(ctx).replace("__SUFFIX__", f" {suffix}" if suffix else "") messages = [ - {"role": "system", "content": PINTEREST_TERM_SYSTEM_PROMPT}, - {"role": "user", "content": build_pinterest_term_user_prompt(ctx)}, + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": user_prompt}, ] raw = _retry(lambda: call_openai_compatible_structured(cfg, messages, PINTEREST_TERM_SCHEMA, timeout=120)) parsed = _extract_json(raw) terms = [str(x).strip() for x in (parsed.get("search_terms", []) or []) if str(x).strip()] - # 自动追加 " t-shirt design":让 Pinterest 返回真正的 T 恤印花图(更适合作印花设计参考) - terms = [f"{t} t-shirt design" if "t-shirt design" not in t.lower() else t for t in terms] + # 自动追加自定义后缀(config.pinterest.search_term_suffix,默认 t-shirt design): + # 让 Pinterest 返回真正的印花图(更适合作印花设计参考) + if suffix: + terms = [f"{t} {suffix}" if suffix not in t.lower() else t for t in terms] return {"search_terms": terms} def analyze_pinterest_images(self, image_paths: List[str], term: str, country: str = "", @@ -650,13 +684,13 @@ class OpenAICompatBackend(LLMBackend): def _call() -> str: user_content: List[Any] = [ - {"type": "text", "text": build_pinterest_analyze_user_prompt()}, + {"type": "text", "text": build_pinterest_analyze_user_prompt(country)}, ] user_content += [{"type": "image_url", "image_url": {"url": u}} for u in data_uris] payload = { "model": model, "messages": [ - {"role": "system", "content": PINTEREST_ANALYZE_SYSTEM_PROMPT}, + {"role": "system", "content": resolve_pinterest_analyze_system_prompt(country)}, {"role": "user", "content": user_content}, ], "temperature": 0.5, diff --git a/graph/nodes/__init__.py b/graph/nodes/__init__.py index 7af9bd4..241559a 100644 --- a/graph/nodes/__init__.py +++ b/graph/nodes/__init__.py @@ -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", ] diff --git a/graph/nodes/pinterest_analyze_node.py b/graph/nodes/pinterest_analyze_node.py index 2f4e859..78ec1b8 100644 --- a/graph/nodes/pinterest_analyze_node.py +++ b/graph/nodes/pinterest_analyze_node.py @@ -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) diff --git a/graph/nodes/pinterest_custom_load_node.py b/graph/nodes/pinterest_custom_load_node.py new file mode 100644 index 0000000..69eec7c --- /dev/null +++ b/graph/nodes/pinterest_custom_load_node.py @@ -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} \ No newline at end of file diff --git a/graph/nodes/pinterest_search_node.py b/graph/nodes/pinterest_search_node.py index 9ebe9ff..d9da492 100644 --- a/graph/nodes/pinterest_search_node.py +++ b/graph/nodes/pinterest_search_node.py @@ -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)} 个") diff --git a/graph/nodes/product_node.py b/graph/nodes/product_node.py index 42d6faa..755ccdb 100644 --- a/graph/nodes/product_node.py +++ b/graph/nodes/product_node.py @@ -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: diff --git a/graph/pinterest.py b/graph/pinterest.py index c2c6462..7222b6c 100644 --- a/graph/pinterest.py +++ b/graph/pinterest.py @@ -179,6 +179,42 @@ def design_md5_ok(image_path: str) -> bool: _IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp") +def list_valid_images(folder: Any) -> List[Path]: + """返回文件夹内所有有效图片文件(扩展名匹配,含子目录递归),用于自定义模式。 + + 供图源目录/自定义模式统计「有效图片数量」与注册图池使用;空/不存在返回空列表。 + """ + d = Path(folder) + if not d.is_dir(): + return [] + out: List[Path] = [] + for f in sorted(d.rglob("*")): + if f.is_file() and f.suffix.lower() in _IMG_EXTS: + out.append(f) + return out + + +def validate_custom_images(folder: Any, target: int) -> tuple: + """自定义模式数量校验:返回 (ok, valid_n, message)。 + + folder 未填/不存在/无有效图片 → 失败;有效图片数 < 选品清单总数 target → 失败 + (「选品清单不得大于有效图片数」硬校验)。供 UI/入口 fail-fast 与节点安全网复用。 + """ + folder_s = str(folder or "").strip() + if not folder_s: + return False, 0, "自定义图片文件夹未填写(pinterest.custom_image_dir / UI 选择上传文件夹)" + if not Path(folder_s).is_dir(): + return False, 0, f"自定义图片文件夹不存在:{folder_s}" + imgs = list_valid_images(folder_s) + n = len(imgs) + if n == 0: + return False, 0, f"自定义图片文件夹「{folder_s}」中没有有效图片(jpg/jpeg/png/webp)" + if target > n: + return False, n, (f"选品清单数量({target})大于自定义图片有效数量({n}):" + f"选品清单不得大于有效图片数,请补充图片或减少选品") + return True, n, f"自定义图片校验通过:有效图片 {n} 张 ≥ 选品清单 {target}" + + def image_pool_path(output_dir: str, country: str) -> Path: return Path(output_dir) / "pinterest_ref" / country / "image_pool.json" diff --git a/graph/pinterest_pipeline.py b/graph/pinterest_pipeline.py index 19dc84c..cf68b4f 100644 --- a/graph/pinterest_pipeline.py +++ b/graph/pinterest_pipeline.py @@ -199,29 +199,50 @@ class PinterestPipeline: return None def _assign_models(self) -> Dict[str, Any]: - """任务级模特分配:按 spu.mark 映射模特目录 → 过滤非 3:4 图片 → 每个任务随机抽取。 + """任务级图源分配:按每个任务 spu.mark 从可配置的「模特图/平铺图」文件夹间随机抽图。 - 每个产品任务(含同一 SPU 的多个款)都独立随机抽一个模特,保证同款多产品模特不重复。 + 每个任务独立随机抽一张:先合并该 mark 对应「有图的」模特/平铺目录的全部合格图,再从其中随机抽一张, + 抽到哪个文件夹的图就返回对应 kind(model/flat),供 product_node 用对应提示词合成。 + 某类目录无图则只用另一类;两类都无图则该任务无图源(跳过合成)。 + 返回 {task_key: {"img": Path, "kind": "model"|"flat", "prompts": {…}}}。 """ - model_assign: Dict[str, Any] = {} + import random as _random + assign: Dict[str, Any] = {} + pcfg = self.config.get("product") or {} + mark_dirs = pcfg.get("mark_dirs") or {} try: - from graph.product import build_mark_model_map, find_model_images_for_mark - mark_map = build_mark_model_map(self._db_path, self._material_root) - except Exception: # noqa: BLE001 - mark_map = {} - # 每个任务按序号绑定独立随机模特(同 SPU 多款也各自随机,不共用) + from graph.product import build_mark_sources + except Exception as e: # noqa: BLE001 + print(f"[pinterest_pipeline] 图源映射导入失败: {e}") + return assign + # 每个出现过的 mark 各建一个图源池,避免多 mark 错配 + pool_by_mark: Dict[str, list] = {} + for _i, (spu, _skus) in enumerate(self._worklist): + mark = str(spu.get("mark") or "").strip() or "1" + if mark in pool_by_mark: + continue + try: + sources = build_mark_sources(self._material_root, mark_dirs, self._category, mark=mark) + except Exception as e: # noqa: BLE001 + print(f"[pinterest_pipeline] mark={mark} 图源构建失败: {e}") + sources = {"model": [], "flat": []} + pool = [] + for kind in ("model", "flat"): + for p in sources.get(kind) or []: + pool.append((p, kind)) + pool_by_mark[mark] = pool + if pool: + print(f"[pinterest_pipeline] mark={mark} 图源池:{len(pool)} 张(模特/平铺)") for _i, (spu, _skus) in enumerate(self._worklist): key = f"task_{_i}" mark = str(spu.get("mark") or "").strip() or "1" - folder = mark_map.get(mark, self._category) - try: - pool_imgs = find_model_images_for_mark(self._db_path, self._material_root, - mark, folder) - except Exception: # noqa: BLE001 - pool_imgs = [] - if pool_imgs: - model_assign[key] = random.choice(pool_imgs) # 过滤后随机抽 - return model_assign + pool = pool_by_mark.get(mark) or [] + if not pool: + continue + img, kind = _random.choice(pool) # 每任务独立随机抽一张(含 kind) + assign[key] = {"img": img, "kind": kind, + "prompts": (mark_dirs.get(mark) or mark_dirs.get("1") or {})} + return assign def _load_materials(self) -> Dict[str, str]: material_map: Dict[str, str] = {} @@ -384,6 +405,17 @@ class PinterestPipeline: self._cond.wait() return not self._briefs and self._in_flight <= 0 + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + # 异常路径也确保排空并释放线程池,避免 dispatcher/worker 泄漏 + try: + self.finish() + except Exception: # noqa: BLE001 + pass + return False + def finish(self) -> tuple: """排空简报池、等待全部产品完成,返回 (products, errors)。 @@ -507,13 +539,16 @@ class PinterestPipeline: def _persist_product(self, prod: Dict[str, Any]) -> None: """把已完成产品追加写入 products_pending.jsonl(JSONL 每行一个产品)。 + 并发安全:写入持 _products_lock,整行一次写(含换行),避免并发 append 交织; 落盘失败不阻塞主流程(仅告警);finish() 时读盘合并,保证已完成产品不丢。 """ try: import json as _json self._pending_file.parent.mkdir(parents=True, exist_ok=True) - with open(self._pending_file, "a", encoding="utf-8") as f: - f.write(_json.dumps(prod, ensure_ascii=False) + "\n") + line = _json.dumps(prod, ensure_ascii=False) + "\n" + with self._products_lock: + with open(self._pending_file, "a", encoding="utf-8") as f: + f.write(line) except Exception as e: # noqa: BLE001 print(f"[pinterest_pipeline] 产品落盘失败(不影响流程): {e}") @@ -630,6 +665,18 @@ class PinterestPipeline: print(f"[pinterest_pipeline] 补充简报装配失败: {e}") return None + def _model_source(self, task_idx: int, spu=None) -> Dict[str, Any]: + """返回某任务给 _process_spu 的图源参数:model_img / model_kind / prompts。""" + src = self._model_assign.get(f"task_{task_idx}") or {} + img = src.get("img") + if img is None: + return {} + if spu is not None and int(spu.get("mark") or 0) != 1: + return {"model_img": img} # 非 mark=1 走旧逻辑(不传 kind,product_node 按其 mark 自行判定) + return {"model_img": img, + "model_kind": src.get("kind", "model"), + "prompts": src.get("prompts") or {}} + def _process_spu(self, brief: Dict[str, Any], spu, skus: str, img_code: str, design_path: str, task_idx: int = 0) -> Optional[Dict[str, Any]]: from graph.nodes.product_node import _process_spu as _ps @@ -642,8 +689,8 @@ class PinterestPipeline: prod_dir, brief, self._ib, spu, skus, self.config.get("product") or {}, self._errors, design_path, self._title_backend, self.country, img_code=img_code, - model_img=self._model_assign.get(f"task_{task_idx}"), - on_503=lambda: (self.record_503(), self.abort_unfinished())) + on_503=lambda: (self.record_503(), self.abort_unfinished()), + **self._model_source(task_idx, spu)) if r: r["img_code"] = img_code return r diff --git a/graph/product.py b/graph/product.py index 0f0540a..e3dbc4a 100644 --- a/graph/product.py +++ b/graph/product.py @@ -135,85 +135,59 @@ def image_ratio_ok(path, target_ratio: float = 3 / 4, tolerance: float = 0.06) - return False -def build_mark_model_map(db_path, material_root) -> Dict[str, str]: - """启动任务前检测 spu.mark 字段,建立 {mark: 模特文件夹名} 字典。 - - 规则: - - 读取 spu 表全部 mark 值(去重); - - 每个 mark 映射到 material_library/ 目录(目录名与 mark 一致); - - 目录不存在时回退到 category 默认目录(T-shirt); - - 目前库中 mark=1 → 映射到 material_library/T-shirt。 - """ - mark_map: Dict[str, str] = {} - try: - root = Path(material_root) - if not root.exists(): - return mark_map - folders = [d.name for d in sorted(root.iterdir()) if d.is_dir()] - if not folders: - return mark_map - # 读 spu.mark 实际值(去重) - marks: List[str] = [] - try: - conn = _connect(db_path) - rows = conn.execute("SELECT DISTINCT mark FROM SPU WHERE mark IS NOT NULL AND mark != ''").fetchall() - conn.close() - marks = [str(r["mark"]).strip() for r in rows if str(r["mark"]).strip()] - except Exception: # noqa: BLE001 - marks = [] - if not marks: - marks = ["1"] # 库无 mark 数据时按默认 1 处理 - for m in marks: - if m in folders: - mark_map[m] = m - else: - # mark 无同名目录 → 回退默认 T-shirt(当前 mark=1 → T-shirt) - mark_map[m] = "T-shirt" if "T-shirt" in folders else folders[0] - print(f"[product] mark→模特目录映射: {mark_map}") - except Exception as e: # noqa: BLE001 - print(f"[product] mark→模特目录映射构建失败: {e}") - return mark_map - - -def find_model_images_for_mark(db_path, material_root, mark, category: str = "T-shirt", - ratio: float = 3 / 4, tolerance: float = 0.06) -> List[Path]: - """按 spu.mark 定位模特目录,过滤非目标比例图片,返回合格图片列表。 - - - mark 有对应目录(material_library/)→ 用该目录; - - 否则回退 category(如 T-shirt); - - 过滤掉非 3:4 比例(默认容差 6%)的图片; - - 返回过滤后的图片列表(供调用方随机抽取)。 - """ - root = Path(material_root) - if not root.exists(): - return [] - d = None - if mark is not None: - cand = root / str(mark) - if cand.is_dir(): - d = cand - if d is None: - cand = root / category - if cand.is_dir(): - d = cand - if d is None: - # 兜底:第一个有图的目录(跳过无图目录) - for sub in sorted(root.iterdir()): - if not sub.is_dir(): - continue - if any(f.is_file() and f.suffix.lower() in IMG_EXTS for f in sub.iterdir()): - d = sub - break - if d is None: +def _dir_images(d: Path, ratio: float, tolerance: float) -> List[Path]: + """返回目录内满足比例过滤(默认3:4)的图片列表;目录不存在/无图片返回空。""" + if d is None or not d.is_dir(): return [] imgs = [f for f in sorted(d.iterdir()) if f.is_file() and f.suffix.lower() in IMG_EXTS] + if not imgs: + return [] ok = [f for f in imgs if image_ratio_ok(f, ratio, tolerance)] if len(ok) < len(imgs): - print(f"[product] 模特目录 {d.name}/ 过滤非 {int(ratio * 100)}:{int(ratio * 100) + 1} 比例:" + print(f"[product] 目录 {d.name}/ 过滤非 {int(ratio * 100)}:{int(ratio * 100) + 1} 比例:" f"{len(imgs)} → {len(ok)} 张") return ok +def build_mark_sources(material_root, mark_dirs: Optional[Dict] = None, + category: str = "T-shirt", mark: str = "1", + ratio: float = 3 / 4, tolerance: float = 0.06) -> Dict[str, Any]: + """按可配置的 mark→图源映射,返回指定 mark 的图源选择信息。 + + 图源 = {"model": [Path...], "flat": [Path...]}(模特图 / 平铺图,各带英文键)。 + 从 config.product.mark_dirs 读 mark 对应的两个子目录名(model_dir/flat_dir), + 在 material_library 下解析 → 过滤 3:4 → 返回「有图的文件夹」图片列表。 + mark 未配置时回退 mark_dirs["1"];完全无配置则回退 category 默认目录。 + 返回结构:{"model": [imgs], "flat": [imgs]}。 + """ + root = Path(material_root) + result: Dict[str, Any] = {"model": [], "flat": []} + if not root.exists(): + return result + mcfg = mark_dirs or {} + cfg = mcfg.get(str(mark)) or mcfg.get("1") or {} # 优先按 mark,其次回退默认 "1" + model_name = str(cfg.get("model_dir") or "").strip() + flat_name = str(cfg.get("flat_dir") or "").strip() + if model_name: + result["model"] = _dir_images(root / model_name, ratio, tolerance) + if flat_name: + result["flat"] = _dir_images(root / flat_name, ratio, tolerance) + # 回退:某类目录未配置时,使用 category 默认目录补充模特图 + if not result["model"] and not result["flat"]: + d = root / category + if d.is_dir(): + result["model"] = _dir_images(d, ratio, tolerance) + if not result["model"] and not result["flat"]: + for sub in sorted(root.iterdir()): + if not sub.is_dir(): + continue + imgs = _dir_images(sub, ratio, tolerance) + if imgs: + result["model"] = imgs + break + return result + + def first_available_sku(db_path, basemap_root, spu_code: str) -> Optional[str]: """返回该款号下第一个「本地有底图」的 SKU.code;无则 None。""" for c in list_colors(db_path, spu_code): diff --git a/graph/template_export.py b/graph/template_export.py index 11dd11f..4b9840c 100644 --- a/graph/template_export.py +++ b/graph/template_export.py @@ -20,11 +20,6 @@ from graph.product import _connect # 商品轮播图列名关键词(模板存在中/英/日变体,如 商品轮播图1 / Product Carousel Image 1 / 商品カルーセル画像1) _CAROUSEL_KW = ("轮播", "carousel", "カルーセル") -# 商品产地:国家简称 → 正式名称(模版要求,如「沙特站」提取为「沙特」但需填「沙特阿拉伯」) -_COUNTRY_NAME_MAP = { - "沙特": "沙特阿拉伯", -} - # 成分值字典映射:db 成分值 → 女装模板下拉框选项(男装模板选项与 db 值一致,直接保留)。 # 女装模板(如 SatVoy 沙特)成分下拉框是「中文+英文」格式(棉Cotton),db 存中文(棉),需映射。 _COMPONENT_FEMALE_MAP = { @@ -232,18 +227,19 @@ def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_ router.ws.cell(row, car1, cc["url"]) # 该颜色轮播图1 -def _build_spu_row(spu: Dict[str, Any], spu_code: str, origin_province: str, +def _build_spu_row(spu: Dict[str, Any], spu_code: str, color: Optional[str] = None, fabric_headers: Optional[List[str]] = None, gender: Optional[str] = None) -> Dict[str, Any]: - """构造一行 SPU(固定字段:SKC货号=code、风格=休闲、商品产地=经营站点;多颜色时用色值列区分)。 + """构造一行 SPU(固定字段:SKC货号=code、风格=休闲、商品产地=中国大陆、产地省份=广东省;多颜色时用色值列区分)。 fabric 填「面料弹性」列(fabric_headers,如 SPU商品属性-面料弹性,检测到才填 spu.fabric)。 component_1/2/3 按性别映射(gender=female 时查 _COMPONENT_FEMALE_MAP,男装/None 保留原值)。""" row: Dict[str, Any] = { "基础信息-商品层级": "spu", "SKC货号": spu_code, # code 路由为 SKC货号(用户要求) "风格": "休闲", # style 路由为"休闲"(用户要求) - "商品产地": origin_province, # 产地省份不用填,经营站点填到「商品产地」 + "商品产地": "中国大陆", # 所有国家统一「中国大陆」,不读经营站点/不做字典匹配(用户要求) + "产地省份": "广东省", # 新增:精确匹配「产地省份」列,统一填「广东省」(用户要求) "款式来源": "现货款", # SPU商品属性-款式来源 统一填「现货款」(用户要求) } if color: @@ -302,6 +298,37 @@ def _read_suggested_required(router, col: int) -> bool: return "必填" in note +def _find_sku_category_headers(router) -> List[str]: + """定位「SKU分类」列(模糊匹配,同建议售价检测方式);匹配到多个时全部返回。""" + return [str(k) for k in router.column_map if "SKU分类" in str(k)] + + +def _find_sku_qty_headers(router) -> List[str]: + """定位「SKU数量」列(排除「SKU数量单位」列);匹配到多个时全部返回。""" + return [str(k) for k in router.column_map if "SKU数量" in str(k) and "单位" not in str(k)] + + +def _find_sku_qty_unit_headers(router) -> List[str]: + """定位「SKU数量单位」列。""" + return [str(k) for k in router.column_map if "SKU数量单位" in str(k)] + + +def _required_headers(router, headers) -> List[str]: + """过滤「非必填」列:读列头下一行备注(与建议售价同套路),备注显式写「非必填」才跳过。 + + SKU分类/SKU数量/SKU数量单位在本模板族属必填项(SKU分类备注「必填」,数量/单位为按规则填写), + 因此默认视为需填写,仅在列备注写明「非必填」时跳过。""" + keep: List[str] = [] + for h in headers: + col = router.column_map.get(h) + if col is not None: + note = str(router.ws.cell(router.header_row + 1, col).value or "") + if "非必填" in note: + continue + keep.append(h) + return keep + + def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color: str, warehouses: List[str], markup_percent: float = 0.0, multi: bool = True, price_header: str = "申报价格-日本站", @@ -311,7 +338,10 @@ def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color: suggested_price_ratio: float = 0.0, suggested_price_headers: Optional[List[str]] = None, suggested_required: Optional[Dict[str, bool]] = None, - suggested_unit_headers: Optional[List[str]] = None) -> Dict[str, Any]: + suggested_unit_headers: Optional[List[str]] = None, + sku_category_headers: Optional[List[str]] = None, + sku_qty_headers: Optional[List[str]] = None, + sku_qty_unit_headers: Optional[List[str]] = None) -> Dict[str, Any]: """构造一行 SKU(固定字段:SPU货号、SKC货号=sku.code、规格类型2、币种 CNY、发货仓1~N 及库存 200)。 价格(price_headers 列,如 申报价格-美国站/日本站,模糊匹配到多个时全部填)= SKU.price × (1+markup/100), 预先填好。建议售价(suggested_price_headers 列,模板「建议售价」必填时才填)= 申报价格 × (1+suggested_price_ratio/100), @@ -369,6 +399,13 @@ def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color: continue if v not in (None, ""): row[header] = v + # SKU 分类/数量/数量单位:模板检测到这些列(且非「非必填」)时统一填固定值(单品 / 1 / 件) + for h in (sku_category_headers or []): + row[h] = "单品" + for h in (sku_qty_headers or []): + row[h] = 1 + for h in (sku_qty_unit_headers or []): + row[h] = "件" return row @@ -441,12 +478,10 @@ def _read_spu(db_path, spu_code: str) -> Optional[Dict[str, Any]]: def _read_meta(router) -> tuple: - """读模板顶头元信息:经营站点、发货仓(按标签名定位,不依赖固定行列)。 + """读模板顶头元信息:发货仓(按标签名精准定位,不依赖固定行列)。 - 返回 (origin_province, warehouses): - - origin_province:经营站点去掉末尾「站」(如「日本站」→「日本」) - - warehouses:发货仓按「、」分隔的列表(如「名古屋仓、inkreach——东京」→ 2 个) - """ + 返回 warehouses:发货仓按「、」分隔的列表(如「名古屋仓、inkreach——东京」→ 2 个)。 + 商品产地/产地省份已固定为「中国大陆/广东省」写死在 _build_spu_row,不再读取经营站点。""" ws = router.ws top = max(1, router.group_row - 1) # 元信息区位于分组行之前 @@ -457,16 +492,12 @@ def _read_meta(router) -> tuple: return str(ws.cell(row + 1, col).value or "").strip() return "" - site = _val("经营站点") raw = _val("发货仓") - if not site and not raw: - # 回退:旧版固定位置(第2行第1/2列) - site = str(ws.cell(2, 1).value or "").strip() + if not raw: + # 回退:旧版固定位置(第2行第2列=发货仓) raw = str(ws.cell(2, 2).value or "").strip() - origin_province = site[:-1] if site.endswith("站") else site - origin_province = _COUNTRY_NAME_MAP.get(origin_province, origin_province) # 简称→正式名称(如 沙特→沙特阿拉伯) warehouses = [w.strip() for w in raw.split("、") if w.strip()] - return origin_province, warehouses + return warehouses def _read_skus(db_path, spu_code: str, sku_code: str) -> List[Dict[str, Any]]: @@ -499,7 +530,7 @@ def _import_router(template_dir: str) -> None: def _insert_product_block( router, db_path, spu_code, sku_code, - origin_province, warehouses, price_headers, + warehouses, price_headers, markup_percent: float = 0.0, images: Optional[List[str]] = None, spu_per_color: bool = True, oss_code: str = "", cn_title: str = "", en_title: str = "", ja_title: str = "", @@ -514,6 +545,9 @@ def _insert_product_block( suggested_price_headers: Optional[List[str]] = None, suggested_required: Optional[Dict[str, bool]] = None, suggested_unit_headers: Optional[List[str]] = None, + sku_category_headers: Optional[List[str]] = None, + sku_qty_headers: Optional[List[str]] = None, + sku_qty_unit_headers: Optional[List[str]] = None, ) -> List[int]: """在已打开的 router 中插入一个产品的 SPU+SKU 块并填充设计字段,返回本块行号。 @@ -543,7 +577,7 @@ def _insert_product_block( # 单 SPU 多色:1 个 SPU 行(无色值,SPU 级信息由 _fill_design_fields 填充) # + 全部颜色尺码 SKU 行(色值在 SKU 行区分) block_rows.append(router.insert( - _build_spu_row(spu, spu_code, origin_province, fabric_headers=fabric_headers, + _build_spu_row(spu, spu_code, fabric_headers=fabric_headers, gender=gender), match="exact", )) @@ -562,7 +596,10 @@ def _insert_product_block( suggested_price_ratio=suggested_price_ratio, suggested_price_headers=suggested_price_headers, suggested_required=suggested_required, - suggested_unit_headers=suggested_unit_headers), + suggested_unit_headers=suggested_unit_headers, + sku_category_headers=sku_category_headers, + sku_qty_headers=sku_qty_headers, + sku_qty_unit_headers=sku_qty_unit_headers), spu_code=spu_code, match="exact", )) @@ -578,7 +615,7 @@ def _insert_product_block( else: # 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分) block_rows.append(router.insert( - _build_spu_row(spu, spu_code, origin_province, fabric_headers=fabric_headers, + _build_spu_row(spu, spu_code, fabric_headers=fabric_headers, gender=gender), match="exact")) multi_variant = len(skus_by_color) > 1 for ci, (sc, skus) in enumerate(skus_by_color): @@ -594,7 +631,10 @@ def _insert_product_block( suggested_price_ratio=suggested_price_ratio, suggested_price_headers=suggested_price_headers, suggested_required=suggested_required, - suggested_unit_headers=suggested_unit_headers), + suggested_unit_headers=suggested_unit_headers, + sku_category_headers=sku_category_headers, + sku_qty_headers=sku_qty_headers, + sku_qty_unit_headers=sku_qty_unit_headers), )) sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else [] _fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs) @@ -659,7 +699,7 @@ def export_product( # append_to:合并模式从已有输出文件继续追加(一次任务多产品填一个模板) router = TemplateRouter(append_to if append_to else template_path) try: - origin_province, warehouses = _read_meta(router) + warehouses = _read_meta(router) price_headers = _find_price_headers(router) # 申报价格列(美站/日站/英站…模糊匹配,多个全填) bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填) fabric_headers = _find_fabric_headers(router) # 面料弹性列(SPU商品属性-面料弹性…检测到才填) @@ -669,8 +709,11 @@ def export_product( suggested_unit_headers = _find_suggested_unit_headers(router) # 建议售价单位列 suggested_required = {h: _read_suggested_required(router, router.column_map[h]) for h in suggested_price_headers} # 各建议售价列是否必填(结合非必填/必填判断) + sku_category_headers = _required_headers(router, _find_sku_category_headers(router)) # SKU分类列(非必填才跳过) + sku_qty_headers = _required_headers(router, _find_sku_qty_headers(router)) # SKU数量列 + sku_qty_unit_headers = _required_headers(router, _find_sku_qty_unit_headers(router)) # SKU数量单位列 _insert_product_block(router, db_path, spu_code, sku_code, - origin_province, warehouses, price_headers, + warehouses, price_headers, markup_percent=markup_percent, images=images, spu_per_color=spu_per_color, oss_code=oss_code, cn_title=cn_title, en_title=en_title, @@ -684,7 +727,10 @@ def export_product( suggested_price_ratio=suggested_price_ratio, suggested_price_headers=suggested_price_headers, suggested_required=suggested_required, - suggested_unit_headers=suggested_unit_headers) + suggested_unit_headers=suggested_unit_headers, + sku_category_headers=sku_category_headers, + sku_qty_headers=sku_qty_headers, + sku_qty_unit_headers=sku_qty_unit_headers) out = router.save(out_path) return Path(out) finally: @@ -714,7 +760,7 @@ def export_products( router = TemplateRouter(template_path) try: - origin_province, warehouses = _read_meta(router) + warehouses = _read_meta(router) price_headers = _find_price_headers(router) # 申报价格列(美站/日站/英站…模糊匹配,多个全填) bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填) fabric_headers = _find_fabric_headers(router) # 面料弹性列(SPU商品属性-面料弹性…检测到才填) @@ -724,12 +770,15 @@ def export_products( suggested_unit_headers = _find_suggested_unit_headers(router) # 建议售价单位列 suggested_required = {h: _read_suggested_required(router, router.column_map[h]) for h in suggested_price_headers} # 各建议售价列是否必填(结合非必填/必填判断) + sku_category_headers = _required_headers(router, _find_sku_category_headers(router)) # SKU分类列(非必填才跳过) + sku_qty_headers = _required_headers(router, _find_sku_qty_headers(router)) # SKU数量列 + sku_qty_unit_headers = _required_headers(router, _find_sku_qty_unit_headers(router)) # SKU数量单位列 for r in products: try: _insert_product_block( router, db_path, r.get("spu_code", ""), r.get("sku_codes") or r.get("sku_code") or "", - origin_province, warehouses, price_headers, + warehouses, price_headers, markup_percent=markup_percent, images=r.get("images"), spu_per_color=bool(r.get("spu_per_color", True)), @@ -748,6 +797,9 @@ def export_products( suggested_price_headers=suggested_price_headers, suggested_required=suggested_required, suggested_unit_headers=suggested_unit_headers, + sku_category_headers=sku_category_headers, + sku_qty_headers=sku_qty_headers, + sku_qty_unit_headers=sku_qty_unit_headers, ) except Exception as e: # noqa: BLE001 print(f"[template_export] 产品 {r.get('spu_code')} 写入失败,跳过: {e}") diff --git a/graph/validate.py b/graph/validate.py index 1efa801..de9c8f0 100644 --- a/graph/validate.py +++ b/graph/validate.py @@ -36,11 +36,41 @@ class ThreadSafeErrors: return len(self._items) +def _mark_fatal_503(state: Dict[str, Any], exc: Exception) -> None: + """致命图像服务错误(503 / No available compatible accounts)→ 标记提前终止,不静默吞掉。 + + with_fallback 原本把所有异常都转成一条错误记录并继续,导致致命的 503 被「吞掉」: + 路由看不到终止信号,任务会继续做无意义的搜索/分析(重试必然失败)。 + 检测到致命 503 时同步标记 Pinterest 流水线终止(record_503 + abort_unfinished), + 让 _pinterest_route 短路到 pinterest_finalize → template_export(合成模板,保留已完成产品)。 + 非 Pinterest 节点无流水线对象时,此函数为空操作(不改变原有兜底行为)。 + """ + if not _is_fatal_image_error(exc): + return + pipe = state.get("pinterest_pipeline") + if pipe is not None and hasattr(pipe, "record_503") and hasattr(pipe, "abort_unfinished"): + try: + pipe.record_503() + pipe.abort_unfinished() + except Exception: # noqa: BLE001 + pass + + +def _is_fatal_image_error(exc: Exception) -> bool: + """判断异常是否为致命图像服务错误(503 / 账户不可用)。""" + try: + from graph.pinterest_pipeline import PinterestPipeline + return bool(PinterestPipeline.is_fatal_503(exc)) + except Exception: # noqa: BLE001 + return False + + def with_fallback(node_name: str): """装饰器:捕获节点异常,转为 state['errors'] 中的一条记录,返回空更新。 节点内部仍建议自己做精细兜底(降级/默认),with_fallback 是最后一道保险: - 任何未预料的异常都不会让整张图中断。 + 任何未预料的异常都不会让整张图中断。唯一例外——致命图像服务错误(503/账户不可用) + 不静默吞掉:会同步标记流水线终止,让任务提前收尾合成模板(见 _mark_fatal_503)。 """ def deco(fn): @@ -58,6 +88,8 @@ def with_fallback(node_name: str): } errors = list(state.get("errors") or []) errors.append(err) + # 致命 503:不静默吞掉,标记流水线终止(路由据此短路到收尾合成模板) + _mark_fatal_503(state, e) # 只更新 errors,其它字段保持上一节点结果,下游继续 return {"errors": errors} diff --git a/material_library/T-shirt/1723967877304-22cf5e971695402dad8ced8a66cdc3a2-goods.jpeg b/material_library/T-shirt/1723967877304-22cf5e971695402dad8ced8a66cdc3a2-goods.jpeg deleted file mode 100644 index 23d8896..0000000 Binary files a/material_library/T-shirt/1723967877304-22cf5e971695402dad8ced8a66cdc3a2-goods.jpeg and /dev/null differ diff --git a/material_library/T-shirt/O1CN010AWgHF1qkcxeXwjD4_!!2644315534.webp b/material_library/T-shirt/O1CN010AWgHF1qkcxeXwjD4_!!2644315534.webp deleted file mode 100644 index ffe6449..0000000 Binary files a/material_library/T-shirt/O1CN010AWgHF1qkcxeXwjD4_!!2644315534.webp and /dev/null differ diff --git a/material_library/平铺图/2f9afb74ec397fd0ef6b35d62709303b.jpg b/material_library/平铺图/2f9afb74ec397fd0ef6b35d62709303b.jpg new file mode 100644 index 0000000..6f464cf Binary files /dev/null and b/material_library/平铺图/2f9afb74ec397fd0ef6b35d62709303b.jpg differ diff --git a/material_library/模特图/result_2_001 (1).png b/material_library/模特图/result_2_001 (1).png new file mode 100644 index 0000000..58b0351 Binary files /dev/null and b/material_library/模特图/result_2_001 (1).png differ diff --git a/prompts/pinterest_analyze_system.md b/prompts/pinterest_analyze_system.md new file mode 100644 index 0000000..145b7ff --- /dev/null +++ b/prompts/pinterest_analyze_system.md @@ -0,0 +1,50 @@ +You are a POD T-shirt design analyst. Given ONE Pinterest reference image, +judge whether it can inspire a T-shirt print, then write an ORIGINAL design brief +capturing its vibe WITHOUT copying. +Your image_prompt will be sent TOGETHER WITH this reference image to an image +generator, so it must actively override visual imitation. + +RULES: +1. NO COPYING — never reproduce or closely imitate the reference's artwork, + characters, layout or text. Deliberately change motif, arrangement and/or + palette so the two read as clearly different works sharing only a general + style. Distill inspiration into generic style words (retro, y2k, minimal, + grunge, boho, kawaii...); never imitate an identifiable artist/studio/IP + style. +2. FORBIDDEN — brand logos, trademarks, slogans, mascots, copyrighted + characters, real people/celebrities, movie/game/anime/band IP, lyrics, + even stylized or silhouette versions. Avoid politics, religion, violence, + sexual content, alcohol, drugs, gambling, flags, death/occult themes. +3. FORM — ONE clear central subject with strong graphic composition; + print-ready standalone artwork. ANY colors are fine — rich palettes, + gradients and detailed shading are all acceptable. Photographic references + may be rendered as detailed full-color illustrations, retro badges or + vintage stickers. + +TEXT: short ORIGINAL English wording (0-5 words) allowed; wrap exact words in +double quotes and demand exact spelling; integrate into composition. Never +reuse/translate reference text; no brand/band/movie names or famous slogans. +When unsure, omit. + +suitable_for_print: DEFAULT TRUE for graphics, illustrations, badges, vector +art, typography posters, or prints on mockups (judge only the printed artwork). +FALSE only for: subjectless photo scenery, memes/screenshots/collages, +watermarked or very low-quality images, decor/food/candid photos with no +usable motif. Even when FALSE, still fill all fields so downstream never breaks. + +image_prompt = two parts: + 1) mandatory opener, e.g.: "Use the attached reference image only as loose + inspiration for overall mood, theme and era — do NOT reproduce, trace, + rearrange, recolor or closely imitate any element, character, layout or + text shown in it." + 2) the new design: [central motif] + [style] + [color treatment] + + [composition] + [mood], plus quoted original text if used. + NEVER mention shirts, apparel, models, scenes, sizes, backgrounds or + watermarks — placement is handled externally. + +negative_prompt: copy of reference artwork, likenesses, characters, logos, +trademarks, watermark, photorealistic shirt/product mockups, busy background; +add garbled-lettering terms only if your design includes text. + +OUTPUT — ONLY valid JSON, no fences: +{"designs":[{"suitable_for_print":,"negative_prompt":"","image_prompt":""}]} \ No newline at end of file diff --git a/prompts/pinterest_analyze_user.md b/prompts/pinterest_analyze_user.md new file mode 100644 index 0000000..4e6078d --- /dev/null +++ b/prompts/pinterest_analyze_user.md @@ -0,0 +1 @@ +Analyze the attached image and produce one ORIGINAL T-shirt print design brief that captures its visual vibe without copying it. \ No newline at end of file diff --git a/ui_app.py b/ui_app.py index dd05b7e..2cec0e1 100644 --- a/ui_app.py +++ b/ui_app.py @@ -379,11 +379,15 @@ def run_pipeline(countries, provider, max_seeds, log_q, def run_pinterest_pipeline(countries, provider, log_q, spu_tasks=None, spu_count=0, - oai=None, markup_percent=0.0, code_prefix="DG", template_path=""): + oai=None, markup_percent=0.0, code_prefix="DG", template_path="", + custom_image_dir=""): """Pinterest 参考模式后台线程:独立于 Google Trends 采集链路。 各国独立种子词池 → LLM 搜索词(json_schema + 动态注入防重复)→ 爬图 → LLM 分析图片 → 设计简报 → 产品生成(设计稿/主图/种草图/模板导出)。 + + custom_image_dir 非空 → 自定义图片模式:不搜索不采集,把该文件夹有效图片直接送多模态分析, + 沿用 Pinterest 后续所有步骤;数量硬校验(有效图片数 ≥ 选品清单总数)由 run_pinterest_ref 执行。 """ config = load_config() config["seed_provider"] = provider @@ -401,6 +405,9 @@ def run_pinterest_pipeline(countries, provider, log_q, spu_tasks=None, spu_count p["markup_percent"] = markup_percent if code_prefix: p["code_prefix"] = code_prefix + # 自定义模式:把上传文件夹写入配置(run_pinterest_ref 据此进入 custom 图并校验) + if custom_image_dir: + config.setdefault("pinterest", {})["custom_image_dir"] = custom_image_dir # 任务扩展(与 run_pipeline 一致):每个集合按自己数量复制 N 份 if spu_tasks: tasks_list = [] @@ -425,7 +432,8 @@ def run_pinterest_pipeline(countries, provider, log_q, spu_tasks=None, spu_count from graph.agent import run_pinterest_ref for c in countries: log_q.put(("log", f"\n===== Pinterest 参考模式 {c}(SPU 数量 {spu_count})=====\n")) - state = run_pinterest_ref(c, config, config_root(), runtime_root(), task_timestamp=task_ts) + state = run_pinterest_ref(c, config, config_root(), runtime_root(), + task_timestamp=task_ts, custom_image_dir=custom_image_dir) items = state.get("product") or [] errs = state.get("errors") or [] log_q.put(("log", f"[{c}] Pinterest 参考完成:{len(items)} 个产品,兜底错误 {len(errs)}\n")) @@ -538,6 +546,13 @@ class App(tk.Tk): variable=self.flow_var).pack(side="left", padx=2) ttk.Radiobutton(top, text="Pinterest 参考", value="pinterest", variable=self.flow_var).pack(side="left", padx=2) + ttk.Radiobutton(top, text="自定义图片", value="custom", + variable=self.flow_var).pack(side="left", padx=2) + ttk.Button(top, text="📁 选图片文件夹…", command=self._choose_custom_dir).pack(side="left", padx=(8, 0)) + self.custom_dir_var = tk.StringVar(value="") + self.custom_dir_name_var = tk.StringVar(value="") + ttk.Label(top, textvariable=self.custom_dir_name_var, anchor="w", + foreground="#185FA5").pack(side="left", padx=(6, 0)) ttk.Label(top, text=" 种子数量:").pack(side="left", padx=(14, 0)) self.seed_var = tk.StringVar(value="24") ttk.Entry(top, textvariable=self.seed_var, width=4).pack(side="left") @@ -854,6 +869,24 @@ class App(tk.Tk): ).start() # ---------- 运行 ---------- + def _choose_custom_dir(self): + """选择自定义模式图片文件夹,并立即统计有效图片数量(供数量校验提示)。""" + path = filedialog.askdirectory(title="选择自定义图片文件夹(直接送多模态分析)") + if not path: + return + try: + from graph.pinterest import list_valid_images + imgs = list_valid_images(path) + n = len(imgs) + except Exception: # noqa: BLE001 + n = 0 + self.custom_dir_var.set(path) + if n == 0: + self.custom_dir_name_var.set(f"已选: {Path(path).name}(0 张有效图片!)") + else: + self.custom_dir_name_var.set(f"已选: {Path(path).name}({n} 张有效图片)") + self._log(f"[UI] 自定义图片文件夹已选择: {path}(有效图片 {n} 张,jpg/jpeg/png/webp)\n") + def _choose_template(self): """选择自定义商品上传模板(.xlsx),后续 template_export 从该模板解析。 导入时校验「经营站点」是否与当前国家一致(如 JP → 日本站),不一致则导入失败。""" @@ -1022,14 +1055,40 @@ class App(tk.Tk): self._busy = True self.run_btn.config(state="disabled", text="运行中…") self.fetch_btn.config(state="disabled") - if self.flow_var.get() == "pinterest": - # Pinterest 参考模式:独立于 Google Trends 采集链路 + flow = self.flow_var.get() + if flow in ("pinterest", "custom"): + # Pinterest 参考 / 自定义图片:独立于 Google Trends 采集链路 + custom_dir = self.custom_dir_var.get().strip() + if flow == "custom": + # —— 自定义图片模式硬校验:必须先选文件夹;有效图片数 ≥ 选品清单总数 —— + if not custom_dir: + self._busy = False + self.run_btn.config(state="normal", text="运行") + self.fetch_btn.config(state="normal") + messagebox.showwarning("自定义图片模式", "请先点击「📁 选图片文件夹…」选择上传的图片文件夹") + return + try: + from graph.pinterest import list_valid_images, validate_custom_images + # 选品清单总数 = 每个任务按 count 扩展后的求和(与 run_pinterest_pipeline 一致) + expanded = sum(int(t.get("count") or 0) or spu_count or 1 for t in tasks) + ok, valid_n, msg = validate_custom_images(custom_dir, expanded) + except Exception as e: # noqa: BLE001 + ok, valid_n, msg = False, 0, f"校验失败: {e}" + if not ok: + self._busy = False + self.run_btn.config(state="normal", text="运行") + self.fetch_btn.config(state="normal") + messagebox.showerror("自定义图片数量校验未通过", msg) + self._log("[UI] 自定义图片数量校验未通过,任务未运行: " + msg + "\n") + return + self._log(f"[UI] 自定义图片数量校验通过:有效图片 {valid_n} 张 ≥ 选品清单 {expanded}\n") threading.Thread( target=run_pinterest_pipeline, args=(countries, self.provider_var.get(), self._q, tasks, spu_count, self._oai_cfg(), markup, self.code_prefix_var.get().strip() or "DG", - self.template_path_var.get().strip()), + self.template_path_var.get().strip(), + custom_dir if flow == "custom" else ""), daemon=True, ).start() else: