init: 商品图合规检测工具(豆包初筛 + DeepSeek 复检级联)
- src 标准布局:config/providers/pipeline/report + CLI/Tk GUI 双入口 - 级联省钱:豆包全量初筛,仅无违规/违规不明图进 DeepSeek 复检(含两票复核) - 输出:时间戳目录 + 分类文件夹图片归档 + Excel 报表 - 30 个单元测试(tests/,测试图片不入库)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""服装商品图合规检测:豆包初筛 + DeepSeek 复检级联。"""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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: S606(Windows 专用)
|
||||
|
||||
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())
|
||||
@@ -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
|
||||
@@ -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": {}}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user