ocr-mt-5090 / python /ocr_engine.py
mohameddalii's picture
OCR + Qwen3-4B RTX 5090 colocated serve kit
ba8abef verified
Raw History Blame Contribute Delete
5.66 kB
#!/usr/bin/env python3
"""PaddleOCR-VL client: parallel layout crops + merge.
Proven on RTX 5090 + vLLM next to Qwen3-4B. Prompt is ``OCR:``, temperature 0.
"""
from __future__ import annotations
import base64
import io
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from openai import OpenAI
from PIL import Image
DEFAULT_BASE_URL = "http://127.0.0.1:18000/v1"
DEFAULT_MODEL = "PaddlePaddle/PaddleOCR-VL"
PROMPT = "OCR:"
_MIN_SIDE = 420
_REPEAT_TOKEN = re.compile(r"(\S+)(?:\s+\1){6,}")
_REPEAT_CHAR = re.compile(r"(.{6,}?)\1{5,}", re.DOTALL)
_REFUSAL = re.compile(
r"(not fully clear|cannot extract|cannot准确|无法|没有文字)",
re.I,
)
@dataclass
class RegionResult:
name: str
text: str
latency_s: float
def _to_data_url(img: Image.Image) -> str:
buf = io.BytesIO()
img.convert("RGB").save(buf, format="PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
def _pad_min(img: Image.Image, min_side: int = _MIN_SIDE) -> Image.Image:
w, h = img.size
if w >= min_side and h >= min_side:
return img
nw, nh = max(w, min_side), max(h, min_side)
canvas = Image.new("RGB", (nw, nh), (255, 255, 255))
canvas.paste(img, ((nw - w) // 2, (nh - h) // 2))
return canvas
def _band(img: Image.Image, top: float, bot: float, left: float = 0.0, right: float = 1.0) -> Image.Image:
w, h = img.size
box = (
int(w * left),
int(h * top),
max(int(w * left) + 8, int(w * right)),
max(int(h * top) + 8, int(h * bot)),
)
return img.crop(box)
def regions_for(img: Image.Image) -> list[tuple[str, Image.Image]]:
w, h = img.size
out: list[tuple[str, Image.Image]] = [
("full", img),
("header", _pad_min(_band(img, 0.0, 0.16))),
("top_right", _pad_min(_band(img, 0.0, 0.22, 0.48, 1.0))),
("right", _pad_min(_band(img, 0.0, 0.62, 0.52, 1.0))),
("totals", _pad_min(_band(img, 0.34, 0.72))),
]
if w < 900:
out.append(("masthead", img.crop((0, 0, w, min(h, 130)))))
return out
def strip_collapse(text: str) -> str:
if not text:
return text
m = _REPEAT_TOKEN.search(text)
if m:
text = text[: m.start()] + m.group(1)
m = _REPEAT_CHAR.search(text)
if m:
text = text[: m.start()] + m.group(1)
lines = text.splitlines()
cleaned: list[str] = []
run = 0
prev = None
short_run = 0
for ln in lines:
key = ln.strip()
if len(key) <= 2:
short_run += 1
if short_run >= 8:
break
continue
short_run = 0
if key and key == prev:
run += 1
if run >= 6:
break
continue
prev = key
run = 0
cleaned.append(ln)
return "\n".join(cleaned).strip()
def is_junk_region(text: str) -> bool:
if not text or not text.strip():
return True
if _REFUSAL.search(text):
return True
cjk = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
return cjk > 12
class OcrEngine:
def __init__(
self,
base_url: str = DEFAULT_BASE_URL,
model: str = DEFAULT_MODEL,
timeout: float = 180.0,
) -> None:
self.model = model
self.client = OpenAI(api_key="EMPTY", base_url=base_url, timeout=timeout)
def _complete(self, img: Image.Image, *, max_tokens: int, penalty: bool) -> str:
extra = {"repetition_penalty": 1.05}
if penalty:
extra = {"repetition_penalty": 1.12, "frequency_penalty": 0.22}
resp = self.client.chat.completions.create(
model=self.model,
temperature=0.0,
max_tokens=max_tokens,
extra_body=extra,
messages=[
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": _to_data_url(img)}},
{"type": "text", "text": PROMPT},
],
}
],
)
return strip_collapse(resp.choices[0].message.content or "")
def ocr_image(self, image: Image.Image | Path | str) -> tuple[str, float, list[RegionResult]]:
if not isinstance(image, Image.Image):
image = Image.open(image).convert("RGB")
else:
image = image.convert("RGB")
jobs = regions_for(image)
t0 = time.perf_counter()
results: list[RegionResult] = []
def run(name: str, crop: Image.Image) -> RegionResult:
t1 = time.perf_counter()
light = name in {"full", "masthead"}
text = self._complete(
crop,
max_tokens=1024 if name == "full" else 512,
penalty=not light,
)
return RegionResult(name, text, time.perf_counter() - t1)
with ThreadPoolExecutor(max_workers=len(jobs)) as pool:
futs = [pool.submit(run, name, crop) for name, crop in jobs]
for fut in as_completed(futs):
results.append(fut.result())
order = {"full": 0, "header": 1, "masthead": 2, "top_right": 3, "right": 4, "totals": 5}
results.sort(key=lambda r: order.get(r.name, 9))
parts = []
for r in results:
chunk = strip_collapse(r.text)
if chunk.strip() and not is_junk_region(chunk):
parts.append(chunk)
return "\n\n".join(parts), time.perf_counter() - t0, results