init: 商品图合规检测工具(豆包初筛 + DeepSeek 复检级联)
- src 标准布局:config/providers/pipeline/report + CLI/Tk GUI 双入口 - 级联省钱:豆包全量初筛,仅无违规/违规不明图进 DeepSeek 复检(含两票复核) - 输出:时间戳目录 + 分类文件夹图片归档 + Excel 报表 - 30 个单元测试(tests/,测试图片不入库)
This commit is contained in:
@@ -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": {}}
|
||||
Reference in New Issue
Block a user