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:
31
services/ocr/pyproject.toml
Normal file
31
services/ocr/pyproject.toml
Normal 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"]
|
||||
0
services/ocr/src/bid_doctor_ocr/__init__.py
Normal file
0
services/ocr/src/bid_doctor_ocr/__init__.py
Normal file
62
services/ocr/src/bid_doctor_ocr/app.py
Normal file
62
services/ocr/src/bid_doctor_ocr/app.py
Normal 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)
|
||||
239
services/ocr/src/bid_doctor_ocr/engine.py
Normal file
239
services/ocr/src/bid_doctor_ocr/engine.py
Normal 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)]
|
||||
Reference in New Issue
Block a user