init: 商品图合规检测工具(豆包初筛 + DeepSeek 复检级联)

- src 标准布局:config/providers/pipeline/report + CLI/Tk GUI 双入口
- 级联省钱:豆包全量初筛,仅无违规/违规不明图进 DeepSeek 复检(含两票复核)
- 输出:时间戳目录 + 分类文件夹图片归档 + Excel 报表
- 30 个单元测试(tests/,测试图片不入库)
This commit is contained in:
yeuimu
2026-09-02 15:28:51 +08:00
commit 08d26710b4
20 changed files with 2513 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
"""视觉模型提供方:豆包(火山方舟)与 DeepSeek,均为 OpenAI 兼容 chat/completions。
统一的返回结构:
{"file", "provider", "status"(ok/parse_fail/error), "category", "attr",
"logic", "raw", "usage": {"prompt", "completion", "cached"}, "finish_reason"}
"""
import asyncio
import base64
import json
import os
import re
import aiohttp
from .config import AppConfig
MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".webp": "image/webp", ".gif": "image/gif", ".bmp": "image/bmp",
".tiff": "image/tiff"}
IMAGE_EXTS = set(MIME)
# 17 类标准名称:按分类号归一化模型输出的写法差异
CANON = {
1: "1. 烟草、毒品、赌博、犹太", 2: "2. 酒精", 3: "3. 色情", 4: "4. LGBT",
5: "5. 负向敏感类信息(血腥暴力)、战争(含动漫里的战争)、武器类、纳粹",
6: "6. 政治相关(包含军队组织等相关内容)", 7: "7. 种族歧视和文化差异",
8: "8. 旗帜和徽章", 9: "9. 宗教 - 其他教", 10: "10. 宗教 - 伊斯兰教",
11: "11. 宗教 - 基督教", 12: "12. 侵权 - 除人物外的其他侵权",
13: "13. 侵权 - 人物相关(如肖像权、姓名权等)",
14: "14. 脏话、侮辱性、谩骂攻击类文字图案(包含冒犯和争议性内容)",
15: "15. 图案消极低俗或者暴力(包含冒犯和争议性内容)",
16: "16. 欧区国家禁售", 17: "17. 冒犯和争议性内容",
}
def encode_image(path: str) -> str:
ext = os.path.splitext(path)[1].lower()
if ext not in MIME:
raise ValueError(f"不支持的图片格式: {path}")
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
return f"data:{MIME[ext]};base64,{b64}"
def norm_category(cat: str) -> str:
c = str(cat).strip()
m = re.match(r"^(\d+)", c)
return CANON.get(int(m.group(1)), c) if m else c
def parse_judgment(raw: str):
"""解析模型返回的三字段 JSON,返回 (attr, logic, category) 或 None。"""
if not raw:
return None
text = raw.strip()
m = re.search(r"```(?:json)?\s*(.*?)```", text, re.S)
if m:
text = m.group(1).strip()
s, e = text.find("{"), text.rfind("}")
if s == -1 or e <= s:
return None
try:
obj = json.loads(text[s:e + 1])
except json.JSONDecodeError:
return None
keys = ["文字/图形属性", "侵权/违规逻辑", "违规分类"]
if not all(k in obj for k in keys):
return None
return (str(obj["文字/图形属性"]).strip(), str(obj["侵权/违规逻辑"]).strip(),
str(obj["违规分类"]).strip())
def _norm_usage(u: dict) -> dict:
u = u or {}
return {"prompt": u.get("prompt_tokens"), "completion": u.get("completion_tokens"),
"cached": (u.get("prompt_tokens_details") or {}).get("cached_tokens", 0)}
def _result(fname, provider, content, data, path_for_err=""):
ch = (data.get("choices") or [{}])[0]
parsed = parse_judgment(content)
base = {"file": fname, "provider": provider, "finish_reason": ch.get("finish_reason"),
"usage": _norm_usage(data.get("usage"))}
if parsed:
attr, logic, cat = parsed
base.update({"status": "ok", "attr": attr, "logic": logic,
"category": norm_category(cat), "raw": content})
else:
base.update({"status": "parse_fail", "attr": "", "logic": "",
"category": "违规不明", "raw": content or ""})
return base
async def detect_ark(session, sem, cfg: AppConfig, prompt: str, path: str, stop=None):
"""豆包(火山方舟)视觉检测:图片在前、文本在后,强制 JSON 输出。"""
fname = os.path.basename(path)
url = cfg.ark_base_url.rstrip("/") + "/chat/completions"
headers = {"Authorization": f"Bearer {cfg.ark_api_key}",
"Content-Type": "application/json",
"x-is-encrypted": "true"}
payload = {
"model": cfg.ark_model,
"messages": [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": encode_image(path)}},
{"type": "text", "text": prompt},
],
}],
"response_format": {"type": "json_object"},
}
last_err = ""
for attempt in range(1, cfg.retries + 1):
if stop and stop():
return {"file": fname, "provider": "doubao", "status": "error", "attr": "",
"logic": "", "category": "检测异常", "raw": "用户停止", "usage": {}}
try:
async with sem:
async with session.post(url, json=payload, headers=headers) as r:
if r.status == 200:
data = await r.json()
content = data["choices"][0]["message"]["content"]
return _result(fname, "doubao", content, data)
last_err = f"HTTP {r.status}: {(await r.text())[:300]}"
except Exception as e: # noqa: BLE001
last_err = f"{type(e).__name__}: {e}"
if attempt < cfg.retries:
await asyncio.sleep(3 * attempt)
return {"file": fname, "provider": "doubao", "status": "error", "attr": "",
"logic": "", "category": "检测异常", "raw": last_err, "usage": {}}
async def detect_deepseek(session, sem, cfg: AppConfig, prompt: str, path: str, stop=None):
"""DeepSeek 视觉检测:temperature=0 + JSON 模式。
注意该模型为推理模型,reasoning 消耗 completion 预算,max_tokens 须留足。"""
fname = os.path.basename(path)
url = cfg.deepseek_base_url.rstrip("/") + "/chat/completions"
headers = {"Authorization": f"Bearer {cfg.deepseek_api_key}",
"Content-Type": "application/json"}
payload = {
"model": cfg.deepseek_model,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": encode_image(path)}},
],
}],
"temperature": 0,
"response_format": {"type": "json_object"},
"max_tokens": cfg.max_tokens,
}
last_err = ""
for attempt in range(1, cfg.retries + 1):
if stop and stop():
return {"file": fname, "provider": "deepseek", "status": "error", "attr": "",
"logic": "", "category": "检测异常", "raw": "用户停止", "usage": {}}
try:
async with sem:
async with session.post(url, json=payload, headers=headers) as r:
if r.status == 200:
data = await r.json()
content = data["choices"][0]["message"]["content"]
if content and content.strip():
return _result(fname, "deepseek", content, data)
# 推理烧尽预算会得到空正文,按可重试错误处理
last_err = "empty content (reasoning exhausted max_tokens?)"
else:
last_err = f"HTTP {r.status}: {(await r.text())[:300]}"
except Exception as e: # noqa: BLE001
last_err = f"{type(e).__name__}: {e}"
if attempt < cfg.retries:
await asyncio.sleep(3 * attempt)
return {"file": fname, "provider": "deepseek", "status": "error", "attr": "",
"logic": "", "category": "检测异常", "raw": last_err, "usage": {}}