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,31 @@
[project]
name = "bid-doctor-ocr"
version = "0.1.0"
description = "标博士 OCR 服务:页面图片 → 文字行 + 坐标(架构 §3.5、vision-plan §4.2)。worker 通过 HTTP 调用,不连数据库"
requires-python = ">=3.12"
dependencies = [
"rapidocr>=3.10,<4", # PP-OCRv6 small 检测 / 识别模型(tech-validation §7.1)
"onnxruntime>=1.20",
"numpy>=2",
"pillow>=11",
"fastapi>=0.115",
"uvicorn>=0.32",
]
[project.scripts]
bid-doctor-ocr = "bid_doctor_ocr.app:main"
[build-system]
requires = ["hatchling>=1.26"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/bid_doctor_ocr"]
[tool.ruff]
line-length = 120
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF", "ASYNC", "S", "BLE"]
ignore = ["RUF001", "RUF002", "RUF003", "S101"]

View File

@@ -0,0 +1,62 @@
"""OCR 服务:POST /v1/ocr,请求体是一张页面图片(PNG / JPEG),返回文字行和坐标(engine.recognize)。
只在内网给 worker 用,不连数据库、不存图片;不开网络出口(模型打进镜像)。
bid-doctor-ocr --host 0.0.0.0 --port 8000
"""
from __future__ import annotations
import argparse
import io
import time
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException, Query, Request
from PIL import Image
from starlette.concurrency import run_in_threadpool
from bid_doctor_ocr import engine
MAX_BYTES = 25 * 1024 * 1024
Image.MAX_IMAGE_PIXELS = 60_000_000 # A3 在 300 dpi 下约 1700 万像素;更大的按解压炸弹拒绝
@asynccontextmanager
async def lifespan(_: FastAPI): # type: ignore[no-untyped-def]
engine.recognize(Image.new("RGB", (64, 64), "white"), full_page=False) # 启动时先加载模型
yield
app = FastAPI(title="bid-doctor-ocr", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.post("/v1/ocr")
async def ocr(request: Request, full_page: bool = Query(default=True)) -> dict[str, Any]:
body = await request.body()
if not body or len(body) > MAX_BYTES:
raise HTTPException(413, "图片为空或超过 25MB")
try:
img = Image.open(io.BytesIO(body))
img.load()
except (OSError, Image.DecompressionBombError) as e:
raise HTTPException(422, "不是可识别的图片") from e
t0 = time.monotonic()
out = await run_in_threadpool(engine.recognize, img, full_page=full_page)
out["seconds"] = round(time.monotonic() - t0, 2)
return out
def main() -> None:
import uvicorn
p = argparse.ArgumentParser(prog="bid-doctor-ocr")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8029)
a = p.parse_args()
uvicorn.run(app, host=a.host, port=a.port)

View File

@@ -0,0 +1,239 @@
"""识别一张图片:转正 → OCR → 红章附近按红色通道重读(tech-validation §7.1)。
返回的每一行带两套坐标:
box:原图(没转正之前)的像素坐标,四个点,worker 换算成 PDF 坐标画高亮;
upright:转正后的外接矩形,worker 按它判断阅读顺序和切句。
stamps:红章的外接矩形(原图像素),worker 用它去掉印章上环形排列的字;以后签章检查也用(vision-plan 第 4 步)。
"""
from __future__ import annotations
import threading
from collections import deque
from dataclasses import dataclass
from typing import Any
import numpy as np
from PIL import Image, ImageOps
from rapidocr import RapidOCR
Box = list[list[float]] # 四个点 [[x, y], ...],左上、右上、右下、左下
@dataclass(slots=True)
class Line:
box: Box # 转正后图片的像素坐标
text: str
score: float
_engine: RapidOCR | None = None
# 一个进程一个引擎、推理串行:onnxruntime 一次推理已经用满所有核;要更快就多起几个副本
_lock = threading.Lock()
def _ocr(img: Image.Image) -> list[Line]:
global _engine
with _lock:
if _engine is None:
_engine = RapidOCR(params={"Global.log_level": "warning"})
r = _engine(np.asarray(img.convert("RGB")))
if r.boxes is None:
return []
return [Line(b.tolist(), str(t), float(s)) for b, t, s in zip(r.boxes, r.txts, r.scores, strict=True)]
def _quality(lines: list[Line]) -> float:
if not lines:
return 0.0
return sum(len(x.text) for x in lines) * sum(x.score for x in lines) / len(lines)
def _tall(b: Box) -> bool:
return (b[2][1] - b[0][1]) > 1.5 * (b[1][0] - b[0][0])
def _upside_down(img: Image.Image, lines: list[Line]) -> bool:
"""整页倒置:挑最多 30 个较长的行,用 RapidOCR 的方向分类模型判断,过半是 180° 就算倒置。
逐行识别时 RapidOCR 会自己翻正,字认得出,但阅读顺序和交给视觉模型的整页图是倒的(tech-validation §7)。"""
long = sorted((x for x in lines if len(x.text) >= 4), key=lambda x: -len(x.text))[:30]
if len(long) < 3:
return False
arr = np.asarray(img.convert("RGB"))
crops = []
for x in long:
x0, y0, x1, y1 = (int(v) for v in _bbox(x.box))
if x1 - x0 > 4 and y1 - y0 > 4:
crops.append(arr[max(0, y0) : y1, max(0, x0) : x1])
if not crops:
return False
with _lock:
assert _engine is not None
res = _engine._load_cls_model()(crops).cls_res or [] # RapidOCR 没有公开单独调方向分类的接口
flipped = sum(1 for label, score in res if "180" in label and score > 0.8)
return flipped > len(res) / 2
def orient(img: Image.Image) -> tuple[int, Image.Image, list[Line]]:
"""转正:整页横放(横版报表、手机横拍的证书)时,0° 识别出的长行多是竖长框,再试 90° / 270°,
取「字数 × 平均置信度」最高的;然后看是不是整页倒置(180°)。"""
lines = _ocr(img)
tall = sum(1 for x in lines if len(x.text) >= 3 and _tall(x.box))
best = (_quality(lines), 0, img, lines)
if not lines or tall >= len(lines) * 0.3:
for rot in (90, 270):
r_img = img.rotate(rot, expand=True)
r_lines = _ocr(r_img)
if (q := _quality(r_lines)) > best[0]:
best = (q, rot, r_img, r_lines)
_, rot, upright, lines = best
if _upside_down(upright, lines):
rot = (rot + 180) % 360
upright = img.rotate(rot, expand=True)
lines = _ocr(upright)
return rot, upright, lines
GRID = 8 # 找红章时按 8×8 像素一格降采样
def _red_mask(img: Image.Image) -> np.ndarray[Any, Any]:
small = img.convert("RGB").resize((max(1, img.width // GRID), max(1, img.height // GRID)))
a = np.asarray(small).astype(np.int16)
r, g, b = a[..., 0], a[..., 1], a[..., 2]
return (r > 120) & (r - g > 40) & (r - b > 40)
def stamps(img: Image.Image) -> list[tuple[int, int, int, int]]:
"""红章的外接矩形(原图像素)。降采样后的红色格子按 8 邻接连成块,太小的(红字、红线)不算。"""
mask = _red_mask(img)
h, w = mask.shape
seen = np.zeros_like(mask)
out: list[tuple[int, int, int, int]] = []
for y0, x0 in zip(*np.nonzero(mask), strict=True):
if seen[y0, x0]:
continue
q = deque([(int(y0), int(x0))])
seen[y0, x0] = True
ys, xs = [int(y0)], [int(x0)]
while q:
y, x = q.popleft()
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
ny, nx = y + dy, x + dx
if 0 <= ny < h and 0 <= nx < w and mask[ny, nx] and not seen[ny, nx]:
seen[ny, nx] = True
q.append((ny, nx))
ys.append(ny)
xs.append(nx)
out.append((min(xs) * GRID, min(ys) * GRID, (max(xs) + 1) * GRID, (max(ys) + 1) * GRID))
# 一个章常被里面的白字和缝隙断成几块:相距 3 格以内的块合并
merged = True
while merged:
merged = False
for i in range(len(out)):
for j in range(i + 1, len(out)):
a, b = out[i], out[j]
if (
a[0] - 3 * GRID < b[2]
and b[0] - 3 * GRID < a[2]
and a[1] - 3 * GRID < b[3]
and b[1] - 3 * GRID < a[3]
):
out[i] = (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]))
del out[j]
merged = True
break
if merged:
break
# 公章直径一般 4 cm,200 dpi 下 300 像素以上;骑缝章露出一截也有 40 像素宽。太小的是红字、红线
return [b for b in out if min(b[2] - b[0], b[3] - b[1]) >= 40 and (b[2] - b[0]) * (b[3] - b[1]) >= 30 * GRID * GRID]
def _bbox(b: Box) -> tuple[float, float, float, float]:
xs, ys = [p[0] for p in b], [p[1] for p in b]
return min(xs), min(ys), max(xs), max(ys)
def _hits(b: Box, r: tuple[float, float, float, float]) -> bool:
x0, y0, x1, y1 = _bbox(b)
return x0 < r[2] and x1 > r[0] and y0 < r[3] and y1 > r[1]
def red_channel(img: Image.Image) -> Image.Image:
"""只取红色通道当灰度图:红章变浅,黑字不变。"""
return ImageOps.autocontrast(img.convert("RGB").split()[0]).convert("RGB")
def reread_stamps(img: Image.Image, lines: list[Line], found: list[tuple[int, int, int, int]]) -> list[Line]:
"""红章附近的行按红色通道重读,比原来好(字数 × 平均置信度更高)就替换。只动章附近:
整页用红色通道时,有的页变好、有的页变差(tech-validation §7.1)。"""
for st in found:
near = [x for x in lines if _hits(x.box, st)]
if not near:
continue
xs = [p for x in near for p in _bbox(x.box)[0::2]] + [st[0], st[2]]
ys = [p for x in near for p in _bbox(x.box)[1::2]] + [st[1], st[3]]
crop = (
max(0, int(min(xs)) - 8),
max(0, int(min(ys)) - 8),
min(img.width, int(max(xs)) + 8),
min(img.height, int(max(ys)) + 8),
)
again = [
Line([[p[0] + crop[0], p[1] + crop[1]] for p in x.box], x.text, x.score)
for x in _ocr(red_channel(img.crop(crop)))
]
# 只比较、替换落在重读区域里的原有行
inside = [
x for x in lines if _hits(x.box, crop) and _bbox(x.box)[0] >= crop[0] - 2 and _bbox(x.box)[2] <= crop[2] + 2
]
if _quality(again) > _quality(inside):
keep = [x for x in lines if x not in inside]
lines = keep + again
return lines
def _unrotate(p: list[float], rot: int, w: int, h: int) -> list[float]:
"""转正后图片里的点 → 原图的点。PIL 的 rotate(rot, expand=True) 是逆时针;w、h 是原图宽高。"""
x, y = p
if rot == 90:
return [w - y, x]
if rot == 180:
return [w - x, h - y]
if rot == 270:
return [y, h - x]
return [x, y]
def recognize(img: Image.Image, *, full_page: bool) -> dict[str, Any]:
"""full_page:整页扫描件才试转正、重读红章;文字页里的插图只识别一次。"""
found: list[tuple[int, int, int, int]] = []
if full_page:
rot, upright, lines = orient(img)
found = stamps(upright)
lines = reread_stamps(upright, lines, found)
else:
rot, upright, lines = 0, img, _ocr(img)
lines.sort(key=lambda x: (_bbox(x.box)[1], _bbox(x.box)[0]))
return {
"rotation": rot,
"width": img.width,
"height": img.height,
"lines": [
{
"box": [[round(v, 1) for v in _unrotate(p, rot, img.width, img.height)] for p in x.box],
"upright": [round(v, 1) for v in _bbox(x.box)],
"text": x.text,
"score": round(x.score, 3),
}
for x in lines
if x.text.strip()
],
"stamps": [_unrotate_box(b, rot, img.width, img.height) for b in found],
}
def _unrotate_box(b: tuple[int, int, int, int], rot: int, w: int, h: int) -> list[float]:
pts = [_unrotate([float(b[0]), float(b[1])], rot, w, h), _unrotate([float(b[2]), float(b[3])], rot, w, h)]
return [min(p[0] for p in pts), min(p[1] for p in pts), max(p[0] for p in pts), max(p[1] for p in pts)]