diff --git a/.github/workflows/skill-eval.yml b/.github/workflows/skill-eval.yml index 3793f38..79369ec 100644 --- a/.github/workflows/skill-eval.yml +++ b/.github/workflows/skill-eval.yml @@ -57,7 +57,6 @@ jobs: python tools/check_image.py --selftest python tools/slop_check.py --selftest python tools/compose_prompt.py --selftest - python tools/run_loop.py --selftest python tools/rules_engine.py --selftest python tools/visual_audit.py --selftest python tools/corpus.py --selftest diff --git a/README.md b/README.md index f22cec3..5a24807 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,9 @@ python tools/atomize.py restore # 从原子的完整内容导出可读文本 不是一堆零散脚本,是一条链路: ``` -拆解 ──→ 组装 ──→ 自查 ──→ 出图 ──→ 机检 ──→ 验收 ──→ 统计 -atomize compose slop qwen check visual fix_stats - prompt _check _image _image _audit +拆解 ──→ 组装 ──→ 自查 ──→ 机检 ──→ 验收 ──→ 统计 +atomize compose slop check visual fix_stats + prompt _check _image _audit ``` | 工具 | 做什么 | @@ -103,15 +103,13 @@ atomize compose slop qwen check visual fix_stats | `tools/compose_prompt.py` | 五槽位组装:保真段 → 场景 → 主体 → 细节 → 用途 → 约束,自动补平台约束、跑必填校验 | | `tools/slop_check.py` | 提示词定稿前查 SD 时代堆词、绝对化用语、虚假促销、功效宣称、AI 痕迹 | | `tools/rules_engine.py` | 按平台、品类、场景三份判据规则校验槽位,可 `--inject` 补默认约束 | -| `tools/qwen_image.py` | 命令行出图通道(Qwen-Image 2.1 Pro),内置授权闸门 | | `tools/check_image.py` | 出图后平台机检:白底纯度、主体占比、比例、分辨率、文件大小、透明通道、图内文字 | -| `tools/run_loop.py` | 出图闭环:生成 → 机检 → 修复句回写 → 重跑,每轮落盘并给 manifest | | `tools/visual_audit.py` | 视觉验收:主体一致性(感知哈希 + 颜色直方图)与 AI 感启发式,只提示不判定 | | `tools/fix_stats.py` | 从 manifest 统计每条修复句的真实生效率,用来判断措辞要不要改 | | `tools/corpus.py` | 正样本语料:录入、相似度检索、编译成 few-shot 范例段 | | `tools/lint_skills.py` | 静态检查全集,CI 的第一道门 | -九个工具带 `--selftest`,离线可验,不依赖密钥与网络。除 `check_image.py` 与 `visual_audit.py` 用到 Pillow 外全是标准库。 +八个工具带 `--selftest`,离线可验,不依赖密钥与网络。除 `check_image.py` 与 `visual_audit.py` 用到 Pillow 外全是标准库。 `check_image.py` 的 OCR 会自己找 tesseract:不在 PATH 时扫常见安装路径,默认语言目录缺中文时去找用户级 tessdata。缺依赖就标 `skipped`,不会假装通过。 @@ -126,17 +124,9 @@ SOP 给的是「方法」,下面两样给的是「判据与范例」: ## 出图与授权 -> **出图先授权(硬性)**:任何图片生成模型(Qwen-Image 全系、wan/万相、Seedream、Gemini、GPT-Image、FLUX、z-image 等,含 MCP 工具与网页端代操作)调用前必须先取得明确授权,说明用途、模型、张数与预计花费。规则见 [`tools/IMAGE-GEN-AUTHORIZATION.md`](tools/IMAGE-GEN-AUTHORIZATION.md)。 +> **出图先授权(硬性)**:任何图片生成模型(Qwen-Image 全系、wan/万相、Seedream、Gemini、GPT-Image、FLUX、z-image 等,含 MCP 工具与网页端代操作)调用前必须先取得明确授权,说明用途、模型与张数;未获授权时只交付提示词文本,不调用任何出图模型。 -`tools/qwen_image.py` 内置闸门,缺 `--authorize` / `--purpose` / `--unit-price` 直接拒绝,不发出请求: - -```bash -export DASHSCOPE_API_KEY="sk-..." -python tools/qwen_image.py -p "提示词" --size 1024*1024 -o out \ - --authorize "用户 2026-10-29 14:20 回复:可以" --purpose "产品A 天猫主图第 2 张" --unit-price 0.25 -``` - -发请求前会打印「张数 × 单价 = 预计花费」,并追加写入 `out/authorization.log`(只留本机)。单价按官方页面当时数值填,拿不到先问,不猜。 +仓库不再内置命令行出图通道:2026-11-10 按用户指令移除 `tools/qwen_image.py`、`tools/run_loop.py` 与规则正本 `tools/IMAGE-GEN-AUTHORIZATION.md`,此后出图走用户指定的外部通道。机检与视觉验收脚本仍在 `tools/`,可对任意通道产出的图做后置校验。 图生图用于产品锁定与翻新:产品实拍图作参考图,提示词里写清保留清单与改动清单。提示词默认按内容投喂、不做模型改写,硬规则不会被稀释。 @@ -189,6 +179,5 @@ npx promptfoo@0.124.1 eval -c evals/promptfooconfig.quality.yaml - **AI 感阈值是经验值**:`visual_audit.py` 的饱和度、平滑度、死白、边缘密度这些判据,还没用真实的好图 / AI 图批次标定过,当提示用,不当结论 - **语料库只有 2 条种子**:检索质量取决于样本量,现在属于「能用但不够准」 -- **`run_loop.py --generate` 未实跑**:需要授权与 API 余额,闭环逻辑只在 `--dry-run` 下验证过 - **OCR 需要本地装 tesseract**:缺 pytesseract 或 tesseract 时这项走 skipped,不会误报通过;CI 环境没装,所以云端不检查图内文字 - **模板抽取有少量噪声**:少数没有围栏代码块的篇目走裸段落规则,个别片段可能偏宽,未逐篇人工复核 diff --git a/ecom-detail-page-prompts/SKILL.md b/ecom-detail-page-prompts/SKILL.md index 1cc8e04..b0ec2b4 100644 --- a/ecom-detail-page-prompts/SKILL.md +++ b/ecom-detail-page-prompts/SKILL.md @@ -41,7 +41,7 @@ description: "电商详情页生图提示词导演:基于上传的产品实拍 14. 定义一套**套图风格锁**——整套页面共用的色板、光影、字体气质、版式语言和装饰系统,每页原样复用。 15. 为每一页输出可直接交给生图工具执行的提示词。 16. 用户指定了生图工具的,读 `references/tool-adaptation.md`,把提示词调整为该工具偏好的写法;未指定则用通用写法。 -17. **不得自行出图**。本项目限制任何图片生成模型(Qwen-Image 全系、wan/万相、Seedream、Gemini/Nano Banana、GPT-Image、FLUX、z-image 等,含 MCP 工具与网页端代操作)在调用前必须先说明用途、模型、张数和预计花费,取得用户明确同意后才可调用;规则正本见仓库(及镜像工作区)`tools/IMAGE-GEN-AUTHORIZATION.md`,单独安装技能目录时不带该文件,约束仍按本句执行。未获授权时只交付提示词文本。 +17. **不得自行出图**。本项目限制任何图片生成模型(Qwen-Image 全系、wan/万相、Seedream、Gemini/Nano Banana、GPT-Image、FLUX、z-image 等,含 MCP 工具与网页端代操作)在调用前必须先说明用途、模型、张数和预计花费,取得用户明确同意后才可调用;约束一律按本句执行。未获授权时只交付提示词文本。 ## 硬规则 diff --git a/ecom-detail-page-prompts/references/tool-adaptation.md b/ecom-detail-page-prompts/references/tool-adaptation.md index 268c2b2..aa54e92 100644 --- a/ecom-detail-page-prompts/references/tool-adaptation.md +++ b/ecom-detail-page-prompts/references/tool-adaptation.md @@ -60,22 +60,8 @@ - **中国法规**:AI 生成合成内容须显式与隐式双标识。 - 纯产品详情页(无 AI 人物)不涉及人物标注,但商品外观仍须与参考图一致,严禁货不对板。 -## 六、本仓库命令行出图通道(Qwen-Image 2.1 Pro) +## 六、出图与授权 -> **调用前必须取得用户授权**:本项目限制任何图片生成模型在调用前都要先说明用途与预计花费并取得同意,Qwen-Image 全系同样适用。规则见仓库 `tools/IMAGE-GEN-AUTHORIZATION.md`。脚本缺 `--authorize` / `--purpose` / `--unit-price` 会直接拒绝,不发出请求。 +> **调用前必须取得用户授权**:本项目限制任何图片生成模型在调用前都要先说明用途与模型并取得同意;未获授权时不调用任何出图模型,只交付提示词文本。 -仓库内 `tools/qwen_image.py` 提供文生图与图生图命令行通道,走阿里云百炼 DashScope 同步接口,模型 `qwen-image-2.1-pro`。密钥只从环境变量 `DASHSCOPE_API_KEY` 或 `--key-file` 读取,不写入任何文件。 - -```bash -export DASHSCOPE_API_KEY="sk-..." # 也可用 --key-file 指向密钥文件 -python tools/qwen_image.py -p "提示词" -o out \ - --authorize "用户 2026-10-29 14:20 回复:可以" --purpose "产品A 天猫详情页第 3 页" --unit-price 0.25 -python tools/qwen_image.py -f page03.txt -i product_front.jpg -i product_detail.jpg --size 1024*1536 \ - --authorize "..." --purpose "..." --unit-price 0.25 -``` - -参数要点:`--size` 用 `宽*高`(也接受 `宽x高`)或 `auto`;`-n` 支持 1 到 6 张;`--seed` 固定随机种子;`-i` 可重复,最多 10 张参考图。提示词默认按原文投喂、不做模型改写,需要模型按简单描述扩写时加 `--prompt-extend`。输出默认落在 `out/`,该目录不进版本库。详情页逐页生成时,一页一个提示词文件,参考图顺序固定为产品主图在前、细节图在后。提示词含大量引号或换行时用 `-f` 传文件,避免命令行转义问题。 - -授权三项:`--authorize` 填用户原话或带日期的授权说明,不得自行编造;`--purpose` 具体到产品、平台、页面位置;`--unit-price` 填官方页面当时的每张单价,拿不到就先问用户。整页批量时按总张数报花费,不要拆成多次小额授权。脚本会打印「张数 × 单价 = 预计花费」并写入 `out/authorization.log`,只留本机。 - -脚本只存在于仓库与镜像工作区,单独安装技能目录时不带 `tools/`。 +仓库不再内置命令行出图通道:2026-11-10 按用户指令移除 `tools/qwen_image.py`、`tools/run_loop.py` 与 `tools/IMAGE-GEN-AUTHORIZATION.md`,出图改走用户指定的外部通道。详情页成图仍按上文各节的口径生成,产出用 `tools/check_image.py` 按平台机检。 diff --git a/ecom-main-image-prompts/SKILL.md b/ecom-main-image-prompts/SKILL.md index 74a5a9a..aefb765 100644 --- a/ecom-main-image-prompts/SKILL.md +++ b/ecom-main-image-prompts/SKILL.md @@ -32,7 +32,7 @@ description: "全平台电商主图生图提示词导演:基于上传的产品 7. 为每张图规划产品专属职责(见下方"动态规划"),沿买家决策顺序展开;同时定义整套图共用的**套图风格锁**。 8. 按"模特规则"和"图中文字规则"执行人物与文字约束。 9. 用户指定了生图工具的,读 `references/tool-adaptation.md`,把提示词调整为该工具偏好的写法;未指定则用通用写法。 -10. **不得自行出图**。本项目限制任何图片生成模型(Qwen-Image 全系、wan/万相、Seedream、Gemini/Nano Banana、GPT-Image、FLUX、z-image 等,含 MCP 工具与网页端代操作)在调用前必须先说明用途、模型、张数和预计花费,取得用户明确同意后才可调用;规则正本见仓库(及镜像工作区)`tools/IMAGE-GEN-AUTHORIZATION.md`,单独安装技能目录时不带该文件,约束仍按本句执行。未获授权时只交付提示词文本。 +10. **不得自行出图**。本项目限制任何图片生成模型(Qwen-Image 全系、wan/万相、Seedream、Gemini/Nano Banana、GPT-Image、FLUX、z-image 等,含 MCP 工具与网页端代操作)在调用前必须先说明用途、模型、张数和预计花费,取得用户明确同意后才可调用;约束一律按本句执行。未获授权时只交付提示词文本。 ## 严格规则 diff --git a/ecom-main-image-prompts/references/tool-adaptation.md b/ecom-main-image-prompts/references/tool-adaptation.md index efe0cb1..feed87f 100644 --- a/ecom-main-image-prompts/references/tool-adaptation.md +++ b/ecom-main-image-prompts/references/tool-adaptation.md @@ -48,25 +48,11 @@ 规则:每步重申保留清单;一次只改一个变量;效果变差时回退上一步重新下达指令,不要叠加新指令硬改。 -## 五、本仓库命令行出图通道(Qwen-Image 2.1 Pro) +## 五、出图与授权 -> **调用前必须取得用户授权**:本项目限制任何图片生成模型在调用前都要先说明用途与预计花费并取得同意,Qwen-Image 全系同样适用。规则见仓库 `tools/IMAGE-GEN-AUTHORIZATION.md`。脚本缺 `--authorize` / `--purpose` / `--unit-price` 会直接拒绝,不发出请求。 +> **调用前必须取得用户授权**:本项目限制任何图片生成模型在调用前都要先说明用途与模型并取得同意;未获授权时不调用任何出图模型,只交付提示词文本。 -仓库内 `tools/qwen_image.py` 提供文生图与图生图命令行通道,走阿里云百炼 DashScope 同步接口,模型 `qwen-image-2.1-pro`。密钥只从环境变量 `DASHSCOPE_API_KEY` 或 `--key-file` 读取,不写入任何文件。 - -```bash -export DASHSCOPE_API_KEY="sk-..." # 也可用 --key-file 指向密钥文件 -python tools/qwen_image.py -p "提示词" -o out \ - --authorize "用户 2026-10-29 14:20 回复:可以" --purpose "产品A 天猫主图第 2 张" --unit-price 0.25 -python tools/qwen_image.py -f prompt.txt -i product_front.jpg -i product_detail.jpg --size 1024*1024 \ - --authorize "..." --purpose "..." --unit-price 0.25 -``` - -参数要点:`--size` 用 `宽*高`(也接受 `宽x高`)或 `auto`;`-n` 支持 1 到 6 张;`--seed` 固定随机种子;`-i` 可重复,最多 10 张参考图。提示词默认按原文投喂、不做模型改写,需要模型按简单描述扩写时加 `--prompt-extend`。输出默认落在 `out/`,该目录不进版本库。提示词含大量引号或换行时用 `-f` 传文件,避免命令行转义问题。 - -授权三项:`--authorize` 填用户原话或带日期的授权说明,不得自行编造;`--purpose` 具体到产品、平台、页面位置;`--unit-price` 填官方页面当时的每张单价,拿不到就先问用户。脚本会打印「张数 × 单价 = 预计花费」并写入 `out/authorization.log`,只留本机。 - -脚本只存在于仓库与镜像工作区,单独安装技能目录时不带 `tools/`。 +仓库不再内置命令行出图通道:2026-11-10 按用户指令移除 `tools/qwen_image.py`、`tools/run_loop.py` 与 `tools/IMAGE-GEN-AUTHORIZATION.md`,出图改走用户指定的外部通道。下节自查脚本对任意通道产出的图同样适用。 ## 六、产出前后两道自查 diff --git a/tools/IMAGE-GEN-AUTHORIZATION.md b/tools/IMAGE-GEN-AUTHORIZATION.md deleted file mode 100644 index 9d7a813..0000000 --- a/tools/IMAGE-GEN-AUTHORIZATION.md +++ /dev/null @@ -1,62 +0,0 @@ -# 图片生成模型调用授权规则 - -> 本项目硬性限制。任何图片生成模型(含 Qwen-Image 全系、wan/万相、Seedream、Gemini/Nano Banana、GPT-Image、FLUX、z-image 等,无论走本仓库脚本、MCP 工具还是网页端)在发起生成前,必须先取得用户授权。未授权不得调用。 -> -> 2026-10-29 由用户指令建立。修改本文件等于修改项目红线,需用户明确同意。 - -## 一、为什么有这个文件 - -出图按张计费,一次批量任务可能几十上百张,费用会累积。用途不明时还可能生成不该生成的东西(未发布的款式、他人的图、含平台风险元素的画面)。所以每一次出图都要先讲清两件事:**用在哪里**、**大概花多少**,用户点头之后才动手。 - -授权不是一次性的通行证。不同批次、不同用途、不同模型各问一次。上一批同意了,不代表这一批可以默认继续。 - -## 二、发起前必须向用户说明的三项 - -1. **用途**:这批图要做什么、用在哪里(哪个产品、哪个平台、哪套主图或详情页的第几页)。说得出具体落点,不能只写"电商图"。 -2. **模型与通道**:本次将调用哪个模型(如 `qwen-image-2.1-pro`),走哪条通道(本仓库 `tools/qwen_image.py`、MCP 图片工具、网页端)。 -3. **预计花费**:`张数 × 单价 = 预计花费`。单价必须取当时官方页面的真实数字,不得凭记忆或按经验估。 - -## 三、单价的取值口径 - -- 官方入口:阿里云百炼控制台的模型详情页或「费用与成本」账单页,查看该模型当前的按张单价。 -- 不同模型、不同分辨率档位、是否含编辑、是否走折扣,单价都不同,不能混用。 -- 拿不到确切单价时,向用户说明"单价待确认",由用户提供或在控制台确认后填入。**不允许猜测一个数字填进去。** -- 本文档不写死价格,价格会随官方调整变化,写死就会误导。 - -## 四、命令行通道的执行方式 - -`tools/qwen_image.py` 已内置闸门:缺少下列任一参数直接拒绝调用,不会发出请求。 - -```bash -python tools/qwen_image.py -p "提示词" -n 4 --size 1024*1536 \ - --authorize "用户在 2026-10-29 14:20 回复:可以,按这个方案出" \ - --purpose "某某产品天猫详情页第 3 页材质特写" \ - --unit-price 0.25 -``` - -| 参数 | 作用 | 填法 | -|---|---|---| -| `--authorize` | 用户同意的凭证 | 用户原话摘录,或带日期的授权说明。不得由模型自行编造 | -| `--purpose` | 这批图的落点 | 具体到产品、平台、页面位置 | -| `--unit-price` | 每张单价(元) | 官方页面当时显示的数值 | - -脚本会在调用前把「用途、张数、单价、预计花费、模型、授权原话」打印到终端,并追加写入 `out/authorization.log` 作为审计记录。`out/` 已在 `.gitignore` 中忽略,该日志只留本机,不进版本库、不推送。 - -## 五、其他通道的执行方式 - -- **MCP 图片工具**(如 `bailian_image_gen`、`bailian_image_edit`):同样先在对话里向用户说明用途、模型、预计花费,取得明确回复后再调用。 -- **网页端手工出图**:操作者自行出图的,不受本文件约束;但由 Agent 代操作网页端时同样适用本规则。 -- **批量任务**:一批里包含多个提示词时,按整批的总张数报花费,不要拆成多次小额授权绕过确认。 -- **重试**:因效果不满意重跑同一批,属于新的一次调用,重新报花费。同一批内的参数微调(改一个词重试一张)可以在原授权范围内继续,但要在日志里记清楚。 - -## 六、禁止事项 - -1. 禁止在未取得用户同意时调用任何图片生成模型,包括"先跑一张试试效果"。 -2. 禁止自行填写 `--authorize`。该字段必须来自用户的真实回复。 -3. 禁止编造单价,或用旧价格充当当前价格。 -4. 禁止把一批拆成多批,用来规避单次授权的花费说明。 -5. 禁止在用户只同意了某一种用途后,把生成的图挪作他用。 - -## 七、授权记录的最小字段 - -每次调用至少留:时间、模型、通道、张数、尺寸、单价、预计花费、用途、用户授权原话。脚本自动写入 `out/authorization.log`;手工或 MCP 通道在对话记录里保留上述信息即可。 diff --git a/tools/fix_stats.py b/tools/fix_stats.py index 7b27117..3410c92 100644 --- a/tools/fix_stats.py +++ b/tools/fix_stats.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """修复句效果统计:从闭环跑出来的 manifest 里算出哪条修复句真正管用。 -闭环(run_loop)每轮会记下「这次因为哪些机检项失败、追加了哪些修复句」。把多轮结果聚合起来, +闭环每轮会记下「这次因为哪些机检项失败、追加了哪些修复句」。把多轮结果聚合起来, 就能知道:写纯白背景那句到底让多少张图过了白底、写 1:1 那句的命中率是多少。 以后调整修复句措辞,用这份统计判断是变好还是变坏。 @@ -140,7 +140,7 @@ def main(): manifests = load_manifests(args.dir) if not manifests: - print(f"{args.dir} 下没有 manifest.json,先跑一次 run_loop", file=sys.stderr) + print(f"{args.dir} 下没有 manifest.json,先跑一次出图闭环", file=sys.stderr) return 1 got = analyze(manifests) @@ -150,7 +150,7 @@ def main(): "\n要看修复统计,用 --generate 跑真闭环(缺授权时有授权闸门拦着,不会真的花钱)。", file=sys.stderr) else: print("manifest 里有轮次但没记修复句:可能是旧格式(fixes 是字符串而非 {id, text})。" - "\n当前版本 run_loop 写的是带 id 的对象,重跑一次闭环即可。", file=sys.stderr) + "\n当前版本闭环写的是带 id 的对象,重跑一次闭环即可。", file=sys.stderr) return 1 if args.json: diff --git a/tools/qwen_image.py b/tools/qwen_image.py deleted file mode 100644 index cdff851..0000000 --- a/tools/qwen_image.py +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/env python3 -"""Qwen-Image 2.1 Pro 出图通道(阿里云百炼 DashScope 同步接口)。 - -密钥只从环境变量 DASHSCOPE_API_KEY 或 --key-file 读取,不落盘、不打印。 - -授权闸门:本项目要求每次出图前取得用户授权(见 tools/IMAGE-GEN-AUTHORIZATION.md)。 ---authorize / --purpose / --unit-price 三项缺一不可,否则不发出请求。 - -用法: - python tools/qwen_image.py -p "提示词" -o out \ - --authorize "用户 2026-10-29 14:20 回复:可以" --purpose "产品A 天猫主图第2张" --unit-price 0.25 - python tools/qwen_image.py -f prompt.txt -i front.jpg -i detail.jpg --size 1024*1024 \ - --authorize "..." --purpose "..." --unit-price 0.25 - python tools/qwen_image.py --selftest -""" - -import argparse -import base64 -import json -import mimetypes -import os -import re -import sys -import time -import urllib.error -import urllib.request -from pathlib import Path - -API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" -DEFAULT_MODEL = "qwen-image-2.1-pro" -MAX_IMAGES = 10 - - -def read_key(key_file): - if key_file: - key = Path(key_file).read_text(encoding="utf-8-sig").strip() - else: - key = os.environ.get("DASHSCOPE_API_KEY", "").strip() - if not key: - sys.exit("缺少 API key:设置环境变量 DASHSCOPE_API_KEY,或用 --key-file 指定文件") - return key - - -def normalize_size(size): - """接受 1024*1024 / 1024x1024 / auto,输出 DashScope 要求的 宽*高。""" - if size is None or size == "auto": - return "auto" - m = re.fullmatch(r"(\d+)[*x×](\d+)", size.strip()) - if not m: - sys.exit(f"size 格式错误:{size}(应为 auto 或 宽*高,如 1024*1024)") - w, h = int(m.group(1)), int(m.group(2)) - if not (512 <= w <= 2048 and 512 <= h <= 2048): - sys.exit(f"size 超范围:{w}x{h}(边长 512 到 2048)") - if max(w, h) / min(w, h) > 8: - sys.exit(f"size 宽高比超范围:{w}:{h}(上限 8:1)") - return f"{w}*{h}" - - -def encode_image(path): - p = Path(path) - if not p.is_file(): - sys.exit(f"参考图不存在:{path}") - if p.stat().st_size > 10 * 1024 * 1024: - sys.exit(f"参考图超过 10MB:{path}") - mime = mimetypes.guess_type(p.name)[0] or "image/png" - if not mime.startswith("image/"): - sys.exit(f"不支持的图片格式:{path}") - return f"data:{mime};base64,{base64.b64encode(p.read_bytes()).decode()}" - - -def build_payload(prompt, images, size, n, seed, prompt_extend, watermark, model): - content = [{"text": prompt}] + [{"image": img} for img in images] - params = {"size": normalize_size(size), "n": n, "prompt_extend": prompt_extend, "watermark": watermark} - if seed is not None: - params["seed"] = seed - return {"model": model, "input": {"messages": [{"role": "user", "content": content}]}, "parameters": params} - - -def parse_images(resp): - """从 DashScope 响应里取出图片 URL;字段缺失时回退到递归找 image 键。""" - urls = [] - for choice in resp.get("output", {}).get("choices", []): - for item in choice.get("message", {}).get("content", []): - if isinstance(item, dict) and item.get("image"): - urls.append(item["image"]) - if not urls: - def walk(node): - if isinstance(node, dict): - for k, v in node.items(): - if k == "image" and isinstance(v, str): - urls.append(v) - else: - walk(v) - elif isinstance(node, list): - for v in node: - walk(v) - walk(resp.get("output", {})) - return urls - - -def check_authorization(authorize, purpose, unit_price, num, model, size, out_dir): - """出图前的授权闸门:三项齐全才放行,并打印与落盘预计花费。""" - missing = [ - name - for name, value in ( - ("--authorize", authorize), - ("--purpose", purpose), - ("--unit-price", unit_price), - ) - if value is None or (isinstance(value, str) and not value.strip()) - ] - if missing: - sys.exit( - "缺少授权参数:" - + "、".join(missing) - + "\n本项目要求每次出图前取得用户授权,先向用户说明用途与预计花费。" - "\n规则见 tools/IMAGE-GEN-AUTHORIZATION.md" - ) - if unit_price < 0: - sys.exit(f"--unit-price 不能为负:{unit_price}") - - total = round(unit_price * num, 4) - record = { - "time": time.strftime("%Y-%m-%d %H:%M:%S"), - "model": model, - "size": size, - "num": num, - "unit_price": unit_price, - "estimated_cost": total, - "purpose": purpose.strip(), - "authorize": authorize.strip(), - } - print( - f"[授权确认] 模型={model} 张数={num} 单价={unit_price}元 预计花费={total}元\n" - f" 用途={record['purpose']}\n" - f" 授权={record['authorize']}", - file=sys.stderr, - ) - out_dir.mkdir(parents=True, exist_ok=True) - with open(out_dir / "authorization.log", "a", encoding="utf-8") as f: - f.write(json.dumps(record, ensure_ascii=False) + "\n") - return record - - -def request(payload, key, timeout): - req = urllib.request.Request( - API_URL, - data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), - headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=timeout) as r: - return json.loads(r.read().decode("utf-8")) - except urllib.error.HTTPError as e: - body = e.read().decode("utf-8", "replace") - sys.exit(f"HTTP {e.code}:{body[:800]}") - except urllib.error.URLError as e: - sys.exit(f"请求失败:{e.reason}") - - -def download(url, out_dir, index): - out_dir.mkdir(parents=True, exist_ok=True) - ext = Path(url.split("?")[0]).suffix.lower() or ".png" - path = out_dir / f"qwen_{time.strftime('%Y%m%d_%H%M%S')}_{index}{ext}" - with urllib.request.urlopen(url, timeout=120) as r, open(path, "wb") as f: - f.write(r.read()) - return path - - -def selftest(): - import tempfile - - assert normalize_size("1024*1024") == "1024*1024" - assert normalize_size("1024x1536") == "1024*1536" - assert normalize_size("auto") == "auto" - for bad in ("511*1024", "4096*4096", "1024", "1024*2048*3"): - try: - normalize_size(bad) - except SystemExit: - pass - else: - raise AssertionError(f"应拒绝 {bad}") - p = build_payload("测试", ["data:image/png;base64,AA"], "1024*1024", 1, 7, False, False, DEFAULT_MODEL) - assert p["parameters"] == {"size": "1024*1024", "n": 1, "prompt_extend": False, "watermark": False, "seed": 7} - assert p["input"]["messages"][0]["content"][0] == {"text": "测试"} - assert p["input"]["messages"][0]["content"][1] == {"image": "data:image/png;base64,AA"} - # 默认必须关闭改写:长 SOP 内容投喂时不能被模型重写 - defaults = {action.dest: action.default for action in build_parser()._actions} - assert defaults["prompt_extend"] is False, "prompt_extend 默认应为 False" - assert defaults["authorize"] is None and defaults["purpose"] is None and defaults["unit_price"] is None - fake = {"output": {"choices": [{"message": {"content": [{"image": "https://a/1.png"}]}}]}} - assert parse_images(fake) == ["https://a/1.png"] - assert parse_images({"output": {"results": [{"url": "x", "image": "https://a/2.png"}]}}) == ["https://a/2.png"] - - with tempfile.TemporaryDirectory() as tmp: - out = Path(tmp) - # 授权三项缺任一都必须拦住,且不写日志 - for kwargs in ( - {"authorize": None, "purpose": "用途", "unit_price": 0.2}, - {"authorize": "用户同意", "purpose": None, "unit_price": 0.2}, - {"authorize": "用户同意", "purpose": "用途", "unit_price": None}, - {"authorize": " ", "purpose": "用途", "unit_price": 0.2}, - ): - try: - check_authorization(kwargs["authorize"], kwargs["purpose"], kwargs["unit_price"], 1, DEFAULT_MODEL, "1024*1024", out) - except SystemExit: - pass - else: - raise AssertionError(f"应拒绝未授权参数:{kwargs}") - assert not (out / "authorization.log").exists(), "未授权时不应写审计日志" - # 三项齐全时放行,花费按 张数 × 单价 计算并落盘 - rec = check_authorization("用户 2026-10-29 同意", "产品A 天猫主图", 0.25, 4, DEFAULT_MODEL, "1024*1536", out) - assert rec["estimated_cost"] == 1.0, rec - logged = (out / "authorization.log").read_text(encoding="utf-8").splitlines() - assert len(logged) == 1 - assert json.loads(logged[0])["purpose"] == "产品A 天猫主图" - print("selftest ok") - - -def build_parser(): - ap = argparse.ArgumentParser(description="Qwen-Image 2.1 Pro 出图") - ap.add_argument("-p", "--prompt", help="提示词文本") - ap.add_argument("-f", "--prompt-file", help="从文件读取提示词") - ap.add_argument("-i", "--image", action="append", default=[], help="参考图路径,可重复,最多 10 张") - ap.add_argument("--size", default="1024*1024", help="auto 或 宽*高,默认 1024*1024") - ap.add_argument("-n", "--num", type=int, default=1, help="出图数量 1 到 6,默认 1") - ap.add_argument("--seed", type=int, help="随机种子,固定后结果相对稳定") - ap.add_argument("--prompt-extend", action="store_true", help="开启提示词智能改写,默认关闭(SOP 内容直接投喂时保原样)") - ap.add_argument("--watermark", action="store_true", help="添加水印,默认不加") - ap.add_argument("--model", default=DEFAULT_MODEL, help=f"模型名,默认 {DEFAULT_MODEL}") - ap.add_argument("--key-file", help="API key 文件路径;不传则读 DASHSCOPE_API_KEY") - ap.add_argument("--authorize", help="用户授权凭证(用户原话或带日期说明),必填;见 tools/IMAGE-GEN-AUTHORIZATION.md") - ap.add_argument("--purpose", help="这批图的用途与落点(产品/平台/页面位置),必填") - ap.add_argument("--unit-price", type=float, help="每张单价(元,取官方页面当时数值),必填") - ap.add_argument("-o", "--out", default="out", help="输出目录,默认 out") - ap.add_argument("--timeout", type=int, default=300, help="请求超时秒数,默认 300") - ap.add_argument("--selftest", action="store_true", help="离线自检,不调用 API") - return ap - - -def main(): - args = build_parser().parse_args() - - if args.selftest: - selftest() - return - - prompt = args.prompt - if args.prompt_file: - prompt = Path(args.prompt_file).read_text(encoding="utf-8-sig").strip() - if not prompt: - sys.exit("缺少提示词:用 -p 或 -f 提供") - if len(args.image) > MAX_IMAGES: - sys.exit(f"参考图最多 {MAX_IMAGES} 张,当前 {len(args.image)} 张") - if not 1 <= args.num <= 6: - sys.exit("--num 取值范围 1 到 6") - - # 授权闸门在读取密钥和发请求之前,未授权不会产生任何 API 调用 - check_authorization( - args.authorize, - args.purpose, - args.unit_price, - args.num, - args.model, - normalize_size(args.size), - Path(args.out), - ) - - key = read_key(args.key_file) - payload = build_payload( - prompt, - [encode_image(p) for p in args.image], - args.size, - args.num, - args.seed, - args.prompt_extend, - args.watermark, - args.model, - ) - - started = time.time() - resp = request(payload, key, args.timeout) - urls = parse_images(resp) - if not urls: - sys.exit(f"响应中未找到图片:{json.dumps(resp, ensure_ascii=False)[:800]}") - - for idx, url in enumerate(urls, 1): - print(download(url, Path(args.out), idx)) - print(f"request_id={resp.get('request_id', '-')} 耗时={time.time() - started:.1f}s", file=sys.stderr) - - -if __name__ == "__main__": - main() diff --git a/tools/run_loop.py b/tools/run_loop.py deleted file mode 100644 index b1f8ade..0000000 --- a/tools/run_loop.py +++ /dev/null @@ -1,293 +0,0 @@ -# -*- coding: utf-8 -*- -"""出图闭环:生成 → 平台机检 → 把修复句写回提示词 → 重跑,直到通过或到轮次上限。 - -解决的问题:机检报告一堆 fail,人要逐条翻译成提示词修改再手动重出,一轮一轮试。 -这里把「报告 → 修复句 → 下一轮提示词」这一段自动化,每轮落盘,最后给一份 manifest。 - -用法: - # 离线推演:给现成图,只跑判定与修复句生成,不调用任何出图 API - python tools/run_loop.py prompt.txt --platform amazon --image out/main.png --dry-run - - # 真跑:调 qwen_image.py 出图,授权三件必填(规则见 tools/IMAGE-GEN-AUTHORIZATION.md) - python tools/run_loop.py prompt.txt --platform amazon --generate --max-rounds 3 \ - --authorize "用户 2026-11-09 19:50 回复:可以" --purpose "产品A Amazon 主图" --unit-price 0.25 - -产物:--out 目录下 round-/prompt.txt 与图片,以及 manifest.json。 -退出码:0 通过;1 到轮次上限仍未通过;2 参数或依赖缺失。 -""" -import argparse -import hashlib -import importlib.util -import json -import subprocess -import sys -import time -from pathlib import Path - -HERE = Path(__file__).resolve().parent -sys.path.insert(0, str(HERE)) - -try: - from check_image import ALIASES, PLATFORMS, analyze -except ImportError as e: # 机检是本脚本的核心依赖 - sys.exit(f"缺少 check_image.py:{e}") - -_slop_spec = importlib.util.find_spec("slop_check") -slop_check = None -if _slop_spec: - from slop_check import check as slop_check - -try: - from visual_audit import audit as visual_audit_fn -except ImportError: # 视觉验收是增强项,缺了不影响几何机检 - visual_audit_fn = None - -# 机检项 → 追加到提示词的修复句。只写正向要求,不堆负面词。 -FIXES = { - "background": "背景为纯白 RGB(255,255,255),产品底部保留自然接触阴影,不要灰底、渐变底或环境色。", - "occupancy": "产品占画面约 85%,四周留白收紧,不要大块空白。", - "ratio": "画面为 1:1 正方形构图,产品完整入画不裁切。", - "size": "输出长边不少于 1600 像素,边缘锐利,材质纹理清晰。", - "text": "画面中不出现任何文字、字母、数字、符号、水印、logo 贴片与信息卡。", - "alpha": "背景完全不透明并铺满画面。", - "subject": "画面必须有一个清晰完整的产品主体,不要空景或纯背景。", - "filesize": "输出控制在 10MB 以内,保持高质量 JPEG。", -} - - -def sha(text): - return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12] - - -def generate(prompt_path, out_dir, args): - """调 qwen_image.py 出图,返回新图路径。授权闸门在子进程内,这里只做参数完整性预检。""" - cmd = [ - sys.executable, str(HERE / "qwen_image.py"), - "-f", str(prompt_path), "-o", str(out_dir), "-n", "1", - "--authorize", args.authorize, "--purpose", args.purpose, - "--unit-price", str(args.unit_price), - ] - if args.size: - cmd += ["--size", args.size] - if args.seed is not None: - cmd += ["--seed", str(args.seed)] - if args.key_file: - cmd += ["--key-file", args.key_file] - for ref in args.ref: - cmd += ["-i", ref] - - proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") - if proc.returncode != 0: - raise RuntimeError(f"出图失败:{(proc.stderr or proc.stdout).strip()[:400]}") - lines = [ln.strip() for ln in proc.stdout.splitlines() if ln.strip()] - if not lines: - raise RuntimeError("出图脚本未返回图片路径") - return lines[-1] - - -def selftest(): - import tempfile - - if not Path(HERE / "check_image.py").exists(): - print("FAIL 缺少 check_image.py") - return 1 - try: - from PIL import Image - except ImportError: - print("selftest: 未安装 Pillow,跳过像素相关用例") - return 0 - - fails = [] - with tempfile.TemporaryDirectory() as tmp: - tmp = Path(tmp) - prompt = tmp / "p.txt" - prompt.write_text("白底主图,产品居中,柔和顶光。", encoding="utf-8") - - # 1) 灰底小图 dry-run:应判 fail 并生成修复句 - bad = tmp / "bad.png" - im = Image.new("RGB", (900, 1200), (208, 208, 208)) - im.paste((40, 80, 140), (250, 400, 650, 800)) - im.save(bad) - rc = main([str(prompt), "--platform", "amazon", "--image", str(bad), "--dry-run", "--out", str(tmp / "loop1")]) - mf = json.loads((tmp / "loop1" / "manifest.json").read_text(encoding="utf-8")) - if rc != 1: - fails.append(f"不合格图应退出 1,实际 {rc}") - if mf["rounds"][0]["verdict"] != "fail": - fails.append("不合格图未判 fail") - if not mf["rounds"][0]["fixes"]: - fails.append("未生成修复句") - if "纯白 RGB" not in (tmp / "loop1" / "manifest.json").read_text(encoding="utf-8"): - fails.append("修复句未写入 manifest") - - # 2) 合规白底图 dry-run:应通过 - ok = tmp / "ok.png" - good = Image.new("RGB", (1600, 1600), (255, 255, 255)) - good.paste((40, 80, 140), (80, 80, 1520, 1520)) - good.save(ok) - rc = main([str(prompt), "--platform", "amazon", "--image", str(ok), "--dry-run", "--out", str(tmp / "loop2")]) - mf2 = json.loads((tmp / "loop2" / "manifest.json").read_text(encoding="utf-8")) - if rc != 0: - fails.append(f"合规图应退出 0,实际 {rc}") - if mf2["result"] != "passed": - fails.append(f"合规图未判 passed:{mf2['result']}") - - # 3) 真跑模式缺授权参数必须被拦住,且不产生任何调用 - rc = main([str(prompt), "--platform", "amazon", "--generate", "--dry-run", "--out", str(tmp / "loop3")]) - if rc != 2: - fails.append(f"缺授权参数应退出 2,实际 {rc}") - - if fails: - for f in fails: - print("FAIL", f) - return 1 - print("selftest: 3 组用例全部通过") - return 0 - - -def main(argv=None): - ap = argparse.ArgumentParser(description="出图闭环:机检不过就把修复句写回提示词重跑") - ap.add_argument("prompt", nargs="?", help="提示词文件") - ap.add_argument("--platform", default="generic", help="平台名,见 tools/check_image.py 的 PLATFORMS") - ap.add_argument("--image", help="离线模式待检图") - ap.add_argument("--ref", action="append", default=[], help="生成模式的参考图,可重复") - ap.add_argument("--generate", action="store_true", help="真跑出图(需授权三件)") - ap.add_argument("--dry-run", action="store_true", help="只做判定与修复句推演,不出图") - ap.add_argument("--max-rounds", type=int, default=3, help="最多几轮,默认 3") - ap.add_argument("--out", default="out/loop", help="产物目录,默认 out/loop") - ap.add_argument("--size", help="出图尺寸,透传给 qwen_image.py") - ap.add_argument("--seed", type=int, help="固定种子") - ap.add_argument("--key-file", help="密钥文件,透传给 qwen_image.py") - ap.add_argument("--authorize", help="用户授权凭证(用户原话或带日期说明)") - ap.add_argument("--purpose", help="这批图的用途与落点") - ap.add_argument("--unit-price", type=float, help="每张单价(元)") - ap.add_argument("--selftest", action="store_true", help="离线自检") - args = ap.parse_args(argv) - - if args.selftest: - return selftest() - - if not args.prompt: - print("缺少提示词文件参数", file=sys.stderr) - return 2 - - if args.generate and args.dry_run: - args.generate = False - if args.generate and not (args.authorize and args.purpose and args.unit_price is not None): - print("真跑模式必须给全 --authorize / --purpose / --unit-price(规则见 tools/IMAGE-GEN-AUTHORIZATION.md)", file=sys.stderr) - return 2 - if not args.generate and not args.image: - print("离线模式必须用 --image 指定待检图", file=sys.stderr) - return 2 - if args.max_rounds < 1: - print("--max-rounds 至少 1", file=sys.stderr) - return 2 - - prompt_file = Path(args.prompt) - if not prompt_file.exists(): - print(f"提示词文件不存在:{prompt_file}", file=sys.stderr) - return 2 - platform = ALIASES.get(args.platform, args.platform) - if platform not in PLATFORMS: - print(f"未知平台 {args.platform},可选:{', '.join(sorted(PLATFORMS))}", file=sys.stderr) - return 2 - spec = PLATFORMS[platform] - - current = prompt_file.read_text(encoding="utf-8-sig").strip() - loop_dir = Path(args.out) - loop_dir.mkdir(parents=True, exist_ok=True) - rounds, result, total_cost = [], "exhausted", 0.0 - - # 离线模式的图不会变,多轮只是重复同一份判定;只跑一轮,把修复句给出来即可。 - rounds_cap = args.max_rounds if args.generate else 1 - if not args.generate and args.max_rounds > 1: - print("离线模式图不变,只跑 1 轮;要看多轮演进请用 --generate", file=sys.stderr) - - for n in range(1, rounds_cap + 1): - rd = loop_dir / f"round-{n}" - rd.mkdir(parents=True, exist_ok=True) - round_prompt = rd / "prompt.txt" - round_prompt.write_text(current + "\n", encoding="utf-8") - - issues = slop_check(current) if slop_check else [] - slop_stat = { - "error": sum(1 for i in issues if i["level"] == "error"), - "warn": sum(1 for i in issues if i["level"] == "warn"), - } - - entry = {"round": n, "prompt": str(round_prompt), "prompt_sha": sha(current), "slop": slop_stat} - try: - if args.generate: - image = generate(round_prompt, rd, args) - total_cost += args.unit_price or 0 - entry["cost"] = round(args.unit_price or 0, 3) - else: - image = args.image - except RuntimeError as e: - entry["error"] = str(e) - rounds.append(entry) - break - - checks, metrics = analyze(image, spec) - failed = [c for c in checks if c["status"] == "fail"] - entry.update({ - "image": image, - "verdict": "fail" if failed else "pass", - "fails": [c["id"] for c in failed], - "skipped": [c["id"] for c in checks if c["status"] == "skipped"], - "metrics": metrics, - }) - # 视觉验收只记录不判定:一致度与 AI 感是启发式,不能替代人看 - if args.ref and visual_audit_fn: - try: - entry["visual"] = visual_audit_fn(image, args.ref[0]) - except Exception as e: # 审计失败不该中断闭环 - entry["visual"] = {"error": str(e)[:160]} - rounds.append(entry) - - if not failed: - result = "passed" - break - - fix_list = [{"id": c["id"], "text": FIXES[c["id"]]} for c in failed if c["id"] in FIXES] - entry["fixes"] = fix_list - if not fix_list: - break - # 下一轮:原提示词 + 修复句。累积写,保留每一轮改过什么,便于回看。 - current = current + "\n" + "".join(f["text"] for f in fix_list) - - manifest = { - "tool": "run_loop.py", - "mode": "generate" if args.generate else "dry-run", - "platform": platform, - "max_rounds": args.max_rounds, - "prompt_file": str(prompt_file), - "result": result, - "total_cost": round(total_cost, 3), - "finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), - "rounds": rounds, - } - (loop_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") - (loop_dir / "final-prompt.txt").write_text(current + "\n", encoding="utf-8") - - print(f"{platform} · {manifest['mode']} · {len(rounds)} 轮 · {result}") - for r in rounds: - line = f" round-{r['round']}: {r.get('verdict', r.get('error', '-'))}" - if r.get("fails"): - line += f" fail={','.join(r['fails'])}" - if r.get("fixes"): - line += f" 修复句 {len(r['fixes'])} 条" - if r.get("cost"): - line += f" 花费 {r['cost']}" - v = r.get("visual") or {} - if v.get("consistency"): - line += f" 一致度 {v['consistency']['score']}" - if v.get("ai_smell"): - line += f" AI感 {v['ai_smell']['score']}" - print(line) - print(f" manifest: {loop_dir / 'manifest.json'}") - print(f" final: {loop_dir / 'final-prompt.txt'}") - return 0 if result == "passed" else 1 - - -if __name__ == "__main__": - sys.exit(main())