| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- # -*- coding: utf-8 -*-
- """
- pokemon_report.py - Pokemon 评级卡识别 技术汇报 HTML (学习 report.html 风格)
- 主题: 评级卡区域识别 (best.pt 检测评级公司 + PaddleOCR 识别分数/编号)
- 不含卡牌匹配(DINOv2)展示, 仅评级。
- 运行: pytorch/任意有 cv2 环境, 在服务器跑(图在服务器)
- """
- import os, sys, csv, json, hashlib, random, base64
- from collections import Counter
- sys.stdout.reconfigure(encoding="utf-8")
- OUT_DIR = "/home/martin/顾工交接/wzj/images_pokemon_out"
- random.seed(42)
- def b64(img, h=240, q=78):
- import cv2
- if img is None:
- return ""
- if img.shape[0] != h:
- sc = h / img.shape[0]
- img = cv2.resize(img, (max(1, int(img.shape[1] * sc)), h))
- ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, q])
- return "data:image/jpeg;base64," + base64.b64encode(buf.tobytes()).decode() if ok else ""
- def load(p):
- import cv2
- return cv2.imread(p) if p and os.path.exists(p) else None
- print("[1] 读数据 ...", flush=True)
- rows = list(csv.DictReader(open(f"{OUT_DIR}/match_result_sorted.csv", encoding="utf-8-sig")))
- ocr = json.load(open(f"{OUT_DIR}/ocr_result.json", encoding="utf-8"))
- print(f" {len(rows)} 张", flush=True)
- # ---- 评级统计 ----
- graded = [r for r in rows if r["评级公司"] != "非评级卡"]
- ungraded = [r for r in rows if r["评级公司"] == "非评级卡"]
- correct = [r for r in graded if r["评级公司"] == r["true_label"]]
- wrong = [r for r in graded if r["评级公司"] != r["true_label"]]
- acc = len(correct) / len(graded) * 100
- COMPANIES = ["PSA", "BGS", "CGC", "SGC"]
- comp_stat = {}
- for cp in COMPANIES:
- sub = [r for r in rows if r["true_label"] == cp]
- c = sum(1 for r in sub if r["评级公司"] == cp)
- comp_stat[cp] = (c, len(sub), len(sub) - c) # 正确, 总数, 误识别/漏检
- # 误识别混淆: 真实 -> 识别成什么
- confuse = Counter((r["true_label"], r["评级公司"]) for r in wrong)
- # ---- OCR 分数/编号统计 ----
- score_cnt = Counter(r["评级分数"] for r in graded)
- n_serial = sum(1 for r in graded if r["serial_no"])
- n_authentic = sum(1 for r in graded if r["评级分数"] == "authentic")
- n_score_num = sum(1 for r in graded if r["评级分数"] and r["评级分数"] != "authentic")
- print(f"[2] 评级准确率 {acc:.2f}% (评级卡{len(graded)}, 非评级卡{len(ungraded)}, 误识别{len(wrong)})", flush=True)
- print(f" 分数识别: 数字{n_score_num} authentic{n_authentic} | serial {n_serial}/{len(graded)}", flush=True)
- # ---- 案例渲染 ----
- def render(r, tag):
- fld, name = r["true_label"], r["filename"]
- lines = ocr.get(name, {}).get("lines", [])
- p_anno = b64(load(f"{OUT_DIR}/predict/{fld}/{name}"), h=280, q=82)
- p_crop = b64(load(f"{OUT_DIR}/crops/{fld}_{name}"), h=120, q=85)
- ocr_html = "<br>".join(lines[:14]) if lines else '<i style="color:#aaa">(OCR 无文本)</i>'
- ok_mark = "✅" if r["评级公司"] == r["true_label"] else "❌"
- return (f'<div class="case"><div class="ttl">{tag} <b>{name}</b> '
- f'<span class="meta">真实=<b>{fld}</b> → 识别=<b style="color:#3b82f6">{r["评级公司"]}</b> {ok_mark} | '
- f'分数=<b>{r["评级分数"]}</b> | 序号={r["serial_no"] or "-"}</span></div>'
- f'<div class="row"><div class="q"><img src="{p_anno}"><small>best.pt 评级检测</small></div>'
- f'<div class="q"><img src="{p_crop}"><small>评级标签条(OCR 输入)</small></div>'
- f'<div class="ocr"><div class="ocr-t">OCR 分行文本</div><pre>{ocr_html}</pre></div></div></div>')
- def sample(lst, n):
- return random.sample(lst, min(n, len(lst))) if lst else []
- cases = [("✅ 识别正确(评级公司=真实类别)", "#2e7d32", sample(correct, 60)),
- ("❌ 误识别(评级公司≠真实类别)", "#d32f2f", wrong), # 全部(数量少)
- ("○ 非评级卡(best.pt 未检出评级框)", "#9e9e9e", ungraded)]
- # ---- HTML ----
- print("[3] 生成 HTML ...", flush=True)
- H = ['<!DOCTYPE html><html><head><meta charset="utf-8"><title>Pokemon 评级卡识别 技术汇报</title><style>']
- H.append(
- 'body{font-family:-apple-system,"Microsoft YaHei",sans-serif;margin:0;background:#f4f5f7;color:#222;}'
- 'h1{background:linear-gradient(90deg,#1565c0,#7e57c2);color:#fff;padding:22px 24px;margin:0;font-size:22px;}'
- '.sub{color:#fff;background:#37474a;padding:8px 24px;font-size:13px;}'
- 'h2{margin:24px 16px 10px;border-left:5px solid #1565c0;padding-left:10px;}'
- '.wrap{max-width:1300px;margin:0 auto;padding:0 16px 40px;}'
- '.kpis{display:flex;gap:12px;flex-wrap:wrap;margin:16px 0;}'
- '.kpi{flex:1;min-width:150px;background:#fff;border-radius:8px;padding:14px 18px;box-shadow:0 1px 3px rgba(0,0,0,.1);}'
- '.kpi .v{font-size:26px;font-weight:bold;} .kpi .l{color:#666;font-size:12px;margin-top:2px;}'
- '.panel{background:#fff;border-radius:8px;padding:14px 20px;margin:10px 0;box-shadow:0 1px 3px rgba(0,0,0,.08);}'
- 'table{border-collapse:collapse;width:100%;font-size:14px;} th,td{border:1px solid #eee;padding:7px 10px;text-align:left;}'
- 'th{background:#eef3ff;} .bar{height:16px;background:#1565c0;border-radius:3px;display:inline-block;vertical-align:middle;}'
- '.pipeline{display:flex;gap:8px;flex-wrap:wrap;} .step{background:#eef;border:1px solid #cdd;border-radius:6px;padding:8px 12px;font-size:13px;}'
- '.case{background:#fff;margin:10px 0;padding:12px;border-radius:7px;box-shadow:0 1px 3px rgba(0,0,0,.12);} '
- '.ttl{font-size:13px;margin-bottom:8px;} .meta{color:#666;font-size:12px;}'
- '.row{display:flex;gap:10px;align-items:flex-start;}'
- '.q{border:1px solid #ddd;padding:2px;border-radius:4px;text-align:center;background:#fafafa;} '
- '.q img{height:280px;display:block;} .q small{color:#888;font-size:11px;}'
- '.ocr{flex:1;background:#263238;color:#eee;border-radius:5px;padding:8px 12px;font-size:11px;min-width:200px;} '
- '.ocr-t{color:#80cbc4;font-size:11px;margin-bottom:4px;} .ocr pre{margin:0;white-space:pre-wrap;font-family:Consolas,monospace;line-height:1.5;}'
- '</style></head><body>')
- H.append('<h1> Pokemon 评级卡识别 · 技术汇报</h1>'
- '<div class="sub">主题:评级公司识别(PSA/BGS/CGC/SGC)+ 评级分数与编号 OCR | '
- '数据集 images_pokemon 2000 张 | 2026-07-03</div><div class="wrap">')
- # KPI
- H.append('<div class="kpis">'
- f'<div class="kpi"><div class="v">{len(rows)}</div><div class="l">测试图片总数</div></div>'
- f'<div class="kpi"><div class="v" style="color:#2e7d32">{acc:.2f}%</div><div class="l">评级公司识别准确率<br>(仅评级卡 {len(graded)} 张)</div></div>'
- f'<div class="kpi"><div class="v" style="color:#d32f2f">{len(wrong)}</div><div class="l">误识别数量</div></div>'
- f'<div class="kpi"><div class="v" style="color:#9e9e9e">{len(ungraded)}</div><div class="l">非评级卡(未检出)</div></div>'
- f'<div class="kpi"><div class="v">{n_score_num}</div><div class="l">OCR 识别出数字分数</div></div>'
- f'<div class="kpi"><div class="v">{n_serial}/{len(graded)}</div><div class="l">编号 serial_no 识别率</div></div>'
- '</div>')
- # Pipeline
- H.append('<h2>🔗 评级识别流程</h2><div class="panel"><div class="pipeline">'
- '<span class="step">① <b>best.pt (YOLO26)</b><br>在原图检测评级标签条<br>→ 输出评级公司 (PSA/BGS/CGC/SGC)</span>'
- '<span class="step">② 裁出评级标签条区域<br>(best.pt 检测框)</span>'
- '<span class="step">③ <b>PaddleOCR</b><br>识别标签条分行文本<br>→ 评级分数 + serial_no</span>'
- '<span class="step">④ 规则解析<br>分数限定 1-10 / 编号≥6位数字<br>评级卡无分数→authentic</span>'
- '</div></div>')
- # 评级公司准确率
- H.append('<h2>📊 各评级公司识别准确率</h2><div class="panel"><table>'
- '<tr><th>真实类别</th><th>样本数</th><th>正确识别</th><th>误识别/漏检</th><th>准确率</th><th>可视化</th></tr>')
- for cp in COMPANIES:
- c, t, w = comp_stat[cp]
- pct = c / t * 100 if t else 0
- H.append(f'<tr><td><b>{cp}</b></td><td>{t}</td><td>{c}</td><td>{w}</td>'
- f'<td><b>{pct:.2f}%</b></td><td><span class="bar" style="width:{pct*0.9}px"></span></td></tr>')
- H.append(f'<tr style="background:#eef3ff"><td><b>合计(仅评级卡)</b></td><td>{len(graded)}</td>'
- f'<td>{len(correct)}</td><td>{len(wrong)}</td><td><b>{acc:.2f}%</b></td><td></td></tr></table></div>')
- # 误识别混淆
- if wrong:
- H.append('<h2>❌ 误识别混淆(真实 → 误识别为)</h2><div class="panel"><table>'
- '<tr><th>真实类别</th><th>误识别为</th><th>数量</th></tr>')
- for (t, p), n in confuse.most_common():
- H.append(f'<tr><td>{t}</td><td><b style="color:#d32f2f">{p or "(空)"}</b></td><td>{n}</td></tr>')
- H.append(f'</table><p style="color:#666;font-size:13px;margin-top:8px">'
- f'共 {len(wrong)} 张误识别(评级卡中)。另 {len(ungraded)} 张非评级卡未检出评级框(不计入准确率)。</p></div>')
- # OCR 分数分析
- H.append('<h2>🔢 OCR 评级分数与编号识别</h2><div class="panel">'
- f'<p>评级卡 {len(graded)} 张中:识别出数字分数 <b>{n_score_num}</b> 张,'
- f'评级卡但分数 OCR 漏识(填 authentic) <b>{n_authentic}</b> 张,'
- f'编号 serial_no(≥6位) 识别 <b>{n_serial}/{len(graded)} = {n_serial/len(graded)*100:.1f}%</b>。</p>'
- '<table><tr><th>评级分数</th><th>数量</th><th>占比</th></tr>')
- for s, n in score_cnt.most_common(15):
- H.append(f'<tr><td><b>{s or "空"}</b></td><td>{n}</td><td>{n/len(graded)*100:.1f}%</td></tr>')
- H.append('</table></div>')
- # 已知局限模块
- H.append('''
- <h2 style="border-color:#ef6c00;color:#ef6c00">⚠️ 已知局限:OCR 评级分数解析有待提高</h2>
- <div class="panel" style="border-left:4px solid #ef6c00">
- <h3 style="margin-top:0">当前判断逻辑(启发式,较粗糙)</h3>
- <pre style="background:#263238;color:#eee;padding:10px;border-radius:5px;font-size:12px">for ln in lines: # 从上到下逐行扫
- if 纯数字 and 1.0 ≤ 该数 ≤ 10.0:
- return ln # 返回第一个命中的</pre>
- <p>即"取 OCR 文本里<b>第一个</b>落在 1-10 范围的纯数字行",完全靠行序,
- <b>不理解标签布局、不认 BGS 四维分数、不区分评级公司、不看语义</b>。</p>
- <h3>主要问题</h3>
- <ul>
- <li><b>BGS 四维分数混淆</b>:BGS 标签有 CENTERING/CORNERS/EDGES/SURFACE 四个维度分 + 一个总分,共 5 个 1-10 数字。
- 当前逻辑会把"第一个碰到的"当总分——可能撞上某个维度分,而非真实总分。</li>
- <li><b>不区分评级公司</b>:PSA/CGC/SGC 是单分数标签(相对好办),BGS 是多分数(容易混),代码对四家一视同仁。</li>
- <li><b>依赖 OCR 行序</b>:PaddleOCR 行序按文字框位置排,标签稍转一下、OCR 漏一行,行序就变,结果就漂。</li>
- <li><b>卡牌信息数字干扰</b>:年份(2025)、编号(#020)、发行量(3/10) 等若被 OCR 拆成纯数字行,可能被误判为分数。</li>
- </ul>
- <h3>具体案例:为何这张 BGS 判 10 而非 9.5</h3>
- <div class="row" style="margin:8px 0">
- <div class="ocr" style="min-width:320px">
- <div class="ocr-t">OCR 分行文本(节选)</div>
- <pre>2025 POKEMON MEGA PROMOS
- <span style="color:#ffd54f">10 ← 第2行, 第一个1-10纯数字 → 判为分数</span>
- B
- #020MP PIKACHU
- MCDONALD'S JAPAN
- PURCHASE INCENTIVE
- CENTERING
- <span style="color:#80cbc4">9.5 ← CENTERING 维度分, 但已在10之后, 轮不到</span>
- CORNERS 10
- PRISTINE
- BECKETT
- EDGES 10
- SURFACE 10</pre>
- </div>
- <div style="flex:1;font-size:13px">
- <p><b>10</b> 在第 2 行、<b>9.5</b> 在第 9 行,逻辑先碰到 10 就返回——
- 纯粹"谁排前面",不是因为 10 更像总分。</p>
- <p>旁证矛盾:文本出现 <code>PRISTINE</code>(BGS 黑标,对应总分 10),
- 但 <code>CENTERING 9.5</code>(PRISTINE 要求四维全 10)→
- 要么总分其实是 9.5(OCR 误读描述),要么行序错位。<b>无 ground truth 无法定论</b>。</p>
- </div>
- </div>
- <h3>改进方向</h3>
- <ol>
- <li><b>按公司分支策略</b>:BGS 专门定位"四维分项之外的总分"(位置先验 + 排除 CENTERING/CORNERS/EDGES/SURFACE 紧邻数字);
- PSA/CGC/SGC 取唯一主分数。</li>
- <li><b>分数描述交叉校验</b>:PRISTINE→10 / GEM MT→10 / MINT→9 / NM-MT→8.5;分数与描述不符时报警或以描述反推。</li>
- <li><b>布局定位</b>:best.pt 已框出评级标签条,在框内按区域定位总分(BGS 总分在固定格子),而非全文扫描。</li>
- <li><b>专项标注+训练</b>:标几百张评级分数 ground truth,训小模型/规则,最稳但有标注成本。</li>
- </ol>
- </div>
- ''')
- # 案例
- H.append('<h2>🖼 识别案例可视化</h2>'
- '<p style="color:#666;font-size:13px;margin:0 16px;">每张:best.pt 评级检测图 | 评级标签条(OCR 输入) | OCR 分行文本 + 识别结果</p>')
- for title, color, items in cases:
- if not items:
- continue
- total = len(correct if "正确" in title else wrong if "误" in title else ungraded)
- H.append(f'<h2 style="border-color:{color};color:{color}">{title} <small>(展示 {len(items)} / 共 {total} 张)</small></h2>')
- for r in items:
- try:
- H.append(render(r, f'<span class="tag" style="color:{color}">●</span>'))
- except Exception as e:
- H.append(f'<div class="case">[{r["filename"]} 渲染失败: {str(e)[:50]}]</div>')
- H.append('</div></body></html>')
- out = f"{OUT_DIR}/pokemon_report.html"
- with open(out, "w", encoding="utf-8") as f:
- f.write("".join(H))
- print(f"[done] -> {out} ({os.path.getsize(out)/1e6:.1f} MB)", flush=True)
|