vivekreddyr commited on
Commit
0ad539c
·
verified ·
1 Parent(s): f0e4752

OCR Compare: PaddleOCR vs PaddleOCR-VL with click-to-edit boxes

Browse files
Files changed (6) hide show
  1. .gitattributes +1 -0
  2. README.md +44 -6
  3. app.py +526 -0
  4. examples/sample.png +3 -0
  5. overlay.py +405 -0
  6. 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: gray
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.28.0
8
- python_version: '3.12'
9
  app_file: app.py
 
 
 
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: f2881cb90a65311bc11f3666ebd41662a62cff71164e126c9c931efcd55a478f
  • Pointer size: 132 Bytes
  • Size of remote file: 2.33 MB
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