Files
violation-detector/src/violation_detector/report.py
T
yeuimu 08d26710b4 init: 商品图合规检测工具(豆包初筛 + DeepSeek 复检级联)
- src 标准布局:config/providers/pipeline/report + CLI/Tk GUI 双入口
- 级联省钱:豆包全量初筛,仅无违规/违规不明图进 DeepSeek 复检(含两票复核)
- 输出:时间戳目录 + 分类文件夹图片归档 + Excel 报表
- 30 个单元测试(tests/,测试图片不入库)
2026-09-02 15:28:51 +08:00

237 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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