# -*- 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 = "
".join(lines[:14]) if lines else '(OCR 无文本)' ok_mark = "✅" if r["评级公司"] == r["true_label"] else "❌" return (f'
{tag} {name} ' f'真实={fld} → 识别={r["评级公司"]} {ok_mark} | ' f'分数={r["评级分数"]} | 序号={r["serial_no"] or "-"}
' f'
best.pt 评级检测
' f'
评级标签条(OCR 输入)
' f'
OCR 分行文本
{ocr_html}
') 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 = ['Pokemon 评级卡识别 技术汇报') H.append('

Pokemon 评级卡识别 · 技术汇报

' '
主题:评级公司识别(PSA/BGS/CGC/SGC)+ 评级分数与编号 OCR | ' '数据集 images_pokemon 2000 张 | 2026-07-03
') # KPI H.append('
' f'
{len(rows)}
测试图片总数
' f'
{acc:.2f}%
评级公司识别准确率
(仅评级卡 {len(graded)} 张)
' f'
{len(wrong)}
误识别数量
' f'
{len(ungraded)}
非评级卡(未检出)
' f'
{n_score_num}
OCR 识别出数字分数
' f'
{n_serial}/{len(graded)}
编号 serial_no 识别率
' '
') # Pipeline H.append('

🔗 评级识别流程

' 'best.pt (YOLO26)
在原图检测评级标签条
→ 输出评级公司 (PSA/BGS/CGC/SGC)
' '② 裁出评级标签条区域
(best.pt 检测框)
' 'PaddleOCR
识别标签条分行文本
→ 评级分数 + serial_no
' '④ 规则解析
分数限定 1-10 / 编号≥6位数字
评级卡无分数→authentic
' '
') # 评级公司准确率 H.append('

📊 各评级公司识别准确率

' '') for cp in COMPANIES: c, t, w = comp_stat[cp] pct = c / t * 100 if t else 0 H.append(f'' f'') H.append(f'' f'
真实类别样本数正确识别误识别/漏检准确率可视化
{cp}{t}{c}{w}{pct:.2f}%
合计(仅评级卡){len(graded)}{len(correct)}{len(wrong)}{acc:.2f}%
') # 误识别混淆 if wrong: H.append('

❌ 误识别混淆(真实 → 误识别为)

' '') for (t, p), n in confuse.most_common(): H.append(f'') H.append(f'
真实类别误识别为数量
{t}{p or "(空)"}{n}

' f'共 {len(wrong)} 张误识别(评级卡中)。另 {len(ungraded)} 张非评级卡未检出评级框(不计入准确率)。

') # OCR 分数分析 H.append('

🔢 OCR 评级分数与编号识别

' f'

评级卡 {len(graded)} 张中:识别出数字分数 {n_score_num} 张,' f'评级卡但分数 OCR 漏识(填 authentic) {n_authentic} 张,' f'编号 serial_no(≥6位) 识别 {n_serial}/{len(graded)} = {n_serial/len(graded)*100:.1f}%

' '') for s, n in score_cnt.most_common(15): H.append(f'') H.append('
评级分数数量占比
{s or "空"}{n}{n/len(graded)*100:.1f}%
') # 已知局限模块 H.append('''

⚠️ 已知局限:OCR 评级分数解析有待提高

当前判断逻辑(启发式,较粗糙)

for ln in lines:                     # 从上到下逐行扫
    if 纯数字 and 1.0 ≤ 该数 ≤ 10.0:
        return ln                    # 返回第一个命中的

即"取 OCR 文本里第一个落在 1-10 范围的纯数字行",完全靠行序, 不理解标签布局、不认 BGS 四维分数、不区分评级公司、不看语义

主要问题

具体案例:为何这张 BGS 判 10 而非 9.5

OCR 分行文本(节选)
2025 POKEMON MEGA PROMOS
10            ← 第2行, 第一个1-10纯数字 → 判为分数
B
#020MP PIKACHU
MCDONALD'S JAPAN
PURCHASE INCENTIVE
CENTERING
9.5          ← CENTERING 维度分, 但已在10之后, 轮不到
CORNERS 10
PRISTINE
BECKETT
EDGES 10
SURFACE 10

10 在第 2 行、9.5 在第 9 行,逻辑先碰到 10 就返回—— 纯粹"谁排前面",不是因为 10 更像总分。

旁证矛盾:文本出现 PRISTINE(BGS 黑标,对应总分 10), 但 CENTERING 9.5(PRISTINE 要求四维全 10)→ 要么总分其实是 9.5(OCR 误读描述),要么行序错位。无 ground truth 无法定论

改进方向

  1. 按公司分支策略:BGS 专门定位"四维分项之外的总分"(位置先验 + 排除 CENTERING/CORNERS/EDGES/SURFACE 紧邻数字); PSA/CGC/SGC 取唯一主分数。
  2. 分数描述交叉校验:PRISTINE→10 / GEM MT→10 / MINT→9 / NM-MT→8.5;分数与描述不符时报警或以描述反推。
  3. 布局定位:best.pt 已框出评级标签条,在框内按区域定位总分(BGS 总分在固定格子),而非全文扫描。
  4. 专项标注+训练:标几百张评级分数 ground truth,训小模型/规则,最稳但有标注成本。
''') # 案例 H.append('

🖼 识别案例可视化

' '

每张:best.pt 评级检测图 | 评级标签条(OCR 输入) | OCR 分行文本 + 识别结果

') 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'

{title} (展示 {len(items)} / 共 {total} 张)

') for r in items: try: H.append(render(r, f'')) except Exception as e: H.append(f'
[{r["filename"]} 渲染失败: {str(e)[:50]}]
') H.append('
') 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)