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
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
"""服装商品图合规检测:豆包初筛 + DeepSeek 复检级联。"""
__version__ = "1.0.0"
+5
View File
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""CLI 入口:python -m violation_detector <图片文件夹> [选项]"""
import argparse
import asyncio
import sys
from . import __version__
from .config import DEFAULT_CONFIG, load_config, missing_fields
from .pipeline import run_detection
from .report import build_report, organize_output
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="violation-detector",
description="服装商品图合规检测:豆包视觉初筛 + DeepSeek 复检级联,输出 Excel 报表")
p.add_argument("folder", help="待检测图片文件夹")
p.add_argument("-o", "--output", default=None, help="报表输出目录(默认为图片文件夹)")
p.add_argument("-c", "--config", default=None, help="配置文件路径(默认 config.ini)")
p.add_argument("--prompt", default=None, help="提示词文件路径(覆盖配置)")
p.add_argument("--mode", choices=["cascade", "doubao", "deepseek"], default="cascade",
help="检测模式:cascade=豆包初筛+DeepSeek复检(默认),doubao=仅豆包,"
"deepseek=仅 DeepSeek 全量")
p.add_argument("--workers-ark", type=int, default=None, help="豆包并发数")
p.add_argument("--workers-ds", type=int, default=None, help="DeepSeek 并发数")
p.add_argument("--max-tokens", type=int, default=None,
help="DeepSeek max_tokens(默认 5000,实测零失败最小档)")
p.add_argument("--no-verify", action="store_true", help="跳过 DeepSeek 无违规两票复核")
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
return p
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
cfg = load_config(args.config)
if args.prompt:
cfg.prompt_file = args.prompt
if args.workers_ark:
cfg.ark_workers = args.workers_ark
if args.workers_ds:
cfg.deepseek_workers = args.workers_ds
if args.max_tokens:
cfg.max_tokens = args.max_tokens
missing = missing_fields(cfg)
if missing:
print(f"配置不完整,请编辑 {DEFAULT_CONFIG} 填写以下必填项:")
for m in missing:
print(f" - {m}")
return 2
try:
rows, summary = asyncio.run(run_detection(
args.folder, cfg, mode=args.mode, verify=not args.no_verify))
except FileNotFoundError as e:
print(f"错误:{e}")
return 1
out_dir = args.output or args.folder
ts_dir = organize_output(rows, args.folder, out_dir)
xlsx = build_report(rows, str(ts_dir), {"mode": args.mode, **{
k: summary.get(k) for k in ("ds_calls", "ds_peak_cost", "ds_idle_cost")}})
print(f"\n分类输出目录:{ts_dir}")
print(f"报表已生成:{xlsx}")
return 0
if __name__ == "__main__":
sys.exit(main())
+185
View File
@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""项目配置:config.ini 自动生成 + 环境变量覆盖,兼容 PyInstaller 冻结环境。"""
import configparser
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
def is_frozen() -> bool:
return getattr(sys, "frozen", False)
def app_dir() -> Path:
"""可执行文件/项目所在目录:config.ini、runs/ 都放在这里。"""
if is_frozen():
return Path(sys.executable).parent
return Path(__file__).resolve().parents[2]
def resource_dir() -> Path:
"""内置资源目录(打包后的 prompts.txt 等)。"""
if is_frozen():
return Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
return Path(__file__).resolve().parents[2]
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CONFIG = app_dir() / "config.ini"
DEFAULT_PROMPT = resource_dir() / "prompts.txt"
RUNS_DIR = app_dir() / "runs"
DEFAULT_INI = """\
# 商品图合规检测工具配置(首次运行自动生成)
# 豆包(火山方舟)初筛模型:api_key 与 model_id 必填
[ark]
api_key =
model_id =
base_url = https://ark.cn-beijing.volces.com/api/v3
workers = 12
# DeepSeek 复检模型:api_key 与 model 必填
[deepseek]
api_key =
model = deepseek-v4-flash-vision-exp
base_url = https://api.deepseek.com
workers = 6
# 梯度实测:零失败的最小档
max_tokens = 5000
# 提示词:留空使用内置提示词
[prompt]
file =
# 级联复检范围:豆包初筛得到这些结论的图片会再过 DeepSeek
[cascade]
recheck = 无违规,违规不明,检测异常
"""
# 环境变量优先于配置文件
ENV_OVERRIDES = {
"ark_api_key": "ARK_API_KEY",
"ark_model": "ARK_MODEL_ID",
"deepseek_api_key": "DEEPSEEK_API_KEY",
"deepseek_model": "DEEPSEEK_MODEL",
}
# DeepSeek 官方定价(元/每百万 token):(高峰, 空闲);高峰=工作日 9-12、14-18 点
PRICE = {"miss": (3.0, 1.5), "cached": (0.10, 0.05), "output": (9.0, 4.5)}
@dataclass
class AppConfig:
# 豆包(火山方舟)
ark_api_key: str = ""
ark_model: str = ""
ark_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
ark_workers: int = 12
# DeepSeek
deepseek_api_key: str = ""
deepseek_base_url: str = "https://api.deepseek.com"
deepseek_model: str = "deepseek-v4-flash-vision-exp"
deepseek_workers: int = 6
max_tokens: int = 5000
# 提示词与级联
prompt_file: str = ""
recheck_categories: list = field(default_factory=lambda: ["无违规", "违规不明", "检测异常"])
# 运行
retries: int = 3
@property
def prompt_path(self) -> Path:
"""提示词路径:空=内置;相对路径依次在工作目录/资源目录中查找。"""
raw = (self.prompt_file or "").strip()
if not raw:
return DEFAULT_PROMPT
p = Path(raw)
if p.is_absolute():
return p
for base in (app_dir(), resource_dir()):
cand = base / p
if cand.exists():
return cand
return app_dir() / p
def ensure_config(path: str | None = None) -> Path:
"""config.ini 不存在时生成默认模板,返回其路径。"""
ini = Path(path) if path else DEFAULT_CONFIG
if not ini.exists():
ini.parent.mkdir(parents=True, exist_ok=True)
ini.write_text(DEFAULT_INI, encoding="utf-8")
return ini
def missing_fields(cfg: AppConfig) -> list:
"""级联模式必填项检查,返回缺失项中文名列表。"""
missing = []
if not cfg.ark_api_key:
missing.append("豆包 API Key[ark] api_key")
if not cfg.ark_model:
missing.append("豆包模型接入点([ark] model_id")
if not cfg.deepseek_api_key:
missing.append("DeepSeek API Key[deepseek] api_key")
if not cfg.deepseek_model:
missing.append("DeepSeek 模型([deepseek] model")
return missing
def load_config(path: str | None = None) -> AppConfig:
ensure_config(path)
cfg = AppConfig()
ini = Path(path) if path else DEFAULT_CONFIG
parser = configparser.ConfigParser()
parser.read(ini, encoding="utf-8")
def get(section, key, conv=str):
if parser.has_option(section, key):
return conv(parser.get(section, key).strip())
return None
cfg.ark_api_key = get("ark", "api_key") or cfg.ark_api_key
cfg.ark_model = get("ark", "model_id") or cfg.ark_model
cfg.ark_base_url = get("ark", "base_url") or cfg.ark_base_url
cfg.ark_workers = get("ark", "workers", int) or cfg.ark_workers
cfg.deepseek_api_key = get("deepseek", "api_key") or cfg.deepseek_api_key
cfg.deepseek_base_url = get("deepseek", "base_url") or cfg.deepseek_base_url
cfg.deepseek_model = get("deepseek", "model") or cfg.deepseek_model
cfg.deepseek_workers = get("deepseek", "workers", int) or cfg.deepseek_workers
cfg.max_tokens = get("deepseek", "max_tokens", int) or cfg.max_tokens
cfg.prompt_file = get("prompt", "file") or ""
raw = get("cascade", "recheck")
if raw:
cfg.recheck_categories = [s.strip() for s in raw.split(",") if s.strip()]
for attr, env in ENV_OVERRIDES.items():
val = os.environ.get(env)
if val:
setattr(cfg, attr, val)
return cfg
def save_config(cfg: AppConfig, path: str | None = None):
"""把配置写回 ini(GUI 首次配置对话框使用)。"""
ini = Path(path) if path else DEFAULT_CONFIG
parser = configparser.ConfigParser()
parser["ark"] = {"api_key": cfg.ark_api_key, "model_id": cfg.ark_model,
"base_url": cfg.ark_base_url, "workers": str(cfg.ark_workers)}
parser["deepseek"] = {"api_key": cfg.deepseek_api_key, "model": cfg.deepseek_model,
"base_url": cfg.deepseek_base_url,
"workers": str(cfg.deepseek_workers),
"max_tokens": str(cfg.max_tokens)}
parser["prompt"] = {"file": cfg.prompt_file or ""}
parser["cascade"] = {"recheck": ",".join(cfg.recheck_categories)}
with ini.open("w", encoding="utf-8") as f:
parser.write(f)
def load_prompt(path: Path) -> str:
"""读取提示词文件:去掉每行行首缩进(兼容历史版本格式)。"""
text = path.read_text(encoding="utf-8-sig")
prompt = "\n".join(ln.lstrip() for ln in text.splitlines()).strip()
if not prompt:
raise ValueError(f"提示词文件为空: {path}")
return prompt
+269
View File
@@ -0,0 +1,269 @@
# -*- coding: utf-8 -*-
"""Tk GUI 入口:python -m violation_detector.gui 或 violation-detector-gui。
面向使用者的极简界面:只选文件夹和输出目录,级联模式与两票复核固定开启,
模型参数全部走 config.ini(缺失时弹出首次配置对话框)。
检测在后台线程运行 asyncio 事件循环;日志与进度经线程安全队列由主线程刷新。
"""
import asyncio
import os
import queue
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext, ttk
from . import __version__
from .config import load_config, missing_fields, save_config
from .pipeline import run_detection
from .report import build_report, organize_output
class FirstRunDialog:
"""首次配置对话框:补齐级联模式必填的四项模型配置并写入 config.ini。"""
FIELDS = [
("ark_api_key", "豆包 API Key", ""),
("ark_model", "豆包模型接入点 (model_id)", ""),
("deepseek_api_key", "DeepSeek API Key", ""),
("deepseek_model", "DeepSeek 模型", "deepseek-v4-flash-vision-exp"),
]
def __init__(self, root, cfg):
self.ok = False
self.win = tk.Toplevel(root)
self.win.title("首次配置 - 商品图合规检测工具")
self.win.resizable(False, False)
self.win.grab_set()
frm = ttk.Frame(self.win, padding=14)
frm.pack(fill="both", expand=True)
ttk.Label(frm, text="请填写以下必填项(将保存到 config.ini,下次无需重复填写):").grid(
row=0, column=0, columnspan=2, sticky="w", pady=(0, 8))
self.vars = {}
for i, (attr, label, default) in enumerate(self.FIELDS, start=1):
ttk.Label(frm, text=label).grid(row=i, column=0, sticky="w", pady=4)
v = tk.StringVar(value=getattr(cfg, attr) or default)
ttk.Entry(frm, textvariable=v, width=44).grid(row=i, column=1, padx=(8, 0), pady=4)
self.vars[attr] = v
btns = ttk.Frame(frm)
btns.grid(row=len(self.FIELDS) + 1, column=0, columnspan=2, pady=(10, 0))
ttk.Button(btns, text="保存并开始", command=self._save).pack(side="left", padx=6)
ttk.Button(btns, text="取消", command=self.win.destroy).pack(side="left", padx=6)
self.win.wait_window()
def _save(self):
values = {attr: v.get().strip() for attr, v in self.vars.items()}
empty = [label for attr, label, _ in self.FIELDS if not values[attr]]
if empty:
messagebox.showwarning("提示", "以下项不能为空:\n" + "\n".join(empty), parent=self.win)
return
self.values = values
self.ok = True
self.win.destroy()
class App:
def __init__(self, root: tk.Tk):
self.root = root
root.title(f"商品图合规检测工具 v{__version__}")
root.geometry("880x640")
self.cfg = load_config()
self.q: queue.Queue = queue.Queue()
self.stop_event = threading.Event()
self.worker: threading.Thread | None = None
self.last_xlsx = ""
self.last_outdir = ""
frm = ttk.Frame(root, padding=12)
frm.pack(fill="both", expand=True)
# ---- 输入区(仅三项:图片文件夹 / 输出目录 / 可选自定义提示词)----
grid = ttk.Frame(frm)
grid.pack(fill="x")
grid.columnconfigure(1, weight=1)
ttk.Label(grid, text="图片文件夹").grid(row=0, column=0, sticky="w", pady=4)
self.var_folder = tk.StringVar()
ttk.Entry(grid, textvariable=self.var_folder).grid(row=0, column=1, sticky="ew", padx=6)
ttk.Button(grid, text="浏览…", command=self.pick_folder).grid(row=0, column=2)
ttk.Label(grid, text="输出目录").grid(row=1, column=0, sticky="w", pady=4)
self.var_output = tk.StringVar()
ttk.Entry(grid, textvariable=self.var_output).grid(row=1, column=1, sticky="ew", padx=6)
ttk.Button(grid, text="浏览…", command=self.pick_output).grid(row=1, column=2)
ttk.Label(grid, text="提示词").grid(row=2, column=0, sticky="w", pady=4)
self.var_prompt = tk.StringVar(value="")
self.ent_prompt = ttk.Entry(grid, textvariable=self.var_prompt)
self.ent_prompt.grid(row=2, column=1, sticky="ew", padx=6)
ttk.Button(grid, text="浏览…", command=self.pick_prompt).grid(row=2, column=2)
ttk.Label(frm, text="提示词留空使用内置版本;检测流程固定为:豆包初筛 → 无违规图 DeepSeek 复检(含两票复核)",
foreground="#666666").pack(anchor="w", pady=(2, 6))
# ---- 控制区 ----
ctrl = ttk.Frame(frm)
ctrl.pack(fill="x", pady=2)
self.btn_run = ttk.Button(ctrl, text="开始检测", command=self.start)
self.btn_run.pack(side="left")
self.btn_stop = ttk.Button(ctrl, text="停止", command=self.stop, state="disabled")
self.btn_stop.pack(side="left", padx=8)
self.btn_open = ttk.Button(ctrl, text="打开报表", command=self.open_xlsx, state="disabled")
self.btn_open.pack(side="left", padx=8)
self.btn_folder = ttk.Button(ctrl, text="打开输出目录", command=self.open_outdir, state="disabled")
self.btn_folder.pack(side="left", padx=8)
self.progress = ttk.Progressbar(frm, mode="determinate")
self.progress.pack(fill="x", pady=(6, 2))
self.var_progress_label = tk.StringVar(value="就绪")
ttk.Label(frm, textvariable=self.var_progress_label).pack(anchor="w")
# ---- 日志区 ----
self.log_box = scrolledtext.ScrolledText(frm, height=17, font=("Consolas", 10),
state="disabled", wrap="word")
self.log_box.pack(fill="both", expand=True, pady=(8, 0))
self.root.after(100, self.poll_queue)
# ---------- UI 回调 ----------
def pick_folder(self):
d = filedialog.askdirectory(title="选择图片文件夹")
if d:
self.var_folder.set(d)
if not self.var_output.get():
self.var_output.set(d)
def pick_output(self):
d = filedialog.askdirectory(title="选择输出目录")
if d:
self.var_output.set(d)
def pick_prompt(self):
f = filedialog.askopenfilename(title="选择提示词txt(留空使用内置)",
filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")])
if f:
self.var_prompt.set(f)
def _ui_log(self, msg: str):
self.log_box.configure(state="normal")
self.log_box.insert("end", msg + "\n")
self.log_box.see("end")
self.log_box.configure(state="disabled")
def poll_queue(self):
try:
while True:
kind, *payload = self.q.get_nowait()
if kind == "log":
self._ui_log(payload[0])
elif kind == "progress":
done, total = payload
self.progress.configure(maximum=total, value=done)
self.var_progress_label.set(f"进度:{done}/{total}")
elif kind == "done":
xlsx, outdir, summary = payload
self.btn_run.configure(state="normal")
self.btn_stop.configure(state="disabled")
if xlsx:
self.btn_open.configure(state="normal")
self.btn_folder.configure(state="normal")
self.last_xlsx, self.last_outdir = xlsx, outdir
counts = summary.get("counts", {})
top = "\n".join(f" {c}{n}" for c, n in counts.items()) or " (无)"
messagebox.showinfo(
"检测完成",
f"图片 {summary.get('total', 0)} 张已处理,已按分类归档。\n\n分类分布:\n{top}\n\n"
f"DeepSeek 成本≈¥{summary.get('ds_peak_cost', 0):.2f}(高峰)/ "
f"¥{summary.get('ds_idle_cost', 0):.2f}(空闲)\n输出目录:{outdir or '未生成'}")
except queue.Empty:
pass
self.root.after(100, self.poll_queue)
def open_xlsx(self):
if self.last_xlsx and os.path.exists(self.last_xlsx):
os.startfile(self.last_xlsx) # noqa: S606Windows 专用)
def open_outdir(self):
d = self.last_outdir or self.var_output.get()
if d and os.path.isdir(d):
os.startfile(d) # noqa: S606
# ---------- 检测控制 ----------
def start(self):
folder = self.var_folder.get().strip()
if not folder or not os.path.isdir(folder):
messagebox.showwarning("提示", "请选择有效的图片文件夹")
return
custom_prompt = self.var_prompt.get().strip()
if custom_prompt and not os.path.isfile(custom_prompt):
messagebox.showwarning("提示", "自定义提示词文件不存在")
return
out_dir = self.var_output.get().strip() or folder
# 配置检查:缺失则弹首次配置对话框
cfg = load_config()
if missing_fields(cfg):
self._ui_log("检测到 config.ini 配置不完整,请补齐必填项。")
dlg = FirstRunDialog(self.root, cfg)
if not dlg.ok:
return
for attr, val in dlg.values.items():
setattr(cfg, attr, val)
save_config(cfg)
self._ui_log("配置已保存到 config.ini")
cfg.prompt_file = custom_prompt # 空 = 内置提示词
self.cfg = cfg
self.btn_run.configure(state="disabled")
self.btn_stop.configure(state="normal")
self.btn_open.configure(state="disabled")
self.btn_folder.configure(state="disabled")
self.stop_event.clear()
self._ui_log(f"===== 开始检测:{folder} =====")
self.var_progress_label.set("检测中…")
def log(msg):
self.q.put(("log", str(msg)))
def progress(done, total, _label=None):
self.q.put(("progress", done, total))
def worker():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
xlsx, outdir, summary = "", "", {}
try:
rows, summary = loop.run_until_complete(run_detection(
folder, cfg, log=log, progress=progress,
stop=self.stop_event.is_set, mode="cascade", verify=True))
self.q.put(("log", "正在整理输出(分类归档 + 生成报表)…"))
ts_dir = organize_output(rows, folder, out_dir)
xlsx = build_report(rows, str(ts_dir),
{"mode": "cascade", **{k: summary.get(k) for k in
("ds_calls", "ds_peak_cost",
"ds_idle_cost")}})
outdir = str(ts_dir)
log(f"输出目录:{ts_dir}")
log(f"报表:{xlsx}")
except Exception as e: # noqa: BLE001
log(f"检测失败:{type(e).__name__}: {e}")
finally:
loop.close()
self.q.put(("done", xlsx, outdir, summary))
self.worker = threading.Thread(target=worker, daemon=True)
self.worker.start()
def stop(self):
self.stop_event.set()
self._ui_log("已请求停止,等待在途请求结束…")
self.btn_stop.configure(state="disabled")
def main() -> int:
root = tk.Tk()
App(root)
root.mainloop()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+269
View File
@@ -0,0 +1,269 @@
# -*- coding: utf-8 -*-
"""两阶段级联检测管道:
阶段一(豆包/Ark)全量初筛 -> 阶段二(DeepSeek)仅复检初筛为
「无违规/违规不明/检测异常」的图片;DeepSeek 判为无违规的再追加两票复核。
mode:
cascade 默认,豆包初筛 + DeepSeek 复检
doubao 仅豆包
deepseek 仅 DeepSeek(全量直接进 DeepSeek,含无违规两票复核)
"""
import asyncio
import json
import os
import re
from datetime import datetime
from pathlib import Path
import aiohttp
from .config import PRICE, AppConfig, RUNS_DIR, load_prompt
from .providers import IMAGE_EXTS, detect_ark, detect_deepseek
STATUS_LABEL = {"ok": "", "parse_fail": "模型返回无法解析为标准JSON",
"error": "调用失败(网络/服务异常)"}
def natural_key(name):
return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", name)]
def list_images(folder: str) -> list:
folder = Path(folder)
files = [f for f in os.listdir(folder)
if os.path.splitext(f)[1].lower() in IMAGE_EXTS
and (folder / f).is_file()]
return sorted(files, key=natural_key)
def _cat_num(cat):
m = re.match(r"^(\d+)\.", cat.strip())
return int(m.group(1)) if m else 99
def decide_final(runs):
"""按票型决定最终结论。runs 为 status=ok 的结果列表(首票在前)。
全票无违规→无违规;无违规占多数→保留但标记复核;
违规票占多数/并列→从严取违规类(并列取分类序号最小)。"""
cats = [r["category"] for r in runs]
n = len(cats)
clean = cats.count("无违规")
if clean == n:
return runs[0], f"{n}票一致:无违规"
if clean * 2 > n:
r = next(r for r in runs if r["category"] == "无违规")
viol = sorted(set(c for c in cats if c != "无违规"), key=_cat_num)
return r, f"票型{clean}:{n - clean}{' / '.join(cats)}),少数违规票:{''.join(viol)},建议人工复核"
viol = [c for c in cats if c != "无违规"]
cnt = {}
for c in viol:
cnt[c] = cnt.get(c, 0) + 1
top = max(cnt.values())
best = sorted([c for c, v in cnt.items() if v == top], key=_cat_num)[0]
r = next(r for r in runs if r["category"] == best)
return r, f"票型({' / '.join(cats)}),从严取「{best}」,建议人工复核"
def _cache_path(stage: str, folder: str) -> Path:
name = re.sub(r"[^\w\u4e00-\u9fff-]", "_", Path(folder).resolve().name) or "root"
RUNS_DIR.mkdir(exist_ok=True)
return RUNS_DIR / f"{stage}_{name}.json"
def _load_cache(path: Path) -> dict:
if path.exists():
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
pass
return {}
def _save_cache(path: Path, data: dict):
path.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
def _is_peak_now() -> bool:
"""DeepSeek 高峰时段:工作日 9-12、14-18 点(本地时间近似北京时间)。"""
now = datetime.now()
if now.weekday() >= 5:
return False
h = now.hour
return 9 <= h < 12 or 14 <= h < 18
def deepseek_cost(results: dict) -> tuple:
"""按 usage 汇总 DeepSeek 成本,返回 (高峰成本, 空闲成本, 输出token合计)。"""
peak = idle = comp_total = 0.0
for r in results.values():
u = r.get("usage") or {}
prompt = u.get("prompt") or 0
cached = min(u.get("cached") or 0, prompt)
miss, comp = prompt - cached, u.get("completion") or 0
comp_total += comp
peak += (miss * PRICE["miss"][0] + cached * PRICE["cached"][0]
+ comp * PRICE["output"][0]) / 1e6
idle += (miss * PRICE["miss"][1] + cached * PRICE["cached"][1]
+ comp * PRICE["output"][1]) / 1e6
return peak, idle, comp_total
async def _run_stage(detect, session, sem, cfg, prompt, folder, files, cache_path,
log, progress, stop):
"""执行一个检测阶段:跳过缓存中已完成的结果,边跑边落盘。"""
cache = _load_cache(cache_path)
todo = [f for f in files if f not in cache or cache[f].get("status") != "ok"]
total = len(files)
done = total - len(todo)
if progress:
progress(done, total)
tasks = []
for f in todo:
tasks.append(asyncio.ensure_future(
detect(session, sem, cfg, prompt, os.path.join(folder, f), stop)))
for fut in asyncio.as_completed(tasks):
try:
res = await fut
except Exception as e: # noqa: BLE001 单任务异常不拖垮整批
res = {"file": "?", "provider": "?", "status": "error", "attr": "",
"logic": "", "category": "检测异常",
"raw": f"{type(e).__name__}: {e}", "usage": {}}
cache[res["file"]] = res
done += 1
cat = res["category"] if res["status"] == "ok" else f"[{res['status']}]"
log(f" ({done}/{total}) {res['file']} -> {cat}")
if progress:
progress(done, total)
if done % 5 == 0:
_save_cache(cache_path, cache)
if stop and stop():
for t in tasks:
t.cancel()
break
_save_cache(cache_path, cache)
return cache
async def _verify_clean(session, sem, prompt, folder, files, stage2, cfg,
log, progress, stop, cache_path):
"""对阶段二判为无违规的图片各追加两票独立复核。"""
verify = [f for f in files
if f in stage2 and stage2[f].get("status") == "ok"
and stage2[f].get("category") == "无违规"
and "votes" not in stage2[f]]
if not verify:
return
log(f"复核阶段:{len(verify)} 张 DeepSeek 无违规图片,各追加 2 票")
async def run_verify(fname):
out = []
for _ in range(2):
if stop and stop():
break
out.append(await detect_deepseek(session, sem, cfg, prompt,
os.path.join(folder, fname), stop))
return fname, out
tasks = [asyncio.ensure_future(run_verify(f)) for f in verify]
vdone = 0
for fut in asyncio.as_completed(tasks):
fname, extra = await fut
runs = [stage2[fname]] + [r for r in extra if r.get("status") == "ok"]
if len(runs) < 2:
stage2[fname]["vote_note"] = "复核调用失败,维持单票结论"
else:
final, note = decide_final(runs)
final = dict(final)
final["votes"] = [r["category"] for r in runs]
final["vote_note"] = note
stage2[fname] = final
vdone += 1
log(f" 复核 ({vdone}/{len(verify)}) {fname} -> {stage2[fname]['category']}"
f"{stage2[fname].get('vote_note', '')}")
if progress:
progress(vdone, len(verify))
_save_cache(cache_path, stage2)
return stage2
async def run_detection(folder, cfg: AppConfig, log=print, progress=None, stop=None,
mode="cascade", verify=True):
"""执行级联检测,返回 (rows, summary)。rows 已按文件名自然排序。"""
prompt = load_prompt(cfg.prompt_path)
files = list_images(folder)
if not files:
raise FileNotFoundError(f"文件夹中没有图片: {folder}")
log(f"{len(files)} 张图片,模式:{mode},提示词:{cfg.prompt_path}")
ark_cache_path = _cache_path("ark", folder)
ds_cache_path = _cache_path("deepseek", folder)
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=300),
connector=aiohttp.TCPConnector(limit=0)) as session:
# ---- 阶段一:豆包初筛(cascade/doubao 模式)----
stage1 = {}
if mode in ("cascade", "doubao"):
log(f"[阶段一] 豆包初筛(并发 {cfg.ark_workers}")
sem1 = asyncio.Semaphore(cfg.ark_workers)
stage1 = await _run_stage(detect_ark, session, sem1, cfg, prompt, folder,
files, ark_cache_path, log, progress, stop)
# ---- 阶段二:DeepSeek 复检 ----
stage2 = {}
if mode == "cascade":
recheck = [f for f in files
if stage1.get(f, {}).get("category") in cfg.recheck_categories
or stage1.get(f, {}).get("status") != "ok"]
if recheck:
log(f"[阶段二] DeepSeek 复检 {len(recheck)} 张(初筛为"
f"{'/'.join(cfg.recheck_categories)},并发 {cfg.deepseek_workers}")
sem2 = asyncio.Semaphore(cfg.deepseek_workers)
stage2 = await _run_stage(detect_deepseek, session, sem2, cfg, prompt,
folder, recheck, ds_cache_path, log,
progress, stop)
else:
log("[阶段二] 无需复检的图片")
elif mode == "deepseek":
log(f"[阶段二] DeepSeek 全量检测(并发 {cfg.deepseek_workers}")
sem2 = asyncio.Semaphore(cfg.deepseek_workers)
stage2 = await _run_stage(detect_deepseek, session, sem2, cfg, prompt, folder,
files, ds_cache_path, log, progress, stop)
# ---- 无违规两票复核 ----
if stage2 and verify and not (stop and stop()):
sem2 = asyncio.Semaphore(cfg.deepseek_workers)
await _verify_clean(session, sem2, prompt, folder, files, stage2, cfg,
log, progress, stop, ds_cache_path)
# ---- 合并结果 ----
rows = []
for f in files:
r = dict(stage2.get(f) or stage1.get(f) or {})
r["file"] = f
r["channel"] = ("DeepSeek复检" if f in stage2 else
"豆包初筛" if stage1 else "DeepSeek")
r["remark"] = STATUS_LABEL.get(r.get("status", ""), "")
rows.append(r)
summary = {"total": len(files), "mode": mode, "folder": str(folder)}
counts = {}
for r in rows:
c = r.get("category") or "(空)"
counts[c] = counts.get(c, 0) + 1
summary["counts"] = dict(sorted(counts.items(), key=lambda kv: _cat_num(kv[0])))
peak, idle, comp = deepseek_cost(stage2)
summary["ds_peak_cost"] = round(peak, 4)
summary["ds_idle_cost"] = round(idle, 4)
summary["ds_output_tokens"] = int(comp)
summary["ds_calls"] = len(stage2)
_save_cache(_cache_path("final", folder),
{"rows": rows, "summary": summary,
"generated_at": datetime.now().isoformat(timespec="seconds")})
log(f"完成:{json.dumps(summary['counts'], ensure_ascii=False)}")
log(f"DeepSeek 用量:{summary['ds_calls']} 次调用,输出 {comp} token"
f"成本≈¥{peak:.2f}(高峰)/¥{idle:.2f}(空闲)")
return rows, summary
+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": {}}
+236
View File
@@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
"""检测结果输出组织与 Excel 报表生成。
输出结构:
<输出目录>/检测结果_<时间戳>/
违规检测结果_<时间戳>.xlsx # 一级目录放 Excel
<违规分类1>/ 图1.jpg 图2.jpg … # 每个分类一个文件夹,图片归档其中
<违规分类2>/ …
"""
import os
import re
import shutil
from datetime import datetime
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
# 设计令牌(三色纪律:主色 + 语义强调色 + 中性灰)
PRIMARY = "1B2A4A"
NEUTRAL_900 = "37352F"
NEUTRAL_600 = "8C8A84"
NEUTRAL_200 = "E9E9E8"
NEUTRAL_100 = "F7F7F5"
ACCENT_POSITIVE = "1B7D46"
ACCENT_NEGATIVE = "C0392B"
ACCENT_WARNING = "D4820A"
FONT_NAME = "Microsoft YaHei"
STATUS_LABEL = {"ok": "", "parse_fail": "模型返回无法解析为标准JSON",
"error": "调用失败(网络/服务异常)"}
def sanitize_dirname(name: str) -> str:
"""分类名转合法文件夹名。"""
s = re.sub(r'[<>:"/\\|?*]', "_", str(name)).strip(" .")
return s[:80] or "未分类"
def organize_output(rows, src_folder, out_dir, base_name="检测结果") -> Path:
"""建立时间戳目录与分类文件夹,把图片复制进对应分类,返回时间戳目录。"""
ts_dir = Path(out_dir) / f"{base_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
for r in rows:
cat = sanitize_dirname(r.get("category") or "检测异常")
d = ts_dir / cat
d.mkdir(parents=True, exist_ok=True)
src = Path(src_folder) / r.get("file", "")
if src.is_file():
shutil.copy2(src, d / src.name)
return ts_dir
def _cat_num(cat):
m = re.match(r"^(\d+)\.", str(cat).strip())
return int(m.group(1)) if m else (99 if str(cat).strip() != "无违规" else 98)
def _cat_color(cat):
c = str(cat).strip()
if c == "无违规":
return ACCENT_POSITIVE
if c in ("违规不明", "检测异常"):
return ACCENT_WARNING
return ACCENT_NEGATIVE
def _est_lines(text, width):
"""按列宽估算换行行数(CJK 记 1.7 宽)。"""
if not text:
return 1
per_line = max(int(width / 1.7), 4)
lines = 0
for seg in str(text).split("\n"):
lines += max(1, -(-len(seg) // per_line))
return lines
def _auto_heights(ws, widths, start_row, end_row):
for rn in range(start_row, end_row + 1):
lines = 1
for col, w in widths.items():
cell = ws[f"{col}{rn}"]
if cell.alignment and cell.alignment.wrap_text:
lines = max(lines, _est_lines(cell.value, w))
ws.row_dimensions[rn].height = max(22, 20 + (lines - 1) * 15)
def build_report(rows, out_dir, meta=None):
"""生成 Excel 报表,返回文件路径。rows 来自 pipeline.run_detection。"""
meta = meta or {}
wb = Workbook()
# ---------- Sheet 1: 检测结果 ----------
ws = wb.active
ws.title = "检测结果"
headers = ["序号", "文件名", "违规分类", "判定通道", "文字/图形属性",
"侵权/违规逻辑", "票型/复核", "备注"]
last_col = len(headers) + 1
widths = {"B": 6, "C": 42, "D": 26, "E": 13, "F": 32, "G": 50, "H": 30, "I": 20}
ws.sheet_view.showGridLines = False
ws.column_dimensions["A"].width = 3
ws.row_dimensions[1].height = 15
for col, w in widths.items():
ws.column_dimensions[col].width = w
# 标题
ws.merge_cells(start_row=2, start_column=2, end_row=2, end_column=last_col)
t = ws["B2"]
t.value = meta.get("title", "商品图合规检测工具 - 检测结果")
t.font = Font(name=FONT_NAME, size=16, bold=True, color=PRIMARY)
t.alignment = Alignment(horizontal="left", vertical="center")
ws.row_dimensions[2].height = 32
ws.row_dimensions[3].height = 8
# 表头
hfill = PatternFill("solid", fgColor=PRIMARY)
hborder = Border(bottom=Side(style="thin", color=NEUTRAL_200))
for ci, h in enumerate(headers, start=2):
c = ws.cell(row=4, column=ci, value=h)
c.fill = hfill
c.font = Font(name=FONT_NAME, size=11, bold=True, color="FFFFFF")
c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
c.border = hborder
ws.row_dimensions[4].height = 28
# 数据行(隔行填充)
for i, r in enumerate(rows):
rn = 5 + i
values = [i + 1, r.get("file", ""), r.get("category", ""),
r.get("channel", ""), r.get("attr", ""), r.get("logic", ""),
r.get("vote_note", "单票判定(未触发复核)"),
r.get("remark", STATUS_LABEL.get(r.get("status", ""), ""))]
fill = PatternFill("solid", fgColor=NEUTRAL_100 if i % 2 else "FFFFFF")
for ci, v in enumerate(values, start=2):
c = ws.cell(row=rn, column=ci, value=v)
c.fill = fill
c.font = Font(name=FONT_NAME, size=11, color=NEUTRAL_900)
c.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
ws.cell(row=rn, column=2).alignment = Alignment(horizontal="right", vertical="center")
ws.cell(row=rn, column=4).font = Font(name=FONT_NAME, size=11,
color=_cat_color(values[2]))
vnote = ws.cell(row=rn, column=8)
if "人工复核" in str(vnote.value):
vnote.font = Font(name=FONT_NAME, size=11, color=ACCENT_WARNING)
last_row = 4 + len(rows)
_auto_heights(ws, widths, 5, last_row)
ws.freeze_panes = "C5"
# 底注
now = datetime.now().strftime("%Y-%m-%d %H:%M")
note1 = (f"检测模式:{meta.get('mode', 'cascade')}(豆包初筛 + DeepSeek 复检无违规)|"
f"图片总数:{len(rows)}|生成时间:{now}")
note2 = ("依据《违规检测提示词》17 类标准判定;DeepSeek temperature=0 + JSON 模式、"
"max_tokens 可配置;判定为无违规的图片自动追加两票复核(3 票取多数,分歧从严取违规并标记人工复核)。")
for off, note in ((2, note1), (3, note2)):
c = ws.cell(row=last_row + off, column=2, value=note)
c.font = Font(name=FONT_NAME, size=9, color=NEUTRAL_600)
# ---------- Sheet 2: 统计汇总 ----------
ws2 = wb.create_sheet("统计汇总")
counts = {}
channels = {}
for r in rows:
c = str(r.get("category", "")).strip() or "(空)"
counts[c] = counts.get(c, 0) + 1
ch = r.get("channel", "")
channels[ch] = channels.get(ch, 0) + 1
ordered = sorted(counts.items(), key=lambda kv: _cat_num(kv[0]))
widths2 = {"B": 46, "C": 10, "D": 10}
ws2.sheet_view.showGridLines = False
ws2.column_dimensions["A"].width = 3
ws2.row_dimensions[1].height = 15
for col, w in widths2.items():
ws2.column_dimensions[col].width = w
ws2.merge_cells("B2:D2")
t2 = ws2["B2"]
t2.value = "违规分类统计汇总"
t2.font = Font(name=FONT_NAME, size=16, bold=True, color=PRIMARY)
t2.alignment = Alignment(horizontal="left", vertical="center")
ws2.row_dimensions[2].height = 32
ws2.row_dimensions[3].height = 8
for ci, h in enumerate(["违规分类", "数量", "占比"], start=2):
c = ws2.cell(row=4, column=ci, value=h)
c.fill = hfill
c.font = Font(name=FONT_NAME, size=11, bold=True, color="FFFFFF")
c.alignment = Alignment(horizontal="center", vertical="center")
c.border = hborder
ws2.row_dimensions[4].height = 28
total = len(rows)
for i, (cat, n) in enumerate(ordered):
rn = 5 + i
fill = PatternFill("solid", fgColor=NEUTRAL_100 if i % 2 else "FFFFFF")
vals = [cat, n, (n / total if total else 0)]
for ci, v in enumerate(vals, start=2):
c = ws2.cell(row=rn, column=ci, value=v)
c.fill = fill
c.font = Font(name=FONT_NAME, size=11, color=NEUTRAL_900)
c.alignment = Alignment(horizontal="left" if ci == 2 else "right",
vertical="center")
ws2.cell(row=rn, column=3).number_format = "#,##0"
ws2.cell(row=rn, column=4).number_format = "0.0%"
ws2.cell(row=rn, column=2).font = Font(name=FONT_NAME, size=11,
color=_cat_color(cat))
ws2.row_dimensions[rn].height = 22
trow = 5 + len(ordered)
ws2.cell(row=trow, column=2, value="合计")
ws2.cell(row=trow, column=3, value=total).number_format = "#,##0"
ws2.cell(row=trow, column=4, value=1.0 if total else 0).number_format = "0.0%"
for ci in range(2, 5):
c = ws2.cell(row=trow, column=ci)
c.fill = PatternFill("solid", fgColor="D6E4F0")
c.font = Font(name=FONT_NAME, size=11, bold=True, color=PRIMARY)
c.border = Border(top=Side(style="medium", color=NEUTRAL_200))
c.alignment = Alignment(horizontal="left" if ci == 2 else "right", vertical="center")
ws2.row_dimensions[trow].height = 26
ch_txt = "".join(f"{k} {v}" for k, v in channels.items())
cost_txt = ""
if meta.get("ds_peak_cost") is not None:
cost_txt = (f"DeepSeek {meta.get('ds_calls', 0)} 次调用成本≈"
f"¥{meta['ds_peak_cost']:.2f}(高峰)/¥{meta['ds_idle_cost']:.2f}(空闲)")
c = ws2.cell(row=trow + 2, column=2,
value=f"判定通道分布:{ch_txt}{cost_txt}|生成时间:{now}")
c.font = Font(name=FONT_NAME, size=9, color=NEUTRAL_600)
wb.properties.creator = "violation-detector"
os.makedirs(out_dir, exist_ok=True)
out = os.path.join(out_dir, f"违规检测结果_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx")
wb.save(out)
return out