feat(ocr): 扫描件与图片识别——OCR 进文字层、资质库上传识别、大图浏览

计划见 docs/architecture/vision-plan.md(VS1–VS13,docs/decisions.md)。

- 验证:OCR(RapidOCR + PP-OCRv6 small,CPU)和 DeepSeek Flash 看图在国动投标文件
  和 7 张证书上初测(tech-validation §7,代码 spikes/s5-ocr/)
- OCR 服务 services/ocr(镜像、Compose 服务):整页转正(含 180°)、红章附近按红色通道重读
- 扫描页和插图识别后并入文字层 v6,句子标 source=ocr;出处标「OCR」(迁移 0013);
  依据识别文字的问题降级,规则不查扫描附件;国动端到端见 check-eval §16
- 资质库上传识别:添加资质对话框最上面传证书照片,识别后填表,只预填和 OCR 文字
  对得上的字段;识别值留痕、照片挂成附件(迁移 0014,提示词 vision.cert_fields v1)
- 资质库改为查看详情(只读键值 + 附件缩略图),附件用大图浏览控件看,不开新标签页
  (设计规范 §7.23);PDF 附件按页出预览图

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 17:50:13 +08:00
parent 0b783ababa
commit 3344f09563
63 changed files with 4973 additions and 345 deletions

View File

@@ -0,0 +1,89 @@
"""S5:合成扫描件上的字错误率(CER)。文字页渲染成图片 → 加扫描退化 → OCR → 和文字层比。
用法:.venv/bin/python -I synth_cer.py <pdf> <页码范围> [dpi]
退化三档:clean(只渲染)、scan(灰度、倾斜 1°、轻微模糊、噪点、JPEG 60)、bad(150 dpi 后放大、倾斜 2°、更模糊、JPEG 35)。
文字层按页的字符顺序,比较时去掉空白;OCR 行按坐标从上到下、从左到右拼接。顺序不同会抬高 CER,所以同时报「字符包」差异(不看顺序)。
"""
from __future__ import annotations
import io
import random
import sys
import time
from collections import Counter
import numpy as np
import pdfplumber
import pypdfium2 as pdfium
from PIL import Image, ImageFilter
from rapidfuzz.distance import Levenshtein
from rapidocr import RapidOCR
def parse_ranges(s: str) -> set[int]:
out: set[int] = set()
for part in s.split(","):
a, _, b = part.partition("-")
out.update(range(int(a), int(b or a) + 1))
return out
def degrade(img: Image.Image, level: str, rnd: random.Random) -> Image.Image:
if level == "clean":
return img
g = img.convert("L")
angle = rnd.uniform(-1, 1) * (1 if level == "scan" else 2)
if level == "bad":
g = g.resize((g.width * 3 // 4, g.height * 3 // 4)).resize((g.width, g.height))
g = g.rotate(angle, expand=False, fillcolor=255, resample=Image.BICUBIC)
g = g.filter(ImageFilter.GaussianBlur(0.6 if level == "scan" else 1.1))
a = np.asarray(g).astype(np.int16)
noise = np.random.default_rng(rnd.randint(0, 1 << 30)).integers(-25, 26, a.shape)
a = np.clip(a + noise, 0, 255).astype(np.uint8)
buf = io.BytesIO()
Image.fromarray(a).save(buf, "JPEG", quality=60 if level == "scan" else 35)
return Image.open(io.BytesIO(buf.getvalue())).convert("RGB")
def norm(s: str) -> str:
return "".join(s.split()).replace("(", "(").replace(")", ")").replace(":", ":").replace(",", ",").replace(";", ";")
def main() -> None:
pdf_path, pages = sys.argv[1], sorted(parse_ranges(sys.argv[2]))
dpi = int(sys.argv[3]) if len(sys.argv) > 3 else 200
eng = RapidOCR()
doc = pdfium.PdfDocument(pdf_path)
rnd = random.Random(7)
stats = {lv: [0, 0, 0, 0.0] for lv in ("clean", "scan", "bad")} # 编辑距离、字符包差、总字数、秒
with pdfplumber.open(pdf_path) as pl:
for no in pages:
page = pl.pages[no - 1]
truth = norm("".join(c["text"] for c in page.chars))
if len(truth) < 100:
continue
base = doc[no - 1].render(scale=dpi / 72).to_pil()
for lv in stats:
img = degrade(base, lv, rnd)
t0 = time.time()
r = eng(np.asarray(img))
dt = time.time() - t0
items = sorted(zip(r.boxes, r.txts), key=lambda x: (round(x[0][0][1] / 20), x[0][0][0])) if r.boxes is not None else []
got = norm("".join(t for _, t in items))
bag = sum(((Counter(truth) - Counter(got)) + (Counter(got) - Counter(truth))).values())
st = stats[lv]
st[0] += Levenshtein.distance(truth, got)
st[1] += bag
st[2] += len(truth)
st[3] += dt
print(f"p{no} {len(truth)} chars", flush=True)
n = sum(1 for _ in pages)
for lv, (ed, bag, total, sec) in stats.items():
print(f"{lv:6s} CER(顺序) {ed / total:.2%} 字符包差 {bag / total / 2:.2%} 共 {total} 字 OCR {sec:.1f}s")
print(f"pages tried {n}, dpi {dpi}")
if __name__ == "__main__":
main()