311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""Offline worker for the full official PaddleOCR-VL layout pipeline.
|
|
|
|
The caller sends JPEG bytes on stdin. This process uses explicit local model
|
|
directories; it writes a mode-0600 temporary file and deletes it afterward.
|
|
No private images or OCR results are saved to the model cache or logs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
|
|
from PIL import Image
|
|
|
|
|
|
ROOT = Path(os.environ.get("OCR_MODEL_HOME", Path(__file__).resolve().parents[1] / ".cache")).expanduser().resolve()
|
|
os.environ["PADDLE_PDX_CACHE_HOME"] = str(ROOT)
|
|
os.environ["HF_HOME"] = str(ROOT / "hf")
|
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
|
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
|
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
|
|
os.environ["OMP_NUM_THREADS"] = "1"
|
|
|
|
|
|
class _TableRows(HTMLParser):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.rows = []
|
|
self.row = None
|
|
self.cell = None
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
if tag == "tr":
|
|
self.row = []
|
|
elif tag in ("td", "th") and self.row is not None:
|
|
self.cell = []
|
|
elif tag == "br" and self.cell is not None:
|
|
self.cell.append(" ")
|
|
|
|
def handle_data(self, data):
|
|
if self.cell is not None:
|
|
self.cell.append(data)
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag in ("td", "th") and self.cell is not None and self.row is not None:
|
|
self.row.append(" ".join("".join(self.cell).split())[:500])
|
|
self.cell = None
|
|
elif tag == "tr" and self.row is not None:
|
|
if self.row:
|
|
self.rows.append(self.row)
|
|
self.row = None
|
|
|
|
|
|
def _table_rows(content: str) -> list[list[str]]:
|
|
parser = _TableRows()
|
|
parser.feed(content)
|
|
parser.close()
|
|
return parser.rows
|
|
|
|
|
|
def _box(value, width: int, height: int, offset=(0, 0)):
|
|
if value is None or len(value) != 4:
|
|
return None
|
|
try:
|
|
x1, y1, x2, y2 = (float(item) for item in value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if not (0 <= x1 < x2 <= width and 0 <= y1 < y2 <= height):
|
|
return None
|
|
ox, oy = offset
|
|
return [round(x1 + ox, 1), round(y1 + oy, 1),
|
|
round(x2 + ox, 1), round(y2 + oy, 1)]
|
|
|
|
|
|
def extract(payload: dict, offset=(0, 0)) -> dict:
|
|
"""Keep original block text and its actual layout region, never fake line boxes."""
|
|
width, height = int(payload["width"]), int(payload["height"])
|
|
lines, regions = [], []
|
|
for block in payload.get("parsing_res_list", []):
|
|
box = _box(block.get("block_bbox"), width, height, offset)
|
|
label = str(block.get("block_label", "region"))[:80]
|
|
content = block.get("block_content", "")
|
|
if not isinstance(content, str):
|
|
content = str(content)
|
|
if box:
|
|
regions.append({"label": label, "box": box})
|
|
if label == "table" and "<tr" in content.lower():
|
|
for cells in _table_rows(content):
|
|
text = " | ".join(cells)
|
|
if text.strip():
|
|
lines.append({"text": text[:1000], "cells": cells,
|
|
"box": box, "source": "PaddleOCR-VL-Tabellenzeile; nur Tabellenregion als Bildbeleg"})
|
|
else:
|
|
for part in content.splitlines():
|
|
text = part.strip()
|
|
if text:
|
|
lines.append({"text": text[:1000], "box": box,
|
|
"source": f"PaddleOCR-VL-Layoutblock {label}; keine eigene Zeilenbox"})
|
|
return {
|
|
"model": "PaddleOCR-VL 1.6 · vollständige lokale Layoutpipeline",
|
|
"lines": lines[:500], "regions": regions[:200],
|
|
"layout_note": "Vision-Bildbelege markieren echte Layoutregionen. Mehrere Textzeilen können dieselbe Region teilen; die Region ist keine präzise Feldbox.",
|
|
}
|
|
|
|
|
|
def _retry_table(block: dict, width: int, height: int) -> bool:
|
|
if block.get("block_label") != "table":
|
|
return False
|
|
box = _box(block.get("block_bbox"), width, height)
|
|
if not box:
|
|
return False
|
|
x1, y1, x2, y2 = box
|
|
area = (x2 - x1) * (y2 - y1) / (width * height)
|
|
content = block.get("block_content", "")
|
|
if not isinstance(content, str):
|
|
return False
|
|
visible_text = re.sub(r"<[^>]*>", "", content).strip()
|
|
return area > .45 and len(visible_text) < 80
|
|
|
|
|
|
def _split_table(box: list[float], width: int, height: int) -> list[tuple[int, int, int, int]]:
|
|
x1, y1, x2, y2 = box
|
|
left = max(0, int(x1) - 20)
|
|
top = max(0, int(y1) - 20)
|
|
right = min(width, int(x2) + 20)
|
|
bottom = min(height, int(y2) + 20)
|
|
middle = (left + right) // 2
|
|
overlap = min(120, max(40, round((right - left) * .045)))
|
|
return [(left, top, min(right, middle + overlap), bottom),
|
|
(max(left, middle - overlap), top, right, bottom)]
|
|
|
|
|
|
def _missing_neighbor_box(block: dict, blocks: list[dict], width: int, height: int):
|
|
"""Find an uncovered page column directly left of a detected right table."""
|
|
if block.get("block_label") != "table":
|
|
return None
|
|
box = _box(block.get("block_bbox"), width, height)
|
|
if not box:
|
|
return None
|
|
x1, y1, x2, y2 = box
|
|
table_width, table_height = x2 - x1, y2 - y1
|
|
if x1 < .52 * width or table_width > .4 * width or table_height < .2 * height:
|
|
return None
|
|
left = max(0, round(x1 - 1.2 * table_width))
|
|
right = min(width, round(x1 + .07 * table_width))
|
|
top = max(0, round(y1 - .1 * table_height))
|
|
bottom = min(height, round(y2 + .3 * table_height))
|
|
if right - left < 300 or bottom - top < 300:
|
|
return None
|
|
for other in blocks:
|
|
if other is block or other.get("block_label") != "table":
|
|
continue
|
|
other_box = _box(other.get("block_bbox"), width, height)
|
|
if not other_box:
|
|
continue
|
|
a, b, c, d = other_box
|
|
overlap = max(0, min(x1, c) - max(left, a)) * max(0, min(bottom, d) - max(top, b))
|
|
if overlap > .15 * (x1 - left) * (bottom - top):
|
|
return None
|
|
return (left, top, right, bottom)
|
|
|
|
|
|
def _run_tile(pipeline, image: Image.Image, crop_box, private: Path):
|
|
descriptor, name = tempfile.mkstemp(prefix="table-", suffix=".jpg", dir=private)
|
|
tile_path = Path(name)
|
|
try:
|
|
with os.fdopen(descriptor, "wb") as stream:
|
|
image.crop(crop_box).save(stream, format="JPEG", quality=92)
|
|
results = list(pipeline.predict(str(tile_path)))
|
|
if len(results) != 1:
|
|
return None
|
|
payload = results[0].json
|
|
return extract(payload.get("res", payload), crop_box[:2])
|
|
except Exception:
|
|
# Keep the original page result if a diagnostic retry fails.
|
|
return None
|
|
finally:
|
|
tile_path.unlink(missing_ok=True)
|
|
|
|
|
|
def predict_tiled(pipeline, path: Path, private: Path) -> dict:
|
|
"""Generic full-page coverage for a wide page whose full parsing timed out."""
|
|
with Image.open(path) as image:
|
|
width, height = image.size
|
|
boxes = [(0, 0, round(width * .34), height),
|
|
(round(width * .25), 0, round(width * .66), height),
|
|
(round(width * .61), 0, width, height)]
|
|
tiles = [_run_tile(pipeline, image, box, private) for box in boxes]
|
|
valid = [tile for tile in tiles if tile and tile["lines"]]
|
|
if not valid:
|
|
raise RuntimeError("tile_inference_failed")
|
|
result = extract({"width": width, "height": height, "parsing_res_list": []})
|
|
for tile in valid:
|
|
result["lines"].extend(tile["lines"])
|
|
result["regions"].extend(tile["regions"])
|
|
result["lines"] = result["lines"][:500]
|
|
result["regions"] = result["regions"][:200]
|
|
result["retry_used"] = True
|
|
result["layout_note"] = (
|
|
f"Die Gesamtansicht brauchte zu lange. {len(valid)} von 3 überlappenden "
|
|
"Ansichten derselben Seite wurden lokal gelesen; widersprüchliche "
|
|
"Feldwerte bleiben ohne Zuordnung. Tabellenbelege sind grobe Regionen.")
|
|
return result
|
|
|
|
|
|
def predict_adaptive(pipeline, path: Path, private: Path) -> dict:
|
|
results = list(pipeline.predict(str(path)))
|
|
if len(results) != 1:
|
|
raise RuntimeError("invalid_page_count")
|
|
original = results[0].json
|
|
payload = original.get("res", original)
|
|
width, height = int(payload["width"]), int(payload["height"])
|
|
blocks = payload.get("parsing_res_list", [])
|
|
large_table = next((block for block in blocks if _retry_table(block, width, height)), None)
|
|
neighbor_box = next((box for block in blocks
|
|
if (box := _missing_neighbor_box(block, blocks, width, height))), None)
|
|
if large_table is None and neighbor_box is None:
|
|
return extract(payload)
|
|
|
|
# A large, almost empty table is a measurable layout/scale failure. Retry
|
|
# only that detected region in two smaller views; all pixels still come
|
|
# from the same upload and all coordinates map back to the common image.
|
|
tiles = []
|
|
large_recovered = False
|
|
neighbor_recovered = False
|
|
with Image.open(path) as image:
|
|
if large_table is not None:
|
|
box = _box(large_table["block_bbox"], width, height)
|
|
for crop_box in _split_table(box, width, height):
|
|
parsed = _run_tile(pipeline, image, crop_box, private)
|
|
if parsed and parsed["lines"]:
|
|
tiles.append(parsed)
|
|
large_recovered = True
|
|
if neighbor_box is not None:
|
|
parsed = _run_tile(pipeline, image, neighbor_box, private)
|
|
if parsed and parsed["lines"]:
|
|
tiles.append(parsed)
|
|
neighbor_recovered = True
|
|
if not tiles:
|
|
return extract(payload)
|
|
kept = [block for block in blocks if block is not large_table] if large_recovered else blocks
|
|
result = extract({**payload, "parsing_res_list": kept})
|
|
for tile in tiles:
|
|
result["lines"].extend(tile["lines"])
|
|
result["regions"].extend(tile["regions"])
|
|
result["lines"] = result["lines"][:500]
|
|
result["regions"] = result["regions"][:200]
|
|
result["retry_used"] = True
|
|
notes = []
|
|
if large_recovered:
|
|
notes.append("Ein übergroßer, fast leerer Tabellenblock wurde in zwei kleineren Ansichten erneut gelesen.")
|
|
if neighbor_recovered:
|
|
notes.append("Eine von der Layoutstufe ausgelassene Nachbarspalte wurde zusätzlich gelesen.")
|
|
notes.append("Tabellenzeilen haben weiterhin nur einen groben Regionsbeleg.")
|
|
result["layout_note"] = " ".join(notes)
|
|
return result
|
|
|
|
|
|
def main() -> None:
|
|
data = sys.stdin.buffer.read(16 * 1024 * 1024 + 1)
|
|
if not data or len(data) > 16 * 1024 * 1024:
|
|
print(json.dumps({"error": "invalid_input"}))
|
|
return
|
|
models = ROOT / "official_models"
|
|
layout = models / "PP-DocLayoutV3"
|
|
vlm = models / "PaddleOCR-VL-1.6"
|
|
if not (layout / "inference.pdiparams").is_file() or not (vlm / "model.safetensors").is_file():
|
|
print(json.dumps({"error": "models_missing"}))
|
|
return
|
|
private = Path(os.environ.get("OCR_PRIVATE_DIR", ROOT / "private"))
|
|
private.mkdir(mode=0o700, exist_ok=True)
|
|
descriptor, name = tempfile.mkstemp(prefix="page-", suffix=".jpg", dir=private)
|
|
path = Path(name)
|
|
try:
|
|
with os.fdopen(descriptor, "wb") as stream:
|
|
stream.write(data)
|
|
# Paddle logs should never expose text or private file paths through
|
|
# the SSH result channel. The app suppresses stderr as well.
|
|
stdout_copy = os.dup(1)
|
|
null_fd = os.open(os.devnull, os.O_WRONLY)
|
|
os.dup2(null_fd, 1)
|
|
os.close(null_fd)
|
|
try:
|
|
from paddleocr import PaddleOCRVL
|
|
pipeline = PaddleOCRVL(pipeline_version="v1.6", use_layout_detection=True,
|
|
layout_detection_model_dir=str(layout), vl_rec_model_dir=str(vlm),
|
|
use_doc_orientation_classify=False,
|
|
use_doc_unwarping=False,
|
|
device=os.environ.get("OCR_VISION_DEVICE", "cpu"))
|
|
result = (predict_tiled(pipeline, path, private)
|
|
if "--tiles-only" in sys.argv[1:] else
|
|
predict_adaptive(pipeline, path, private))
|
|
finally:
|
|
os.dup2(stdout_copy, 1)
|
|
os.close(stdout_copy)
|
|
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
|
|
except Exception:
|
|
# No exception text: model libraries may include recognized content.
|
|
print(json.dumps({"error": "inference_failed"}))
|
|
finally:
|
|
path.unlink(missing_ok=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|