Spaces:
Sleeping
Sleeping
OCR Compare: PaddleOCR vs PaddleOCR-VL with click-to-edit boxes
Browse files- .gitattributes +1 -0
- README.md +44 -6
- app.py +526 -0
- examples/sample.png +3 -0
- overlay.py +405 -0
- requirements.txt +22 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
examples/sample.png filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,13 +1,51 @@
|
|
| 1 |
---
|
| 2 |
title: OCR Compare
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
|
|
|
|
|
|
|
|
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: OCR Compare
|
| 3 |
+
emoji: 🔍
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.50.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
python_version: "3.12"
|
| 10 |
+
short_description: Classic PaddleOCR vs PaddleOCR-VL with click-to-edit boxes
|
| 11 |
+
startup_duration_timeout: 1h
|
| 12 |
pinned: false
|
| 13 |
+
license: apache-2.0
|
| 14 |
---
|
| 15 |
|
| 16 |
+
# OCR Compare: PaddleOCR vs PaddleOCR-VL
|
| 17 |
+
|
| 18 |
+
Upload a scanned PDF or image and read the same page with two engines:
|
| 19 |
+
|
| 20 |
+
| | Engine | Runs on |
|
| 21 |
+
|---|---|---|
|
| 22 |
+
| Left | **PaddleOCR** (PP-OCRv5 detection + recognition) | Space CPU |
|
| 23 |
+
| Right | **PaddleOCR-VL** (0.9B vision-language model) | ZeroGPU |
|
| 24 |
+
|
| 25 |
+
Both panels show the page with **one invisible box per detected line**. Click a
|
| 26 |
+
box to see its text, correct it, and see what the other engine read at the same
|
| 27 |
+
spot. Below the panels, a table lists every place where the two readings differ.
|
| 28 |
+
|
| 29 |
+
## How to use
|
| 30 |
+
|
| 31 |
+
1. Upload a PDF or image, or click **Load sample**.
|
| 32 |
+
2. Click **Run both**.
|
| 33 |
+
3. Click any line on either page.
|
| 34 |
+
- **Show all boxes** colours each box by agreement.
|
| 35 |
+
- **Show recognized text** draws the OCR text over the page.
|
| 36 |
+
|
| 37 |
+
## PaddleOCR-VL box modes
|
| 38 |
+
|
| 39 |
+
- **Same text lines as classic** (default). VL re-reads the exact lines that
|
| 40 |
+
classic PaddleOCR detected, so any difference is purely down to recognition.
|
| 41 |
+
- **VL's own layout zones**. VL detects regions (text, tables, formulas) itself
|
| 42 |
+
and reads each whole region. This shows how VL handles a page on its own.
|
| 43 |
+
Tables come back as HTML and formulas as LaTeX.
|
| 44 |
+
|
| 45 |
+
## Notes
|
| 46 |
+
|
| 47 |
+
- Edits live only in your browser, so they are for comparison and nothing is
|
| 48 |
+
written back to a file.
|
| 49 |
+
- Free ZeroGPU gives each visitor a few minutes of GPU per day. VL reads at most
|
| 50 |
+
150 lines per page. Untick **Run PaddleOCR-VL** to use classic only.
|
| 51 |
+
- The first launch downloads both models, which takes a few minutes.
|
app.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OCR Compare -- classic PaddleOCR vs PaddleOCR-VL, side by side
|
| 3 |
+
==============================================================
|
| 4 |
+
|
| 5 |
+
PDF / image page
|
| 6 |
+
├─► classic PaddleOCR (PP-OCRv5 det + rec, CPU) ─┐
|
| 7 |
+
└─► PaddleOCR-VL-0.9B (VLM, ZeroGPU) ─┤ same {text, box} shape
|
| 8 |
+
▼
|
| 9 |
+
two page panels with invisible click-to-edit boxes + a diff table
|
| 10 |
+
|
| 11 |
+
Everything runs on Hugging Face: PaddlePaddle on the Space CPU, the VLM on
|
| 12 |
+
ZeroGPU. The box overlay and comparison live in overlay.py.
|
| 13 |
+
|
| 14 |
+
The VLM loading code (version pins, kwarg shim, chunked GPU calls, prompt
|
| 15 |
+
slicing) is carried over from the PaddleOCR-VL-Doc-Editor Space, where each of
|
| 16 |
+
those was needed to make it run on ZeroGPU at all.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
import spaces # must be imported before torch / any CUDA-touching import
|
| 21 |
+
except ImportError: # local run without the HF `spaces` package
|
| 22 |
+
|
| 23 |
+
class spaces: # noqa: N801
|
| 24 |
+
@staticmethod
|
| 25 |
+
def GPU(*args, **kwargs):
|
| 26 |
+
if args and callable(args[0]):
|
| 27 |
+
return args[0]
|
| 28 |
+
return lambda fn: fn
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
import io
|
| 32 |
+
import os
|
| 33 |
+
import time
|
| 34 |
+
import traceback
|
| 35 |
+
|
| 36 |
+
import fitz # PyMuPDF
|
| 37 |
+
import gradio as gr
|
| 38 |
+
import numpy as np
|
| 39 |
+
from PIL import Image
|
| 40 |
+
|
| 41 |
+
from overlay import (
|
| 42 |
+
CSS,
|
| 43 |
+
HEAD_JS,
|
| 44 |
+
TABLE_HEADERS,
|
| 45 |
+
compare,
|
| 46 |
+
image_to_data_uri,
|
| 47 |
+
overlay_html,
|
| 48 |
+
placeholder_html,
|
| 49 |
+
quad_to_box,
|
| 50 |
+
reading_order,
|
| 51 |
+
table_rows,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
import torch
|
| 56 |
+
except ImportError: # local UI testing without the VLM stack
|
| 57 |
+
torch = None
|
| 58 |
+
|
| 59 |
+
# ---------------------------------------------------------------------------
|
| 60 |
+
# Config
|
| 61 |
+
# ---------------------------------------------------------------------------
|
| 62 |
+
VL_MODEL = "PaddlePaddle/PaddleOCR-VL"
|
| 63 |
+
CLASSIC_MODELS = {
|
| 64 |
+
"mobile (fast)": ("PP-OCRv5_mobile_det", "PP-OCRv5_mobile_rec"),
|
| 65 |
+
"server (accurate)": ("PP-OCRv5_server_det", "PP-OCRv5_server_rec"),
|
| 66 |
+
}
|
| 67 |
+
LAYOUT_MODELS = ["PP-DocLayoutV2", "PP-DocLayout_plus-L", "PP-DocLayout-L"]
|
| 68 |
+
|
| 69 |
+
VL_SAME_LINES = "Same text lines as classic (fair comparison)"
|
| 70 |
+
VL_LAYOUT = "VL's own layout zones (text / table / formula)"
|
| 71 |
+
|
| 72 |
+
RENDER_DPI = 150
|
| 73 |
+
MAX_SIDE = 2200 # cap the long side of a rendered page (px)
|
| 74 |
+
MAX_PAGES = 30
|
| 75 |
+
MAX_VL_ITEMS = 150 # bound GPU time per page
|
| 76 |
+
SAMPLE_PATH = "examples/sample.png"
|
| 77 |
+
|
| 78 |
+
# Free ZeroGPU caps each call's duration and each visitor's GPU-seconds per day,
|
| 79 |
+
# so recognition runs in several short allocations rather than one long one.
|
| 80 |
+
GPU_DURATION = 55
|
| 81 |
+
CHUNK_LINES = 24 # short single-line crops
|
| 82 |
+
CHUNK_ZONES = 10 # whole regions (can be long)
|
| 83 |
+
|
| 84 |
+
PROMPTS = {"ocr": "OCR:", "table": "Table Recognition:",
|
| 85 |
+
"formula": "Formula Recognition:", "chart": "Chart Recognition:"}
|
| 86 |
+
MAX_TOKENS = {"ocr": 1024, "table": 1536, "formula": 512, "chart": 1024}
|
| 87 |
+
LABEL_TO_TASK = {"table": "table", "formula": "formula", "equation": "formula",
|
| 88 |
+
"chart": "chart"}
|
| 89 |
+
|
| 90 |
+
ON_ZEROGPU = bool(os.environ.get("SPACES_ZERO_GPU"))
|
| 91 |
+
USE_CUDA = torch is not None and (ON_ZEROGPU or torch.cuda.is_available())
|
| 92 |
+
DEVICE = "cuda" if USE_CUDA else "cpu"
|
| 93 |
+
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
# Models (lazy singletons)
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
_classic: dict = {}
|
| 98 |
+
_layout = {"model": None}
|
| 99 |
+
_vlm = {"model": None, "processor": None, "error": None}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _get_classic(size: str):
|
| 103 |
+
if size in _classic:
|
| 104 |
+
return _classic[size]
|
| 105 |
+
from paddleocr import PaddleOCR
|
| 106 |
+
|
| 107 |
+
det, rec = CLASSIC_MODELS[size]
|
| 108 |
+
print(f"[classic] loading {det} + {rec}", flush=True)
|
| 109 |
+
engine = PaddleOCR(
|
| 110 |
+
text_detection_model_name=det,
|
| 111 |
+
text_recognition_model_name=rec,
|
| 112 |
+
# Keep the page exactly as rendered so boxes line up with the image.
|
| 113 |
+
use_doc_orientation_classify=False,
|
| 114 |
+
use_doc_unwarping=False,
|
| 115 |
+
use_textline_orientation=False,
|
| 116 |
+
)
|
| 117 |
+
_classic[size] = engine
|
| 118 |
+
return engine
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _get_layout():
|
| 122 |
+
if _layout["model"] is not None:
|
| 123 |
+
return _layout["model"]
|
| 124 |
+
from paddleocr import LayoutDetection
|
| 125 |
+
|
| 126 |
+
last = None
|
| 127 |
+
for name in LAYOUT_MODELS:
|
| 128 |
+
try:
|
| 129 |
+
_layout["model"] = LayoutDetection(model_name=name)
|
| 130 |
+
print("[layout] using", name, flush=True)
|
| 131 |
+
return _layout["model"]
|
| 132 |
+
except Exception as e: # noqa: BLE001
|
| 133 |
+
last = e
|
| 134 |
+
print(f"[layout] {name} failed: {e}", flush=True)
|
| 135 |
+
raise RuntimeError(f"Could not load a layout model: {last}")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _patch_transformers_masking():
|
| 139 |
+
"""PaddleOCR-VL's remote code calls create_causal_mask(inputs_embeds=...),
|
| 140 |
+
but transformers 4.55 names that parameter `input_embeds`. Rename it."""
|
| 141 |
+
import functools
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
import transformers.masking_utils as mu
|
| 145 |
+
except Exception as e: # noqa: BLE001
|
| 146 |
+
print("[patch] masking_utils unavailable:", e, flush=True)
|
| 147 |
+
return
|
| 148 |
+
orig = mu.create_causal_mask
|
| 149 |
+
if getattr(orig, "_paddleocr_shim", False):
|
| 150 |
+
return
|
| 151 |
+
|
| 152 |
+
@functools.wraps(orig)
|
| 153 |
+
def shim(*args, **kwargs):
|
| 154 |
+
if "inputs_embeds" in kwargs and "input_embeds" not in kwargs:
|
| 155 |
+
kwargs["input_embeds"] = kwargs.pop("inputs_embeds")
|
| 156 |
+
return orig(*args, **kwargs)
|
| 157 |
+
|
| 158 |
+
shim._paddleocr_shim = True
|
| 159 |
+
mu.create_causal_mask = shim
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _load_vlm():
|
| 163 |
+
if _vlm["model"] is not None:
|
| 164 |
+
return
|
| 165 |
+
if torch is None:
|
| 166 |
+
raise RuntimeError("PyTorch is not installed, so PaddleOCR-VL is unavailable here.")
|
| 167 |
+
_patch_transformers_masking()
|
| 168 |
+
from transformers import AutoModelForCausalLM, AutoProcessor
|
| 169 |
+
|
| 170 |
+
print("[vlm] loading", VL_MODEL, flush=True)
|
| 171 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 172 |
+
VL_MODEL, trust_remote_code=True, torch_dtype=torch.bfloat16
|
| 173 |
+
).eval()
|
| 174 |
+
if DEVICE == "cuda":
|
| 175 |
+
model = model.to("cuda")
|
| 176 |
+
_vlm["processor"] = AutoProcessor.from_pretrained(VL_MODEL, trust_remote_code=True)
|
| 177 |
+
_vlm["model"] = model
|
| 178 |
+
print("[vlm] ready", flush=True)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# Load the VLM at import so ZeroGPU can pack the weights (ZeroGPU best practice).
|
| 182 |
+
try:
|
| 183 |
+
_load_vlm()
|
| 184 |
+
except Exception as e: # noqa: BLE001
|
| 185 |
+
_vlm["error"] = str(e)
|
| 186 |
+
print("[vlm] eager load failed, will retry on first use:", e, flush=True)
|
| 187 |
+
|
| 188 |
+
# ---------------------------------------------------------------------------
|
| 189 |
+
# Pages
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _cap(img: Image.Image) -> Image.Image:
|
| 194 |
+
w, h = img.size
|
| 195 |
+
if max(w, h) > MAX_SIDE:
|
| 196 |
+
s = MAX_SIDE / max(w, h)
|
| 197 |
+
img = img.resize((int(w * s), int(h * s)), Image.LANCZOS)
|
| 198 |
+
return img
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def load_pages(path):
|
| 202 |
+
if not path:
|
| 203 |
+
return []
|
| 204 |
+
if os.path.splitext(path)[1].lower() == ".pdf":
|
| 205 |
+
doc = fitz.open(path)
|
| 206 |
+
zoom = RENDER_DPI / 72.0
|
| 207 |
+
pages = []
|
| 208 |
+
for i, page in enumerate(doc):
|
| 209 |
+
if i >= MAX_PAGES:
|
| 210 |
+
break
|
| 211 |
+
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
|
| 212 |
+
pages.append(_cap(Image.open(io.BytesIO(pix.tobytes("png"))).convert("RGB")))
|
| 213 |
+
doc.close()
|
| 214 |
+
return pages
|
| 215 |
+
return [_cap(Image.open(path).convert("RGB"))]
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# ---------------------------------------------------------------------------
|
| 219 |
+
# Engine 1: classic PaddleOCR (CPU)
|
| 220 |
+
# ---------------------------------------------------------------------------
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _result_dict(res):
|
| 224 |
+
"""PaddleOCR 3.x results are dict-like; .json gives plain lists."""
|
| 225 |
+
try:
|
| 226 |
+
j = res.json
|
| 227 |
+
if isinstance(j, dict):
|
| 228 |
+
return j.get("res", j)
|
| 229 |
+
except Exception: # noqa: BLE001
|
| 230 |
+
pass
|
| 231 |
+
return res
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def run_classic(img: Image.Image, size: str):
|
| 235 |
+
"""-> (items, detected_boxes). detected_boxes includes lines the recognizer
|
| 236 |
+
returned empty, so the VL 'same lines' mode sees every detection."""
|
| 237 |
+
engine = _get_classic(size)
|
| 238 |
+
bgr = np.ascontiguousarray(np.array(img.convert("RGB"))[:, :, ::-1]) # Paddle expects BGR
|
| 239 |
+
res = _result_dict(list(engine.predict(bgr))[0])
|
| 240 |
+
texts = list(res.get("rec_texts") or [])
|
| 241 |
+
scores = list(res.get("rec_scores") or [])
|
| 242 |
+
polys = list(res.get("rec_polys") or [])
|
| 243 |
+
items = []
|
| 244 |
+
for i, text in enumerate(texts):
|
| 245 |
+
if i >= len(polys) or not str(text).strip():
|
| 246 |
+
continue
|
| 247 |
+
items.append({
|
| 248 |
+
"text": str(text).strip(),
|
| 249 |
+
"box": quad_to_box(polys[i]),
|
| 250 |
+
"conf": float(scores[i]) * 100 if i < len(scores) else None,
|
| 251 |
+
"label": "text line",
|
| 252 |
+
})
|
| 253 |
+
det = [quad_to_box(p) for p in (res.get("dt_polys") or polys)]
|
| 254 |
+
return reading_order(items), det
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
# ---------------------------------------------------------------------------
|
| 258 |
+
# Engine 2: PaddleOCR-VL (ZeroGPU)
|
| 259 |
+
# ---------------------------------------------------------------------------
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _vlm_one(crop: Image.Image, task: str, max_tokens: int) -> str:
|
| 263 |
+
model, processor = _vlm["model"], _vlm["processor"]
|
| 264 |
+
messages = [{"role": "user", "content": [
|
| 265 |
+
{"type": "image", "image": crop},
|
| 266 |
+
{"type": "text", "text": PROMPTS[task]},
|
| 267 |
+
]}]
|
| 268 |
+
inputs = processor.apply_chat_template(
|
| 269 |
+
messages, tokenize=True, add_generation_prompt=True,
|
| 270 |
+
return_dict=True, return_tensors="pt",
|
| 271 |
+
).to(model.device)
|
| 272 |
+
with torch.inference_mode():
|
| 273 |
+
out = model.generate(**inputs, max_new_tokens=max_tokens,
|
| 274 |
+
do_sample=False, use_cache=True)
|
| 275 |
+
# Decode only the new tokens; otherwise the prompt is echoed back.
|
| 276 |
+
text = processor.batch_decode(out[:, inputs["input_ids"].shape[1]:],
|
| 277 |
+
skip_special_tokens=True)[0]
|
| 278 |
+
for marker in ("Assistant:", "assistant\n", "assistant:"):
|
| 279 |
+
if marker in text:
|
| 280 |
+
text = text.split(marker)[-1]
|
| 281 |
+
return text.strip()
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
@spaces.GPU(duration=GPU_DURATION)
|
| 285 |
+
def _recognize_chunk(img: Image.Image, chunk: list[dict]) -> list[dict]:
|
| 286 |
+
"""One short GPU allocation. Returns the chunk (ZeroGPU runs this in a
|
| 287 |
+
separate process, so in-place mutation would not come back)."""
|
| 288 |
+
_load_vlm()
|
| 289 |
+
W, H = img.size
|
| 290 |
+
for z in chunk:
|
| 291 |
+
x1, y1, x2, y2 = z["box"]
|
| 292 |
+
if z.get("line"):
|
| 293 |
+
# a little vertical padding helps the recognizer on tight line boxes
|
| 294 |
+
pad = max(2, int((y2 - y1) * 0.15))
|
| 295 |
+
y1, y2 = max(0, y1 - pad), min(H, y2 + pad)
|
| 296 |
+
max_tokens = 128
|
| 297 |
+
else:
|
| 298 |
+
max_tokens = MAX_TOKENS.get(z["task"], 1024)
|
| 299 |
+
try:
|
| 300 |
+
z["text"] = _vlm_one(img.crop((x1, y1, x2, y2)), z["task"], max_tokens)
|
| 301 |
+
except Exception as e: # noqa: BLE001
|
| 302 |
+
z["text"] = f"[recognition error: {e}]"
|
| 303 |
+
return chunk
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def _layout_zones(img: Image.Image) -> list[dict]:
|
| 307 |
+
out = _get_layout().predict(np.array(img), batch_size=1)
|
| 308 |
+
res = _result_dict(list(out)[0])
|
| 309 |
+
W, H = img.size
|
| 310 |
+
zones = []
|
| 311 |
+
for b in res.get("boxes", []):
|
| 312 |
+
coord = b.get("coordinate") or b.get("bbox") or b.get("box")
|
| 313 |
+
if coord is None:
|
| 314 |
+
continue
|
| 315 |
+
x1, y1, x2, y2 = [float(v) for v in coord[:4]]
|
| 316 |
+
x1, y1, x2, y2 = max(0, x1), max(0, y1), min(W, x2), min(H, y2)
|
| 317 |
+
if x2 - x1 < 3 or y2 - y1 < 3:
|
| 318 |
+
continue
|
| 319 |
+
label = str(b.get("label", "text")).lower().strip()
|
| 320 |
+
zones.append({"box": [int(x1), int(y1), int(x2), int(y2)], "label": label,
|
| 321 |
+
"task": LABEL_TO_TASK.get(label, "ocr")})
|
| 322 |
+
return reading_order(zones)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def run_vl(img: Image.Image, mode: str, line_boxes: list[list[int]]):
|
| 326 |
+
if mode == VL_SAME_LINES:
|
| 327 |
+
zones = [{"box": b, "label": "text line", "task": "ocr", "line": True}
|
| 328 |
+
for b in line_boxes]
|
| 329 |
+
chunk = CHUNK_LINES
|
| 330 |
+
else:
|
| 331 |
+
zones = _layout_zones(img)
|
| 332 |
+
chunk = CHUNK_ZONES
|
| 333 |
+
zones = reading_order(zones)[:MAX_VL_ITEMS]
|
| 334 |
+
for i in range(0, len(zones), chunk):
|
| 335 |
+
zones[i : i + chunk] = _recognize_chunk(img, zones[i : i + chunk])
|
| 336 |
+
return [{"text": z.get("text", ""), "box": z["box"], "conf": None, "label": z["label"]}
|
| 337 |
+
for z in zones]
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# ---------------------------------------------------------------------------
|
| 341 |
+
# Gradio callbacks
|
| 342 |
+
# ---------------------------------------------------------------------------
|
| 343 |
+
|
| 344 |
+
EMPTY_TABLE = gr.update(value=[], headers=TABLE_HEADERS)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def on_upload(path):
|
| 348 |
+
pages = load_pages(path)
|
| 349 |
+
if not pages:
|
| 350 |
+
return (gr.update(choices=[], value=None), [], placeholder_html("Upload a PDF or image."),
|
| 351 |
+
placeholder_html("Upload a PDF or image."), [], EMPTY_TABLE, "")
|
| 352 |
+
choices = [(f"Page {i + 1}", i) for i in range(len(pages))]
|
| 353 |
+
msg = f"Loaded {len(pages)} page(s). Pick a page and click **Run both**."
|
| 354 |
+
ready = placeholder_html("Click “Run both” to OCR this page.")
|
| 355 |
+
return (gr.update(choices=choices, value=0), pages, ready, ready, [], EMPTY_TABLE, msg)
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def load_sample():
|
| 359 |
+
return on_upload(SAMPLE_PATH if os.path.exists(SAMPLE_PATH) else None)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def _summary(t_classic, n_classic, t_vl, n_vl, stats, vl_mode, note=""):
|
| 363 |
+
lines = [f"**PaddleOCR (classic):** {n_classic} lines in {t_classic:.1f}s"]
|
| 364 |
+
if t_vl is not None:
|
| 365 |
+
mode = "same lines" if vl_mode == VL_SAME_LINES else "layout zones"
|
| 366 |
+
lines.append(f"**PaddleOCR-VL:** {n_vl} boxes ({mode}) in {t_vl:.1f}s")
|
| 367 |
+
if stats:
|
| 368 |
+
c = stats["counts"]
|
| 369 |
+
mean = f"{stats['mean_ratio'] * 100:.1f}%" if stats["mean_ratio"] is not None else "—"
|
| 370 |
+
lines.append(
|
| 371 |
+
f"**Agreement:** {c['same']} same · {c['minor']} minor · {c['differs']} differ · "
|
| 372 |
+
f"{c['only-classic']} only classic · {c['only-vl']} only VL "
|
| 373 |
+
f"(mean similarity {mean})"
|
| 374 |
+
)
|
| 375 |
+
if note:
|
| 376 |
+
lines.append(note)
|
| 377 |
+
return " \n".join(lines)
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def run_both(page_idx, pages, classic_size, vl_mode, use_vl, only_diff):
|
| 381 |
+
if not pages:
|
| 382 |
+
raise gr.Error("Upload a document (or click “Load sample”) first.")
|
| 383 |
+
img = pages[int(page_idx or 0)]
|
| 384 |
+
uri = image_to_data_uri(img) # encode once, reuse for both panels
|
| 385 |
+
waiting = placeholder_html("Waiting for classic PaddleOCR…")
|
| 386 |
+
|
| 387 |
+
yield (placeholder_html("Running classic PaddleOCR on CPU…"), waiting, [],
|
| 388 |
+
EMPTY_TABLE, "Running classic PaddleOCR…")
|
| 389 |
+
|
| 390 |
+
t0 = time.perf_counter()
|
| 391 |
+
try:
|
| 392 |
+
classic, det_boxes = run_classic(img, classic_size)
|
| 393 |
+
except Exception as e: # noqa: BLE001
|
| 394 |
+
traceback.print_exc()
|
| 395 |
+
raise gr.Error(f"Classic PaddleOCR failed: {e}")
|
| 396 |
+
t_classic = time.perf_counter() - t0
|
| 397 |
+
classic_html = overlay_html(img, classic, "classic", uri)
|
| 398 |
+
|
| 399 |
+
if not use_vl:
|
| 400 |
+
yield (classic_html, placeholder_html("PaddleOCR-VL is switched off."), [],
|
| 401 |
+
EMPTY_TABLE, _summary(t_classic, len(classic), None, 0, None, vl_mode))
|
| 402 |
+
return
|
| 403 |
+
|
| 404 |
+
if vl_mode == VL_SAME_LINES and not det_boxes:
|
| 405 |
+
yield (classic_html, placeholder_html("Classic found no text lines to re-read."),
|
| 406 |
+
[], EMPTY_TABLE, _summary(t_classic, len(classic), None, 0, None, vl_mode))
|
| 407 |
+
return
|
| 408 |
+
|
| 409 |
+
yield (classic_html, placeholder_html("Running PaddleOCR-VL on ZeroGPU…"), [],
|
| 410 |
+
EMPTY_TABLE,
|
| 411 |
+
_summary(t_classic, len(classic), None, 0, None, vl_mode) + " \nRunning PaddleOCR-VL…")
|
| 412 |
+
|
| 413 |
+
t0 = time.perf_counter()
|
| 414 |
+
try:
|
| 415 |
+
vl = run_vl(img, vl_mode, det_boxes)
|
| 416 |
+
except Exception as e: # noqa: BLE001
|
| 417 |
+
traceback.print_exc()
|
| 418 |
+
yield (classic_html, placeholder_html(f"PaddleOCR-VL failed: {e}"), [], EMPTY_TABLE,
|
| 419 |
+
_summary(t_classic, len(classic), None, 0, None, vl_mode))
|
| 420 |
+
return
|
| 421 |
+
t_vl = time.perf_counter() - t0
|
| 422 |
+
|
| 423 |
+
rows, stats = compare(classic, vl) # also tags boxes with their diff row
|
| 424 |
+
note = ""
|
| 425 |
+
if vl_mode == VL_SAME_LINES and len(det_boxes) > MAX_VL_ITEMS:
|
| 426 |
+
note = f"_VL re-read the first {MAX_VL_ITEMS} of {len(det_boxes)} lines (GPU budget)._"
|
| 427 |
+
yield (
|
| 428 |
+
overlay_html(img, classic, "classic", uri),
|
| 429 |
+
overlay_html(img, vl, "vl", uri),
|
| 430 |
+
rows,
|
| 431 |
+
gr.update(value=table_rows(rows, only_diff), headers=TABLE_HEADERS),
|
| 432 |
+
_summary(t_classic, len(classic), t_vl, len(vl), stats, vl_mode, note),
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def on_filter(rows, only_diff):
|
| 437 |
+
return gr.update(value=table_rows(rows or [], only_diff), headers=TABLE_HEADERS)
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
# ---------------------------------------------------------------------------
|
| 441 |
+
# UI
|
| 442 |
+
# ---------------------------------------------------------------------------
|
| 443 |
+
|
| 444 |
+
with gr.Blocks(title="OCR Compare", theme=gr.themes.Soft(), css=CSS, head=HEAD_JS) as demo:
|
| 445 |
+
pages_state = gr.State([])
|
| 446 |
+
rows_state = gr.State([])
|
| 447 |
+
|
| 448 |
+
gr.Markdown(
|
| 449 |
+
"# 🔍 OCR Compare — PaddleOCR vs PaddleOCR-VL\n"
|
| 450 |
+
"Upload a scan, run both engines, and compare them side by side. "
|
| 451 |
+
"**Every word or line is an invisible box — click one to see and edit its text.** "
|
| 452 |
+
"The other engine's reading for the same spot is shown under the editor."
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
with gr.Row():
|
| 456 |
+
file_in = gr.File(label="PDF or image", type="filepath",
|
| 457 |
+
file_types=[".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff"],
|
| 458 |
+
scale=3)
|
| 459 |
+
with gr.Column(scale=2):
|
| 460 |
+
page_sel = gr.Dropdown(label="Page", choices=[], interactive=True)
|
| 461 |
+
with gr.Row():
|
| 462 |
+
sample_btn = gr.Button("Load sample")
|
| 463 |
+
run_btn = gr.Button("Run both", variant="primary")
|
| 464 |
+
|
| 465 |
+
with gr.Accordion("Options", open=False):
|
| 466 |
+
with gr.Row():
|
| 467 |
+
classic_size = gr.Radio(list(CLASSIC_MODELS), value="mobile (fast)",
|
| 468 |
+
label="Classic PaddleOCR models")
|
| 469 |
+
vl_mode = gr.Radio([VL_SAME_LINES, VL_LAYOUT], value=VL_SAME_LINES,
|
| 470 |
+
label="PaddleOCR-VL boxes",
|
| 471 |
+
info="Same lines isolates recognition quality; layout "
|
| 472 |
+
"zones shows how VL would read the page on its own.")
|
| 473 |
+
use_vl = gr.Checkbox(value=True, label="Run PaddleOCR-VL (uses ZeroGPU quota)")
|
| 474 |
+
|
| 475 |
+
with gr.Row():
|
| 476 |
+
show_boxes = gr.Checkbox(value=False, label="Show all boxes (colored by agreement)")
|
| 477 |
+
show_text = gr.Checkbox(value=False, label="Show recognized text on the page")
|
| 478 |
+
|
| 479 |
+
status = gr.Markdown("Upload a PDF or image, or click **Load sample**.")
|
| 480 |
+
|
| 481 |
+
with gr.Row(equal_height=False):
|
| 482 |
+
with gr.Column():
|
| 483 |
+
gr.Markdown("### PaddleOCR (classic)")
|
| 484 |
+
classic_panel = gr.HTML(placeholder_html("No page yet."), padding=False)
|
| 485 |
+
with gr.Column():
|
| 486 |
+
gr.Markdown("### PaddleOCR-VL")
|
| 487 |
+
vl_panel = gr.HTML(placeholder_html("No page yet."), padding=False)
|
| 488 |
+
|
| 489 |
+
gr.Markdown(
|
| 490 |
+
"### Differences\n"
|
| 491 |
+
"Green = identical · yellow = minor · red = different · blue/purple = found by "
|
| 492 |
+
"one engine only. Row numbers match the “diff row” shown when you click a box."
|
| 493 |
+
)
|
| 494 |
+
only_diff = gr.Checkbox(value=True, label="Only show rows that differ")
|
| 495 |
+
diff_table = gr.Dataframe(headers=TABLE_HEADERS, value=[], wrap=True, interactive=False,
|
| 496 |
+
column_widths=["6%", "14%", "8%", "36%", "36%"])
|
| 497 |
+
|
| 498 |
+
# --- wiring ---
|
| 499 |
+
upload_out = [page_sel, pages_state, classic_panel, vl_panel, rows_state, diff_table, status]
|
| 500 |
+
file_in.change(on_upload, inputs=file_in, outputs=upload_out)
|
| 501 |
+
sample_btn.click(load_sample, inputs=None, outputs=upload_out)
|
| 502 |
+
|
| 503 |
+
def _page_changed(_):
|
| 504 |
+
ready = placeholder_html("Click “Run both” to OCR this page.")
|
| 505 |
+
return ready, ready, [], EMPTY_TABLE
|
| 506 |
+
|
| 507 |
+
page_sel.change(_page_changed, inputs=page_sel,
|
| 508 |
+
outputs=[classic_panel, vl_panel, rows_state, diff_table])
|
| 509 |
+
run_btn.click(
|
| 510 |
+
run_both,
|
| 511 |
+
inputs=[page_sel, pages_state, classic_size, vl_mode, use_vl, only_diff],
|
| 512 |
+
outputs=[classic_panel, vl_panel, rows_state, diff_table, status],
|
| 513 |
+
)
|
| 514 |
+
only_diff.change(on_filter, inputs=[rows_state, only_diff], outputs=diff_table)
|
| 515 |
+
|
| 516 |
+
# Display toggles are pure CSS classes on <body>; no server round trip.
|
| 517 |
+
show_boxes.change(None, inputs=show_boxes, outputs=None,
|
| 518 |
+
js="(v) => { document.body.classList.toggle('ocrc-show-boxes', v); }")
|
| 519 |
+
show_text.change(None, inputs=show_text, outputs=None,
|
| 520 |
+
js="(v) => { document.body.classList.toggle('ocrc-show-text', v); }")
|
| 521 |
+
|
| 522 |
+
demo.queue(default_concurrency_limit=1, max_size=16)
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
if __name__ == "__main__":
|
| 526 |
+
demo.launch()
|
examples/sample.png
ADDED
|
Git LFS Details
|
overlay.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Invisible click-to-edit boxes + engine comparison.
|
| 2 |
+
|
| 3 |
+
Pure Python (no ML imports), so it can be tested without the models.
|
| 4 |
+
|
| 5 |
+
Every OCR engine is normalised to the same item shape:
|
| 6 |
+
|
| 7 |
+
{"text": str, "box": [x1, y1, x2, y2], "conf": float | None, "label": str}
|
| 8 |
+
|
| 9 |
+
where the box is in page-image pixels. From that one shape we build:
|
| 10 |
+
|
| 11 |
+
* overlay_html() -- the page image with one invisible <div> per item,
|
| 12 |
+
positioned in percentages so it stays aligned at any size;
|
| 13 |
+
* compare() -- which classic lines fall inside which VL box, and how
|
| 14 |
+
similar their text is.
|
| 15 |
+
|
| 16 |
+
Gradio has no "clickable box over a word" component, so the boxes are plain
|
| 17 |
+
HTML and the click-to-edit behaviour is a small script (HEAD_JS) injected into
|
| 18 |
+
the page head: scripts placed inside gr.HTML are not executed.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import base64
|
| 24 |
+
import html
|
| 25 |
+
import io
|
| 26 |
+
import re
|
| 27 |
+
import unicodedata
|
| 28 |
+
from difflib import SequenceMatcher
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# Normalisation
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def quad_to_box(poly) -> list[int]:
|
| 36 |
+
"""Any polygon (4 corner points, or a flat list) -> tight upright [x1,y1,x2,y2]."""
|
| 37 |
+
pts = poly.tolist() if hasattr(poly, "tolist") else list(poly)
|
| 38 |
+
if pts and not isinstance(pts[0], (list, tuple)):
|
| 39 |
+
# flat [x1, y1, x2, y2, ...]
|
| 40 |
+
pts = [pts[i : i + 2] for i in range(0, len(pts) - 1, 2)]
|
| 41 |
+
xs = [float(p[0]) for p in pts]
|
| 42 |
+
ys = [float(p[1]) for p in pts]
|
| 43 |
+
return [int(min(xs)), int(min(ys)), int(round(max(xs))), int(round(max(ys)))]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def reading_order(items: list[dict]) -> list[dict]:
|
| 47 |
+
return sorted(items, key=lambda it: (it["box"][1], it["box"][0]))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
_TAG = re.compile(r"<[^>]+>")
|
| 51 |
+
_WS = re.compile(r"\s+")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def norm_text(text: str) -> str:
|
| 55 |
+
"""Text as compared: tags stripped (VL tables are HTML), case and spacing folded."""
|
| 56 |
+
t = unicodedata.normalize("NFKC", text or "")
|
| 57 |
+
t = _TAG.sub(" ", t)
|
| 58 |
+
return _WS.sub(" ", t).strip().lower()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _squash(text: str) -> str:
|
| 62 |
+
return norm_text(text).replace(" ", "")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# Comparison
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
STATUS_LABEL = {
|
| 70 |
+
"same": "✅ same",
|
| 71 |
+
"minor": "🟡 minor",
|
| 72 |
+
"differs": "🔴 differs",
|
| 73 |
+
"only-classic": "🔵 only classic",
|
| 74 |
+
"only-vl": "🟣 only VL",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _contains(box, x, y, pad=2):
|
| 79 |
+
return box[0] - pad <= x <= box[2] + pad and box[1] - pad <= y <= box[3] + pad
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _area(box):
|
| 83 |
+
return max(1, box[2] - box[0]) * max(1, box[3] - box[1])
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def compare(classic: list[dict], vl: list[dict]) -> tuple[list[dict], dict]:
|
| 87 |
+
"""Match classic lines to VL boxes and score the text difference.
|
| 88 |
+
|
| 89 |
+
Each classic line is assigned to the smallest VL box containing its centre.
|
| 90 |
+
That works for both VL modes: with "same lines" every VL box *is* a classic
|
| 91 |
+
line; with "layout zones" a VL region collects the several lines inside it,
|
| 92 |
+
and their text is joined in reading order before comparing.
|
| 93 |
+
|
| 94 |
+
Mutates the items in place, setting ``grp`` (shared by matched boxes across
|
| 95 |
+
both panels), ``row`` (the diff-table row) and ``status``. Returns
|
| 96 |
+
(rows, stats); rows are dicts ready for the table.
|
| 97 |
+
"""
|
| 98 |
+
for it in classic + vl:
|
| 99 |
+
it.pop("grp", None)
|
| 100 |
+
it.pop("row", None)
|
| 101 |
+
it["status"] = ""
|
| 102 |
+
|
| 103 |
+
members: dict[int, list[int]] = {vi: [] for vi in range(len(vl))}
|
| 104 |
+
unassigned: list[int] = []
|
| 105 |
+
for ci, c in enumerate(classic):
|
| 106 |
+
x = (c["box"][0] + c["box"][2]) / 2
|
| 107 |
+
y = (c["box"][1] + c["box"][3]) / 2
|
| 108 |
+
best, best_area = None, None
|
| 109 |
+
for vi, v in enumerate(vl):
|
| 110 |
+
if _contains(v["box"], x, y):
|
| 111 |
+
a = _area(v["box"])
|
| 112 |
+
if best is None or a < best_area:
|
| 113 |
+
best, best_area = vi, a
|
| 114 |
+
if best is None:
|
| 115 |
+
unassigned.append(ci)
|
| 116 |
+
else:
|
| 117 |
+
members[best].append(ci)
|
| 118 |
+
|
| 119 |
+
raw_rows = []
|
| 120 |
+
for vi, v in enumerate(vl):
|
| 121 |
+
cis = sorted(members[vi], key=lambda i: (classic[i]["box"][1], classic[i]["box"][0]))
|
| 122 |
+
c_text = " ".join(classic[i]["text"] for i in cis).strip()
|
| 123 |
+
v_text = (v.get("text") or "").strip()
|
| 124 |
+
if not c_text and not v_text:
|
| 125 |
+
continue
|
| 126 |
+
if not cis:
|
| 127 |
+
status, ratio = "only-vl", 0.0
|
| 128 |
+
elif not v_text:
|
| 129 |
+
status, ratio = "only-classic", 0.0
|
| 130 |
+
else:
|
| 131 |
+
ratio = SequenceMatcher(None, norm_text(c_text), norm_text(v_text)).ratio()
|
| 132 |
+
if _squash(c_text) == _squash(v_text):
|
| 133 |
+
status, ratio = "same", 1.0
|
| 134 |
+
elif ratio >= 0.9:
|
| 135 |
+
status = "minor"
|
| 136 |
+
else:
|
| 137 |
+
status = "differs"
|
| 138 |
+
raw_rows.append(
|
| 139 |
+
{"y": v["box"][1], "x": v["box"][0], "classic": c_text, "vl": v_text,
|
| 140 |
+
"ratio": ratio, "status": status, "vl_idx": [vi], "classic_idx": cis}
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
for ci in unassigned:
|
| 144 |
+
c = classic[ci]
|
| 145 |
+
raw_rows.append(
|
| 146 |
+
{"y": c["box"][1], "x": c["box"][0], "classic": c["text"], "vl": "",
|
| 147 |
+
"ratio": 0.0, "status": "only-classic", "vl_idx": [], "classic_idx": [ci]}
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
raw_rows.sort(key=lambda r: (r["y"], r["x"]))
|
| 151 |
+
rows = []
|
| 152 |
+
for n, r in enumerate(raw_rows, start=1):
|
| 153 |
+
grp = f"g{n}"
|
| 154 |
+
for i in r["vl_idx"]:
|
| 155 |
+
vl[i].update(grp=grp, row=n, status=r["status"])
|
| 156 |
+
for i in r["classic_idx"]:
|
| 157 |
+
classic[i].update(grp=grp, row=n, status=r["status"])
|
| 158 |
+
rows.append({"n": n, **{k: r[k] for k in ("classic", "vl", "ratio", "status")}})
|
| 159 |
+
|
| 160 |
+
counts = {k: 0 for k in STATUS_LABEL}
|
| 161 |
+
for r in rows:
|
| 162 |
+
counts[r["status"]] += 1
|
| 163 |
+
both = [r for r in rows if r["status"] in ("same", "minor", "differs")]
|
| 164 |
+
stats = {
|
| 165 |
+
"rows": len(rows),
|
| 166 |
+
"counts": counts,
|
| 167 |
+
"compared": len(both),
|
| 168 |
+
"mean_ratio": (sum(r["ratio"] for r in both) / len(both)) if both else None,
|
| 169 |
+
}
|
| 170 |
+
return rows, stats
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def table_rows(rows: list[dict], only_differences: bool = False) -> list[list]:
|
| 174 |
+
out = []
|
| 175 |
+
for r in rows:
|
| 176 |
+
if only_differences and r["status"] == "same":
|
| 177 |
+
continue
|
| 178 |
+
match = f"{r['ratio'] * 100:.0f}%" if r["status"] in ("same", "minor", "differs") else "—"
|
| 179 |
+
out.append([r["n"], STATUS_LABEL[r["status"]], match, r["classic"], r["vl"]])
|
| 180 |
+
return out
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
TABLE_HEADERS = ["#", "Status", "Match", "PaddleOCR (classic)", "PaddleOCR-VL"]
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ---------------------------------------------------------------------------
|
| 187 |
+
# Overlay HTML
|
| 188 |
+
# ---------------------------------------------------------------------------
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def image_to_data_uri(img, max_side: int = 1600, quality: int = 85) -> str:
|
| 192 |
+
"""Embed the page as a JPEG data URI (only the display copy is downscaled;
|
| 193 |
+
boxes are percentages, so they stay aligned regardless)."""
|
| 194 |
+
im = img.convert("RGB")
|
| 195 |
+
w, h = im.size
|
| 196 |
+
if max(w, h) > max_side:
|
| 197 |
+
s = max_side / max(w, h)
|
| 198 |
+
im = im.resize((int(w * s), int(h * s)))
|
| 199 |
+
buf = io.BytesIO()
|
| 200 |
+
im.save(buf, format="JPEG", quality=quality)
|
| 201 |
+
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def overlay_html(img, items: list[dict], engine: str, data_uri: str | None = None) -> str:
|
| 205 |
+
"""The page image with one invisible, clickable box per item."""
|
| 206 |
+
W, H = img.size
|
| 207 |
+
uri = data_uri or image_to_data_uri(img)
|
| 208 |
+
boxes = []
|
| 209 |
+
for i, it in enumerate(items):
|
| 210 |
+
x1, y1, x2, y2 = it["box"]
|
| 211 |
+
x1, y1 = max(0, x1), max(0, y1)
|
| 212 |
+
x2, y2 = min(W, x2), min(H, y2)
|
| 213 |
+
if x2 <= x1 or y2 <= y1:
|
| 214 |
+
continue
|
| 215 |
+
left, top = 100 * x1 / W, 100 * y1 / H
|
| 216 |
+
width, height = 100 * (x2 - x1) / W, 100 * (y2 - y1) / H
|
| 217 |
+
# Font size in container-width units (the page div is an inline-size
|
| 218 |
+
# container), so the optional text layer scales with the image.
|
| 219 |
+
font = 100 * (y2 - y1) / W * 0.72
|
| 220 |
+
text = it.get("text") or ""
|
| 221 |
+
conf = it.get("conf")
|
| 222 |
+
attrs = {
|
| 223 |
+
"data-engine": engine,
|
| 224 |
+
"data-id": str(i),
|
| 225 |
+
"data-grp": it.get("grp", ""),
|
| 226 |
+
"data-row": str(it.get("row", "")),
|
| 227 |
+
"data-status": it.get("status", ""),
|
| 228 |
+
"data-label": it.get("label", ""),
|
| 229 |
+
"data-conf": "" if conf is None else f"{conf:.0f}",
|
| 230 |
+
"data-orig": text,
|
| 231 |
+
"data-text": text,
|
| 232 |
+
"title": text,
|
| 233 |
+
}
|
| 234 |
+
attr_s = " ".join(f'{k}="{html.escape(v, quote=True)}"' for k, v in attrs.items())
|
| 235 |
+
boxes.append(
|
| 236 |
+
f'<div class="ocrc-box" {attr_s} style="left:{left:.3f}%;top:{top:.3f}%;'
|
| 237 |
+
f'width:{width:.3f}%;height:{height:.3f}%;font-size:{font:.3f}cqw"></div>'
|
| 238 |
+
)
|
| 239 |
+
return (
|
| 240 |
+
f'<div class="ocrc-page" data-engine="{engine}">'
|
| 241 |
+
f'<img src="{uri}" alt="page" draggable="false">{"".join(boxes)}</div>'
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def placeholder_html(message: str) -> str:
|
| 246 |
+
return f'<div class="ocrc-empty">{html.escape(message)}</div>'
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# ---------------------------------------------------------------------------
|
| 250 |
+
# CSS + click-to-edit script
|
| 251 |
+
# ---------------------------------------------------------------------------
|
| 252 |
+
|
| 253 |
+
CSS = """
|
| 254 |
+
.ocrc-page{position:relative;width:100%;container-type:inline-size;line-height:0;
|
| 255 |
+
user-select:none;border:1px solid var(--border-color-primary);border-radius:6px;overflow:hidden}
|
| 256 |
+
.ocrc-page img{width:100%;height:auto;display:block}
|
| 257 |
+
.ocrc-empty{padding:48px 16px;text-align:center;color:var(--body-text-color-subdued);
|
| 258 |
+
border:1px dashed var(--border-color-primary);border-radius:6px}
|
| 259 |
+
.ocrc-box{position:absolute;cursor:pointer;box-sizing:border-box;border-radius:2px;
|
| 260 |
+
min-width:4px;min-height:4px;line-height:1}
|
| 261 |
+
.ocrc-box:hover{outline:2px solid #2563eb;background:rgba(37,99,235,.10)}
|
| 262 |
+
.ocrc-box.active{outline:2px solid #2563eb;background:rgba(37,99,235,.18)}
|
| 263 |
+
.ocrc-box.edited{outline:2px dashed #16a34a}
|
| 264 |
+
body.ocrc-show-boxes .ocrc-box{outline:1px solid #94a3b8}
|
| 265 |
+
body.ocrc-show-boxes .ocrc-box[data-status=same]{outline-color:#16a34a}
|
| 266 |
+
body.ocrc-show-boxes .ocrc-box[data-status=minor]{outline-color:#ca8a04}
|
| 267 |
+
body.ocrc-show-boxes .ocrc-box[data-status=differs]{outline:2px solid #dc2626;background:rgba(220,38,38,.08)}
|
| 268 |
+
body.ocrc-show-boxes .ocrc-box[data-status=only-classic]{outline:2px solid #2563eb}
|
| 269 |
+
body.ocrc-show-boxes .ocrc-box[data-status=only-vl]{outline:2px solid #9333ea}
|
| 270 |
+
body.ocrc-show-text .ocrc-box::after{content:attr(data-text);position:absolute;inset:0;
|
| 271 |
+
background:rgba(255,255,255,.92);color:#111;font-family:Arial,sans-serif;
|
| 272 |
+
white-space:nowrap;overflow:hidden;padding:0 1px}
|
| 273 |
+
.ocrc-editor{position:fixed;z-index:1000;width:340px;max-width:calc(100vw - 16px);
|
| 274 |
+
background:var(--background-fill-primary,#fff);color:var(--body-text-color,#111);
|
| 275 |
+
border:1px solid var(--border-color-primary,#ccc);border-radius:8px;
|
| 276 |
+
box-shadow:0 8px 28px rgba(0,0,0,.22);padding:10px 12px;font-size:13px;line-height:1.4}
|
| 277 |
+
.ocrc-editor[hidden]{display:none}
|
| 278 |
+
.ocrc-ed-head{font-weight:600;margin-bottom:4px}
|
| 279 |
+
.ocrc-ed-meta{color:var(--body-text-color-subdued,#666);font-size:12px;margin-bottom:6px}
|
| 280 |
+
.ocrc-editor textarea{width:100%;box-sizing:border-box;font:inherit;padding:6px;
|
| 281 |
+
border:1px solid var(--border-color-primary,#ccc);border-radius:6px;
|
| 282 |
+
background:var(--input-background-fill,#fff);color:inherit;resize:vertical}
|
| 283 |
+
.ocrc-ed-other{margin-top:6px;font-size:12px;color:var(--body-text-color-subdued,#666)}
|
| 284 |
+
.ocrc-ed-other b{color:var(--body-text-color,#111);font-weight:600}
|
| 285 |
+
.ocrc-ed-btns{display:flex;gap:6px;margin-top:8px;justify-content:flex-end}
|
| 286 |
+
.ocrc-ed-btns button{font:inherit;padding:4px 10px;border-radius:6px;cursor:pointer;
|
| 287 |
+
border:1px solid var(--border-color-primary,#ccc);background:transparent;color:inherit}
|
| 288 |
+
.ocrc-ed-btns button[data-act=save]{background:#2563eb;border-color:#2563eb;color:#fff}
|
| 289 |
+
"""
|
| 290 |
+
|
| 291 |
+
HEAD_JS = r"""
|
| 292 |
+
<script>
|
| 293 |
+
(function () {
|
| 294 |
+
if (window.__ocrcInit) return;
|
| 295 |
+
window.__ocrcInit = true;
|
| 296 |
+
|
| 297 |
+
var NAMES = { classic: "PaddleOCR (classic)", vl: "PaddleOCR-VL" };
|
| 298 |
+
var ed = null, cur = null;
|
| 299 |
+
|
| 300 |
+
function editor() {
|
| 301 |
+
if (ed) return ed;
|
| 302 |
+
ed = document.createElement("div");
|
| 303 |
+
ed.className = "ocrc-editor";
|
| 304 |
+
ed.hidden = true;
|
| 305 |
+
ed.innerHTML =
|
| 306 |
+
'<div class="ocrc-ed-head"></div><div class="ocrc-ed-meta"></div>' +
|
| 307 |
+
'<textarea rows="3" spellcheck="false"></textarea>' +
|
| 308 |
+
'<div class="ocrc-ed-other"></div>' +
|
| 309 |
+
'<div class="ocrc-ed-btns"><button data-act="reset">Reset</button>' +
|
| 310 |
+
'<button data-act="close">Close</button><button data-act="save">Save</button></div>';
|
| 311 |
+
document.body.appendChild(ed);
|
| 312 |
+
ed.addEventListener("click", function (ev) {
|
| 313 |
+
var act = ev.target.getAttribute && ev.target.getAttribute("data-act");
|
| 314 |
+
if (!act) return;
|
| 315 |
+
if (act === "save") save();
|
| 316 |
+
else if (act === "reset") { ed.querySelector("textarea").value = cur.getAttribute("data-orig"); save(); }
|
| 317 |
+
else hide();
|
| 318 |
+
});
|
| 319 |
+
ed.querySelector("textarea").addEventListener("keydown", function (ev) {
|
| 320 |
+
if (ev.key === "Enter" && !ev.shiftKey) { ev.preventDefault(); save(); }
|
| 321 |
+
else if (ev.key === "Escape") { hide(); }
|
| 322 |
+
});
|
| 323 |
+
return ed;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
function clearActive() {
|
| 327 |
+
document.querySelectorAll(".ocrc-box.active").forEach(function (b) { b.classList.remove("active"); });
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
function hide() {
|
| 331 |
+
if (ed) ed.hidden = true;
|
| 332 |
+
clearActive();
|
| 333 |
+
cur = null;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
function save() {
|
| 337 |
+
if (!cur) return;
|
| 338 |
+
var v = ed.querySelector("textarea").value;
|
| 339 |
+
cur.setAttribute("data-text", v);
|
| 340 |
+
cur.setAttribute("title", v);
|
| 341 |
+
cur.classList.toggle("edited", v !== cur.getAttribute("data-orig"));
|
| 342 |
+
hide();
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
function open(box) {
|
| 346 |
+
var e = editor();
|
| 347 |
+
cur = box;
|
| 348 |
+
var engine = box.getAttribute("data-engine");
|
| 349 |
+
var row = box.getAttribute("data-row");
|
| 350 |
+
var conf = box.getAttribute("data-conf");
|
| 351 |
+
var label = box.getAttribute("data-label");
|
| 352 |
+
e.querySelector(".ocrc-ed-head").textContent = NAMES[engine] || engine;
|
| 353 |
+
var meta = [];
|
| 354 |
+
if (row) meta.push("diff row #" + row);
|
| 355 |
+
if (label) meta.push(label);
|
| 356 |
+
if (conf) meta.push("confidence " + conf + "%");
|
| 357 |
+
e.querySelector(".ocrc-ed-meta").textContent = meta.join(" · ");
|
| 358 |
+
e.querySelector("textarea").value = box.getAttribute("data-text");
|
| 359 |
+
|
| 360 |
+
// What did the other engine read in the same place?
|
| 361 |
+
var other = e.querySelector(".ocrc-ed-other");
|
| 362 |
+
var grp = box.getAttribute("data-grp");
|
| 363 |
+
other.textContent = "";
|
| 364 |
+
if (grp) {
|
| 365 |
+
var texts = [];
|
| 366 |
+
document.querySelectorAll('.ocrc-box[data-grp="' + grp + '"]').forEach(function (b) {
|
| 367 |
+
if (b.getAttribute("data-engine") !== engine) texts.push(b.getAttribute("data-text"));
|
| 368 |
+
});
|
| 369 |
+
var otherName = NAMES[engine === "vl" ? "classic" : "vl"];
|
| 370 |
+
var label2 = document.createElement("b");
|
| 371 |
+
label2.textContent = otherName + ": ";
|
| 372 |
+
other.appendChild(label2);
|
| 373 |
+
other.appendChild(document.createTextNode(texts.length ? texts.join(" ") : "(nothing here)"));
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
var r = box.getBoundingClientRect();
|
| 377 |
+
e.hidden = false;
|
| 378 |
+
var w = e.offsetWidth, h = e.offsetHeight;
|
| 379 |
+
var left = Math.min(Math.max(8, r.left), window.innerWidth - w - 8);
|
| 380 |
+
var top = r.bottom + 6;
|
| 381 |
+
if (top + h > window.innerHeight - 8) top = Math.max(8, r.top - h - 6);
|
| 382 |
+
e.style.left = left + "px";
|
| 383 |
+
e.style.top = top + "px";
|
| 384 |
+
var ta = e.querySelector("textarea");
|
| 385 |
+
ta.focus();
|
| 386 |
+
ta.select();
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
document.addEventListener("click", function (ev) {
|
| 390 |
+
if (ed && ed.contains(ev.target)) return;
|
| 391 |
+
var box = ev.target.closest && ev.target.closest(".ocrc-box");
|
| 392 |
+
if (!box) { if (ed && !ed.hidden) hide(); return; }
|
| 393 |
+
clearActive();
|
| 394 |
+
var grp = box.getAttribute("data-grp");
|
| 395 |
+
if (grp) {
|
| 396 |
+
document.querySelectorAll('.ocrc-box[data-grp="' + grp + '"]').forEach(function (b) { b.classList.add("active"); });
|
| 397 |
+
}
|
| 398 |
+
box.classList.add("active");
|
| 399 |
+
open(box);
|
| 400 |
+
});
|
| 401 |
+
|
| 402 |
+
window.addEventListener("scroll", function () { if (ed && !ed.hidden) hide(); }, true);
|
| 403 |
+
})();
|
| 404 |
+
</script>
|
| 405 |
+
"""
|
requirements.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Same pins as the PaddleOCR-VL-Doc-Editor Space, where they are known to work
|
| 2 |
+
# together on ZeroGPU. Do not bump them one at a time.
|
| 3 |
+
|
| 4 |
+
# --- PaddleOCR-VL recognition (PyTorch, ZeroGPU) ---
|
| 5 |
+
torch==2.10.0
|
| 6 |
+
torchvision==0.25.0
|
| 7 |
+
# The model's remote code was written for transformers 4.55.0; 4.57 renamed a
|
| 8 |
+
# kwarg it passes and 5.x removed a helper it imports. app.py shims the rename.
|
| 9 |
+
transformers==4.55.0
|
| 10 |
+
accelerate>=1.5.0
|
| 11 |
+
safetensors>=0.5.3
|
| 12 |
+
sentencepiece>=0.2.0
|
| 13 |
+
einops>=0.8.1
|
| 14 |
+
|
| 15 |
+
# --- Classic PaddleOCR + layout detection (PaddlePaddle, CPU) ---
|
| 16 |
+
# Plain paddleocr, NOT the [doc-parser] extra: that extra pulls a conflicting
|
| 17 |
+
# transformers.
|
| 18 |
+
paddlepaddle==3.2.1
|
| 19 |
+
paddleocr>=3.4.0
|
| 20 |
+
|
| 21 |
+
# --- PDF rendering ---
|
| 22 |
+
PyMuPDF>=1.24.0
|