himipo commited on
Commit
78f1e89
·
verified ·
1 Parent(s): d8ea607

Deploy OCR Model Workbench

Browse files
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .venv/
5
+ .env
6
+ *.zip
7
+ .DS_Store
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,13 +1,34 @@
1
  ---
2
- title: Ocr Workbench Zerogpu
3
- emoji: 💻
4
  colorFrom: indigo
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
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 Model Workbench
3
+ emoji: 🔬
4
  colorFrom: indigo
5
+ colorTo: blue
6
  sdk: gradio
 
 
7
  app_file: app.py
8
+ python_version: "3.12"
9
+ app_port: 7860
10
  pinned: false
11
  ---
12
 
13
+ # OCR Model Workbench Gateway
14
+
15
+ This Space provides the shared Gradio UI. It does not load OCR weights itself; it sends page images to model-specific worker Spaces.
16
+
17
+ Set these Space Variables:
18
+
19
+ - `PADDLEOCR_VL_WORKER_URL`
20
+ - `QIANFAN_WORKER_URL`
21
+ - `UNLIMITED_WORKER_URL`
22
+ - `PPOCRV6_WORKER_URL`
23
+
24
+ Optional secrets:
25
+
26
+ - `HF_TOKEN`: needed when worker Spaces are private.
27
+ - `WORKER_API_TOKEN`: shared token checked by workers through `X-OCR-Worker-Token`.
28
+
29
+ Optional limits:
30
+
31
+ - `MAX_INPUT_MB=80`
32
+ - `DEFAULT_MAX_PAGES=8`
33
+ - `ABSOLUTE_MAX_PAGES=40`
34
+ - `STALE_RUN_HOURS=12`(一時結果の保持時間)
THIRD_PARTY.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Third-party models and runtime notice
2
+
3
+ このリポジトリはOCRモデルの重み、上流ソースコード、サンプル文書を再配布しません。各Workerは実行時に指定されたHugging Faceリポジトリまたはマウント済みStorage Bucketからモデルを読み込みます。
4
+
5
+ 利用者はモデル、依存ライブラリ、入力文書、生成結果に適用されるライセンス・利用規約・個人情報保護要件を個別に確認してください。モデルrevisionの既定値は `main` であり、上流更新によりコード・挙動・ライセンス表示が変わる可能性があります。評価や本番運用では `MODEL_REVISION` をcommit hashへ固定することを推奨します。
6
+
7
+ 2026-06-23時点でモデルカードに表示されていたライセンスは次のとおりです。
8
+
9
+ - `baidu/Qianfan-OCR`: Apache License 2.0(上流NOTICEにMIT対象ファイルの記載あり)
10
+ - `baidu/Unlimited-OCR`: MIT License
11
+ - `PaddlePaddle/PaddleOCR-VL-1.6`: Apache License 2.0
12
+ - `PaddlePaddle/PP-OCRv6_medium_det_safetensors`: Apache License 2.0
13
+ - `PaddlePaddle/PP-OCRv6_medium_rec` および実行時に取得される補助モデル: 使用するリポジトリのモデルカードを確認
14
+
15
+ 本リポジトリ自身のコードはルートの `LICENSE` に従います。
app.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import statistics
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import gradio as gr
10
+
11
+ from ocr_workbench.client import WorkerError, check_health, normalize_endpoint, run_page
12
+ from ocr_workbench.documents import (
13
+ DocumentError,
14
+ cleanup_stale_runs,
15
+ gallery_items,
16
+ normalize_gradio_path,
17
+ prepare_document,
18
+ )
19
+ from ocr_workbench.export import build_exports
20
+ from ocr_workbench.registry import ModelSpec, load_registry
21
+
22
+
23
+ REGISTRY = load_registry()
24
+ MAX_INPUT_MB = int(os.getenv("MAX_INPUT_MB", "80"))
25
+ DEFAULT_MAX_PAGES = int(os.getenv("DEFAULT_MAX_PAGES", "8"))
26
+ ABSOLUTE_MAX_PAGES = int(os.getenv("ABSOLUTE_MAX_PAGES", "40"))
27
+ STALE_RUN_HOURS = float(os.getenv("STALE_RUN_HOURS", "12"))
28
+
29
+
30
+ def _spec(model_id: str) -> ModelSpec:
31
+ try:
32
+ return REGISTRY[model_id]
33
+ except KeyError as exc:
34
+ raise gr.Error(f"Unknown model: {model_id}") from exc
35
+
36
+
37
+ def _model_info(spec: ModelSpec) -> str:
38
+ endpoint = spec.endpoint() or "未設定"
39
+ return (
40
+ f"### {spec.label}\n\n"
41
+ f"{spec.description}\n\n"
42
+ f"**Worker URL:** `{endpoint}` \n"
43
+ f"**環境変数:** `{spec.endpoint_env}` \n"
44
+ f"**画像出力:** {spec.result_note}"
45
+ )
46
+
47
+
48
+ def on_model_change(model_id: str) -> tuple[str, str, str, int, bool, str]:
49
+ spec = _spec(model_id)
50
+ return (
51
+ spec.default_prompt,
52
+ spec.endpoint(),
53
+ _model_info(spec),
54
+ spec.default_max_tokens,
55
+ spec.default_layout_as_thought,
56
+ spec.default_image_mode,
57
+ )
58
+
59
+
60
+ def health_check(model_id: str, endpoint_override: str) -> str:
61
+ spec = _spec(model_id)
62
+ endpoint = normalize_endpoint(endpoint_override) or spec.endpoint()
63
+ try:
64
+ payload = check_health(endpoint)
65
+ except WorkerError as exc:
66
+ return f"❌ **Health check failed:** {exc}"
67
+ return "✅ **Worker reachable**\n\n```json\n" + json.dumps(payload, ensure_ascii=False, indent=2) + "\n```"
68
+
69
+
70
+ def run_ocr(
71
+ file_value: object,
72
+ model_id: str,
73
+ prompt: str,
74
+ endpoint_override: str,
75
+ page_selection: str,
76
+ max_pages: int,
77
+ dpi: int,
78
+ max_new_tokens: int,
79
+ layout_as_thought: bool,
80
+ unlimited_image_mode: str,
81
+ request_timeout: int,
82
+ progress: gr.Progress = gr.Progress(track_tqdm=False),
83
+ ) -> tuple[
84
+ list[tuple[str, str]],
85
+ list[tuple[str, str]],
86
+ str,
87
+ str,
88
+ str,
89
+ str,
90
+ str,
91
+ ]:
92
+ cleanup_stale_runs(STALE_RUN_HOURS)
93
+ source_path = normalize_gradio_path(file_value)
94
+ spec = _spec(model_id)
95
+ endpoint = normalize_endpoint(endpoint_override) or spec.endpoint()
96
+ if not endpoint:
97
+ raise gr.Error(
98
+ f"{spec.label} のWorker URLが未設定です。Space Variable "
99
+ f"`{spec.endpoint_env}` または画面のWorker URL欄を設定してください。"
100
+ )
101
+
102
+ max_pages = min(max(1, int(max_pages)), ABSOLUTE_MAX_PAGES)
103
+ progress(0.03, desc="入力を展開しています")
104
+ try:
105
+ run_dir, pages = prepare_document(
106
+ source_path,
107
+ dpi=int(dpi),
108
+ selection=page_selection,
109
+ max_pages=max_pages,
110
+ max_input_mb=MAX_INPUT_MB,
111
+ )
112
+ except DocumentError as exc:
113
+ raise gr.Error(str(exc)) from exc
114
+
115
+ source_gallery = gallery_items(pages)
116
+ options: dict[str, Any] = {
117
+ "max_new_tokens": int(max_new_tokens),
118
+ "layout_as_thought": bool(layout_as_thought),
119
+ "image_mode": unlimited_image_mode,
120
+ }
121
+
122
+ responses: list[dict[str, Any]] = []
123
+ elapsed_values: list[float] = []
124
+ for index, page in enumerate(pages, start=1):
125
+ progress(
126
+ 0.08 + 0.82 * ((index - 1) / len(pages)),
127
+ desc=f"Page {page.page_number} / {pages[-1].page_number}: {spec.label}",
128
+ )
129
+ try:
130
+ response = run_page(
131
+ endpoint=endpoint,
132
+ model_id=model_id,
133
+ page_path=page.image_path,
134
+ prompt=prompt,
135
+ options=options,
136
+ timeout_seconds=float(request_timeout),
137
+ )
138
+ except WorkerError as exc:
139
+ raise gr.Error(f"Page {page.page_number} failed: {exc}") from exc
140
+ responses.append(response)
141
+ elapsed = response.get("metrics", {}).get("elapsed_seconds")
142
+ if isinstance(elapsed, (int, float)):
143
+ elapsed_values.append(float(elapsed))
144
+
145
+ progress(0.94, desc="結果をまとめています")
146
+ annotated, markdown, text, raw_json, archive = build_exports(
147
+ run_dir=run_dir,
148
+ model_id=model_id,
149
+ model_label=spec.label,
150
+ pages=pages,
151
+ responses=responses,
152
+ )
153
+ total_elapsed = sum(elapsed_values)
154
+ median_elapsed = statistics.median(elapsed_values) if elapsed_values else 0.0
155
+ warning_count = sum(len(response.get("warnings", []) or []) for response in responses)
156
+ status = (
157
+ f"完了: {spec.label} / {len(pages)} page(s). "
158
+ f"Worker inference total {total_elapsed:.1f}s, median {median_elapsed:.1f}s/page. "
159
+ f"Warnings: {warning_count}."
160
+ )
161
+ progress(1.0, desc="完了")
162
+ return source_gallery, annotated, markdown, text, raw_json, archive, status
163
+
164
+
165
+ model_choices = [(spec.label, spec.id) for spec in REGISTRY.values()]
166
+ default_model_id = next(iter(REGISTRY))
167
+ default_spec = REGISTRY[default_model_id]
168
+
169
+ CSS = """
170
+ #status-box textarea {font-family: ui-monospace, SFMono-Regular, Menlo, monospace;}
171
+ .result-gallery {min-height: 460px;}
172
+ """
173
+
174
+ with gr.Blocks(title="OCR Model Workbench") as demo:
175
+ gr.Markdown(
176
+ "# OCR Model Workbench\n"
177
+ "PDF・画像を共通UIから最新OCRモデルへ送り、ページ画像、可視化画像、Markdown、テキスト、JSONを比較します。"
178
+ )
179
+
180
+ with gr.Row(equal_height=False):
181
+ with gr.Column(scale=5):
182
+ input_file = gr.File(
183
+ label="PDF / 画像",
184
+ file_count="single",
185
+ type="filepath",
186
+ file_types=["image", ".pdf"],
187
+ )
188
+ model = gr.Dropdown(
189
+ choices=model_choices,
190
+ value=default_model_id,
191
+ label="モデル",
192
+ )
193
+ prompt = gr.Textbox(
194
+ value=default_spec.default_prompt,
195
+ label="プロンプト(VLM系のみ)",
196
+ lines=4,
197
+ )
198
+ with gr.Accordion("ページ・推論設定", open=False):
199
+ page_selection = gr.Textbox(
200
+ label="PDFページ指定",
201
+ placeholder="空欄=先頭から。例: 1-3,5",
202
+ value="",
203
+ )
204
+ max_pages = gr.Slider(
205
+ minimum=1,
206
+ maximum=ABSOLUTE_MAX_PAGES,
207
+ step=1,
208
+ value=min(DEFAULT_MAX_PAGES, ABSOLUTE_MAX_PAGES),
209
+ label="最大ページ数",
210
+ )
211
+ dpi = gr.Slider(96, 300, value=180, step=12, label="PDF rasterize DPI")
212
+ max_new_tokens = gr.Slider(
213
+ 256,
214
+ 32768,
215
+ value=default_spec.default_max_tokens,
216
+ step=256,
217
+ label="生成上限(max_new_tokens / max_length)",
218
+ )
219
+ layout_as_thought = gr.Checkbox(
220
+ label="Qianfan Layout-as-Thought",
221
+ value=default_spec.default_layout_as_thought,
222
+ )
223
+ unlimited_image_mode = gr.Radio(
224
+ choices=["gundam", "base"],
225
+ value=default_spec.default_image_mode,
226
+ label="Unlimited-OCR image mode",
227
+ )
228
+ request_timeout = gr.Slider(
229
+ 60,
230
+ 1800,
231
+ value=600,
232
+ step=30,
233
+ label="1ページのタイムアウト(秒)",
234
+ )
235
+ with gr.Accordion("Worker接続", open=False):
236
+ endpoint_override = gr.Textbox(
237
+ value=default_spec.endpoint(),
238
+ label="Worker URL(空欄ならSpace Variableを使用)",
239
+ placeholder="https://username-space-name.hf.space",
240
+ )
241
+ health_button = gr.Button("Health check")
242
+ run_button = gr.Button("OCRを実行", variant="primary")
243
+ status = gr.Textbox(label="Status", interactive=False, elem_id="status-box")
244
+
245
+ with gr.Column(scale=7):
246
+ model_info = gr.Markdown(_model_info(default_spec))
247
+ health_output = gr.Markdown()
248
+ with gr.Tabs():
249
+ with gr.Tab("入力ページ"):
250
+ source_gallery = gr.Gallery(
251
+ label="Source pages",
252
+ columns=2,
253
+ height=520,
254
+ elem_classes=["result-gallery"],
255
+ )
256
+ with gr.Tab("結果画像"):
257
+ result_gallery = gr.Gallery(
258
+ label="Annotated / normalized result images",
259
+ columns=2,
260
+ height=520,
261
+ elem_classes=["result-gallery"],
262
+ )
263
+ with gr.Tab("Markdown"):
264
+ markdown_output = gr.Markdown()
265
+ with gr.Tab("Text"):
266
+ text_output = gr.Textbox(lines=28, buttons=["copy"])
267
+ with gr.Tab("JSON"):
268
+ json_output = gr.Code(language="json", lines=28)
269
+ with gr.Tab("Download"):
270
+ download_output = gr.File(label="全結果ZIP")
271
+
272
+ model.change(
273
+ fn=on_model_change,
274
+ inputs=model,
275
+ outputs=[
276
+ prompt,
277
+ endpoint_override,
278
+ model_info,
279
+ max_new_tokens,
280
+ layout_as_thought,
281
+ unlimited_image_mode,
282
+ ],
283
+ )
284
+ health_button.click(
285
+ fn=health_check,
286
+ inputs=[model, endpoint_override],
287
+ outputs=health_output,
288
+ )
289
+ run_button.click(
290
+ fn=run_ocr,
291
+ inputs=[
292
+ input_file,
293
+ model,
294
+ prompt,
295
+ endpoint_override,
296
+ page_selection,
297
+ max_pages,
298
+ dpi,
299
+ max_new_tokens,
300
+ layout_as_thought,
301
+ unlimited_image_mode,
302
+ request_timeout,
303
+ ],
304
+ outputs=[
305
+ source_gallery,
306
+ result_gallery,
307
+ markdown_output,
308
+ text_output,
309
+ json_output,
310
+ download_output,
311
+ status,
312
+ ],
313
+ )
314
+
315
+
316
+ if __name__ == "__main__":
317
+ demo.queue(default_concurrency_limit=2, max_size=12).launch(
318
+ server_name="0.0.0.0",
319
+ server_port=int(os.getenv("PORT", "7860")),
320
+ css=CSS,
321
+ )
ocr_workbench/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Shared components for the OCR Workbench gateway."""
2
+
3
+ __version__ = "0.1.0"
ocr_workbench/client.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import inspect
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from gradio_client import Client, handle_file
11
+
12
+
13
+ class WorkerError(RuntimeError):
14
+ pass
15
+
16
+
17
+ def normalize_endpoint(endpoint: str) -> str:
18
+ value = (endpoint or "").strip().rstrip("/")
19
+ if not value:
20
+ return ""
21
+ if not value.startswith(("http://", "https://")):
22
+ value = "https://" + value
23
+ return value
24
+
25
+
26
+ def _client(endpoint: str) -> Client:
27
+ hf_token = os.getenv("HF_TOKEN", "").strip() or None
28
+ if not hf_token:
29
+ return Client(endpoint)
30
+
31
+ signature = inspect.signature(Client.__init__)
32
+ if "hf_token" in signature.parameters:
33
+ return Client(endpoint, hf_token=hf_token)
34
+ if "token" in signature.parameters:
35
+ return Client(endpoint, token=hf_token)
36
+ if "headers" in signature.parameters:
37
+ return Client(endpoint, headers={"Authorization": f"Bearer {hf_token}"})
38
+ return Client(endpoint)
39
+
40
+
41
+ def check_health(endpoint: str, timeout_seconds: float = 20.0) -> dict[str, Any]:
42
+ endpoint = normalize_endpoint(endpoint)
43
+ if not endpoint:
44
+ raise WorkerError("Worker URL is not configured.")
45
+ try:
46
+ payload = _client(endpoint).predict(api_name="/health")
47
+ if not isinstance(payload, dict):
48
+ raise WorkerError("Worker health response is not a JSON object.")
49
+ return payload
50
+ except Exception as exc:
51
+ raise WorkerError(f"Worker health check failed: {exc}") from exc
52
+
53
+
54
+ def run_page(
55
+ *,
56
+ endpoint: str,
57
+ model_id: str,
58
+ page_path: Path,
59
+ prompt: str,
60
+ options: dict[str, Any],
61
+ timeout_seconds: float,
62
+ ) -> dict[str, Any]:
63
+ endpoint = normalize_endpoint(endpoint)
64
+ if not endpoint:
65
+ raise WorkerError("Worker URL is not configured.")
66
+
67
+ retry_delays = (0.0, 2.0, 6.0)
68
+ last_error: Exception | None = None
69
+
70
+ for attempt, delay in enumerate(retry_delays, start=1):
71
+ if delay:
72
+ time.sleep(delay)
73
+ try:
74
+ payload = _client(endpoint).predict(
75
+ handle_file(str(page_path)),
76
+ model_id,
77
+ prompt or "",
78
+ json.dumps(options, ensure_ascii=False),
79
+ os.getenv("WORKER_API_TOKEN", "").strip(),
80
+ api_name="/ocr",
81
+ )
82
+ if not isinstance(payload, dict):
83
+ raise WorkerError("Worker response is not a JSON object.")
84
+ required = {"model", "text", "markdown", "metrics"}
85
+ missing = sorted(required - payload.keys())
86
+ if missing:
87
+ raise WorkerError(f"Worker response is missing fields: {missing}")
88
+ return payload
89
+ except (ValueError, OSError, WorkerError, Exception) as exc:
90
+ last_error = exc
91
+ if attempt >= len(retry_delays):
92
+ break
93
+
94
+ raise WorkerError(f"OCR request failed after retries: {last_error}")
ocr_workbench/documents.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ import tempfile
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Iterable
10
+
11
+ import fitz
12
+ from PIL import Image, ImageOps
13
+
14
+
15
+ Image.MAX_IMAGE_PIXELS = 80_000_000
16
+
17
+ SUPPORTED_IMAGE_SUFFIXES = {
18
+ ".png",
19
+ ".jpg",
20
+ ".jpeg",
21
+ ".webp",
22
+ ".bmp",
23
+ ".tif",
24
+ ".tiff",
25
+ }
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class DocumentPage:
30
+ page_number: int
31
+ image_path: Path
32
+
33
+
34
+ class DocumentError(ValueError):
35
+ pass
36
+
37
+
38
+ def cleanup_stale_runs(max_age_hours: float = 12.0) -> None:
39
+ """Remove old gateway work directories and archives from the ephemeral disk."""
40
+ cutoff = time.time() - max(0.5, float(max_age_hours)) * 3600
41
+ root = Path(tempfile.gettempdir())
42
+ for pattern in ("ocr_workbench_*", "ocr_workbench_*_ocr_result.zip"):
43
+ for path in root.glob(pattern):
44
+ try:
45
+ if path.stat().st_mtime >= cutoff:
46
+ continue
47
+ if path.is_dir():
48
+ shutil.rmtree(path, ignore_errors=True)
49
+ else:
50
+ path.unlink(missing_ok=True)
51
+ except OSError:
52
+ continue
53
+
54
+
55
+ def normalize_gradio_path(value: object) -> str:
56
+ if value is None:
57
+ return ""
58
+ if isinstance(value, str):
59
+ return value
60
+ path = getattr(value, "name", None)
61
+ return str(path or value)
62
+
63
+
64
+ def parse_page_selection(selection: str, total_pages: int, max_pages: int) -> list[int]:
65
+ if total_pages < 1:
66
+ return []
67
+ max_pages = max(1, int(max_pages))
68
+ text = (selection or "").strip()
69
+ if not text:
70
+ return list(range(min(total_pages, max_pages)))
71
+
72
+ selected: set[int] = set()
73
+ for token in re.split(r"\s*,\s*", text):
74
+ if not token:
75
+ continue
76
+ if "-" in token:
77
+ match = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
78
+ if not match:
79
+ raise DocumentError(f"Invalid page range: {token!r}")
80
+ start, end = map(int, match.groups())
81
+ if start > end:
82
+ start, end = end, start
83
+ selected.update(range(start, end + 1))
84
+ else:
85
+ if not token.isdigit():
86
+ raise DocumentError(f"Invalid page number: {token!r}")
87
+ selected.add(int(token))
88
+
89
+ invalid = sorted(page for page in selected if page < 1 or page > total_pages)
90
+ if invalid:
91
+ raise DocumentError(
92
+ f"Page selection contains pages outside 1-{total_pages}: {invalid}"
93
+ )
94
+
95
+ ordered = sorted(selected)
96
+ if len(ordered) > max_pages:
97
+ raise DocumentError(
98
+ f"Selected {len(ordered)} pages, but the current limit is {max_pages}."
99
+ )
100
+ return [page - 1 for page in ordered]
101
+
102
+
103
+ def _save_normalized_image(input_path: Path, output_path: Path) -> None:
104
+ try:
105
+ with Image.open(input_path) as image:
106
+ normalized = ImageOps.exif_transpose(image).convert("RGB")
107
+ normalized.save(output_path, format="PNG", optimize=False)
108
+ except Exception as exc:
109
+ raise DocumentError(f"Unable to decode image: {exc}") from exc
110
+
111
+
112
+ def prepare_document(
113
+ source_path: str,
114
+ *,
115
+ dpi: int,
116
+ selection: str,
117
+ max_pages: int,
118
+ max_input_mb: int,
119
+ ) -> tuple[Path, list[DocumentPage]]:
120
+ source = Path(source_path)
121
+ if not source.exists() or not source.is_file():
122
+ raise DocumentError("Upload an image or PDF first.")
123
+
124
+ size_mb = source.stat().st_size / (1024 * 1024)
125
+ if size_mb > max_input_mb:
126
+ raise DocumentError(
127
+ f"Input is {size_mb:.1f} MB; the configured limit is {max_input_mb} MB."
128
+ )
129
+
130
+ run_dir = Path(tempfile.mkdtemp(prefix="ocr_workbench_"))
131
+ input_dir = run_dir / "input"
132
+ pages_dir = run_dir / "source_pages"
133
+ input_dir.mkdir(parents=True, exist_ok=True)
134
+ pages_dir.mkdir(parents=True, exist_ok=True)
135
+ shutil.copy2(source, input_dir / source.name)
136
+
137
+ suffix = source.suffix.lower()
138
+ pages: list[DocumentPage] = []
139
+ if suffix == ".pdf":
140
+ try:
141
+ document = fitz.open(source)
142
+ except Exception as exc:
143
+ raise DocumentError(f"Unable to open PDF: {exc}") from exc
144
+ try:
145
+ indices = parse_page_selection(selection, document.page_count, max_pages)
146
+ scale = max(72, min(360, int(dpi))) / 72.0
147
+ matrix = fitz.Matrix(scale, scale)
148
+ for index in indices:
149
+ output = pages_dir / f"page_{index + 1:04d}.png"
150
+ pixmap = document.load_page(index).get_pixmap(
151
+ matrix=matrix,
152
+ alpha=False,
153
+ colorspace=fitz.csRGB,
154
+ )
155
+ pixmap.save(output)
156
+ pages.append(DocumentPage(index + 1, output))
157
+ finally:
158
+ document.close()
159
+ elif suffix in SUPPORTED_IMAGE_SUFFIXES:
160
+ output = pages_dir / "page_0001.png"
161
+ _save_normalized_image(source, output)
162
+ pages.append(DocumentPage(1, output))
163
+ else:
164
+ raise DocumentError(
165
+ f"Unsupported file type {suffix!r}. Use PDF, PNG, JPEG, WebP, BMP, or TIFF."
166
+ )
167
+
168
+ if not pages:
169
+ raise DocumentError("No pages were selected.")
170
+ return run_dir, pages
171
+
172
+
173
+ def gallery_items(pages: Iterable[DocumentPage]) -> list[tuple[str, str]]:
174
+ return [(str(page.image_path), f"Page {page.page_number}") for page in pages]
ocr_workbench/export.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import json
6
+ import mimetypes
7
+ import re
8
+ import shutil
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .documents import DocumentPage
13
+
14
+
15
+ _IMAGE_EXTENSION = {
16
+ "image/png": ".png",
17
+ "image/jpeg": ".jpg",
18
+ "image/webp": ".webp",
19
+ }
20
+
21
+
22
+ def _decode_image(payload: dict[str, Any], output_stem: Path) -> Path | None:
23
+ encoded = payload.get("annotated_image_base64")
24
+ if not encoded:
25
+ return None
26
+ mime = str(payload.get("annotated_image_mime") or "image/png")
27
+ extension = _IMAGE_EXTENSION.get(mime) or mimetypes.guess_extension(mime) or ".png"
28
+ output = output_stem.with_suffix(extension)
29
+ try:
30
+ output.write_bytes(base64.b64decode(encoded, validate=True))
31
+ except (binascii.Error, ValueError) as exc:
32
+ raise ValueError(f"Worker returned an invalid base64 image: {exc}") from exc
33
+ return output
34
+
35
+
36
+ def _plain_from_markdown(markdown: str) -> str:
37
+ text = re.sub(r"```[^\n]*\n(.*?)```", r"\1", markdown, flags=re.DOTALL)
38
+ text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
39
+ text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
40
+ text = re.sub(r"<[^>]+>", "", text)
41
+ text = re.sub(r"^[#>*+-]+\s*", "", text, flags=re.MULTILINE)
42
+ text = re.sub(r"[`*_~]", "", text)
43
+ return text.strip()
44
+
45
+
46
+ def build_exports(
47
+ *,
48
+ run_dir: Path,
49
+ model_id: str,
50
+ model_label: str,
51
+ pages: list[DocumentPage],
52
+ responses: list[dict[str, Any]],
53
+ ) -> tuple[list[tuple[str, str]], str, str, str, str]:
54
+ output_dir = run_dir / "results"
55
+ annotated_dir = output_dir / "annotated"
56
+ annotated_dir.mkdir(parents=True, exist_ok=True)
57
+
58
+ annotated_gallery: list[tuple[str, str]] = []
59
+ markdown_parts = [f"# OCR result — {model_label}"]
60
+ text_parts: list[str] = []
61
+ raw_pages: list[dict[str, Any]] = []
62
+
63
+ for page, response in zip(pages, responses, strict=True):
64
+ image = _decode_image(
65
+ response,
66
+ annotated_dir / f"page_{page.page_number:04d}",
67
+ )
68
+ if image is None:
69
+ image = annotated_dir / f"page_{page.page_number:04d}.png"
70
+ shutil.copy2(page.image_path, image)
71
+ annotated_gallery.append((str(image), f"Page {page.page_number}"))
72
+
73
+ markdown = str(response.get("markdown") or "").strip()
74
+ text = str(response.get("text") or "").strip()
75
+ if not text and markdown:
76
+ text = _plain_from_markdown(markdown)
77
+ warnings = [str(item) for item in (response.get("warnings", []) or [])]
78
+ warning_markdown = ""
79
+ warning_text = ""
80
+ if warnings:
81
+ warning_markdown = "\n\n> ⚠ " + "\n> ⚠ ".join(warnings)
82
+ warning_text = "\n\n[Warnings]\n- " + "\n- ".join(warnings)
83
+ markdown_parts.append(
84
+ f"\n## Page {page.page_number}\n\n"
85
+ f"{markdown or text or '(empty result)'}{warning_markdown}"
86
+ )
87
+ text_parts.append(
88
+ f"===== Page {page.page_number} =====\n"
89
+ f"{text or '(empty result)'}{warning_text}"
90
+ )
91
+
92
+ raw_page = dict(response)
93
+ raw_page.pop("annotated_image_base64", None)
94
+ raw_page["page_number"] = page.page_number
95
+ raw_pages.append(raw_page)
96
+
97
+ markdown_document = "\n".join(markdown_parts).strip() + "\n"
98
+ text_document = "\n\n".join(text_parts).strip() + "\n"
99
+ raw_payload = {
100
+ "schema_version": "1.0",
101
+ "model": model_id,
102
+ "model_label": model_label,
103
+ "pages": raw_pages,
104
+ }
105
+ json_document = json.dumps(raw_payload, ensure_ascii=False, indent=2)
106
+
107
+ output_dir.mkdir(parents=True, exist_ok=True)
108
+ (output_dir / "result.md").write_text(markdown_document, encoding="utf-8")
109
+ (output_dir / "result.txt").write_text(text_document, encoding="utf-8")
110
+ (output_dir / "result.json").write_text(json_document, encoding="utf-8")
111
+
112
+ archive_base = run_dir.parent / f"{run_dir.name}_ocr_result"
113
+ archive = shutil.make_archive(str(archive_base), "zip", run_dir)
114
+ return annotated_gallery, markdown_document, text_document, json_document, archive
ocr_workbench/registry.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import asdict, dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ModelSpec:
11
+ id: str
12
+ label: str
13
+ endpoint_env: str
14
+ default_prompt: str
15
+ description: str
16
+ result_note: str
17
+ default_max_tokens: int = 4096
18
+ default_layout_as_thought: bool = False
19
+ default_image_mode: str = "gundam"
20
+
21
+ def endpoint(self) -> str:
22
+ return os.getenv(self.endpoint_env, "").strip().rstrip("/")
23
+
24
+ def public_dict(self) -> dict[str, Any]:
25
+ value = asdict(self)
26
+ value["endpoint"] = self.endpoint()
27
+ return value
28
+
29
+
30
+ _BUILTIN_MODELS: list[ModelSpec] = [
31
+ ModelSpec(
32
+ id="paddleocr-vl-1.6",
33
+ label="PaddleOCR-VL 1.6",
34
+ endpoint_env="PADDLEOCR_VL_WORKER_URL",
35
+ default_prompt="Parse this document to Markdown.",
36
+ description=(
37
+ "Compact document parser for text, layout, tables, formulas, charts and seals. "
38
+ "The Storage Bucket can be mounted on this worker as a persistent model/cache volume."
39
+ ),
40
+ result_note="Returns PaddleOCR export images plus Markdown/JSON where available.",
41
+ default_max_tokens=4096,
42
+ ),
43
+ ModelSpec(
44
+ id="qianfan-ocr",
45
+ label="Qianfan-OCR",
46
+ endpoint_env="QIANFAN_WORKER_URL",
47
+ default_prompt="Parse this document to Markdown.",
48
+ description=(
49
+ "Prompt-driven end-to-end document intelligence model. Layout-as-Thought can emit "
50
+ "layout reasoning before the final document output."
51
+ ),
52
+ result_note=(
53
+ "When parseable layout boxes are emitted, they are drawn over the page. "
54
+ "Otherwise the result image is the normalized source page."
55
+ ),
56
+ default_max_tokens=4096,
57
+ default_layout_as_thought=True,
58
+ ),
59
+ ModelSpec(
60
+ id="unlimited-ocr",
61
+ label="Unlimited-OCR",
62
+ endpoint_env="UNLIMITED_WORKER_URL",
63
+ default_prompt="document parsing.",
64
+ description=(
65
+ "One-shot long-horizon document parser. The worker uses the model's native "
66
+ "result.md and result_with_boxes image exports."
67
+ ),
68
+ result_note="Native visualization is returned when the model generates it.",
69
+ default_max_tokens=32768,
70
+ default_image_mode="gundam",
71
+ ),
72
+ ModelSpec(
73
+ id="ppocrv6-medium",
74
+ label="PP-OCRv6 Medium (det + rec)",
75
+ endpoint_env="PPOCRV6_WORKER_URL",
76
+ default_prompt="",
77
+ description=(
78
+ "Lightweight OCR pipeline combining PP-OCRv6_medium_det with "
79
+ "PP-OCRv6_medium_rec. The detection checkpoint alone does not emit text."
80
+ ),
81
+ result_note="Returns recognized text and the PaddleOCR detection/recognition overlay.",
82
+ default_max_tokens=512,
83
+ ),
84
+ ]
85
+
86
+
87
+ def _custom_models() -> list[ModelSpec]:
88
+ raw = os.getenv("OCR_MODEL_CONFIG_JSON", "").strip()
89
+ if not raw:
90
+ return []
91
+ payload = json.loads(raw)
92
+ if not isinstance(payload, list):
93
+ raise ValueError("OCR_MODEL_CONFIG_JSON must contain a JSON list.")
94
+
95
+ result: list[ModelSpec] = []
96
+ for index, item in enumerate(payload):
97
+ if not isinstance(item, dict):
98
+ raise ValueError(f"OCR_MODEL_CONFIG_JSON[{index}] must be an object.")
99
+ result.append(
100
+ ModelSpec(
101
+ id=str(item["id"]),
102
+ label=str(item.get("label", item["id"])),
103
+ endpoint_env=str(item["endpoint_env"]),
104
+ default_prompt=str(item.get("default_prompt", "Extract all visible text.")),
105
+ description=str(item.get("description", "Custom OCR worker.")),
106
+ result_note=str(item.get("result_note", "Uses the common OCR worker response schema.")),
107
+ default_max_tokens=int(item.get("default_max_tokens", 4096)),
108
+ default_layout_as_thought=bool(item.get("default_layout_as_thought", False)),
109
+ default_image_mode=str(item.get("default_image_mode", "gundam")),
110
+ )
111
+ )
112
+ return result
113
+
114
+
115
+ def load_registry() -> dict[str, ModelSpec]:
116
+ registry: dict[str, ModelSpec] = {model.id: model for model in _BUILTIN_MODELS}
117
+ for model in _custom_models():
118
+ registry[model.id] = model
119
+ return registry
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=5.49.1,<7.0
2
+ gradio_client>=2.3.0,<3.0
3
+ httpx==0.28.1
4
+ PyMuPDF==1.27.2.2
5
+ Pillow==12.1.1