Spaces:
Sleeping
Sleeping
Deploy OCR Model Workbench
Browse files- .dockerignore +7 -0
- LICENSE +21 -0
- README.md +27 -6
- THIRD_PARTY.md +15 -0
- app.py +321 -0
- ocr_workbench/__init__.py +3 -0
- ocr_workbench/client.py +94 -0
- ocr_workbench/documents.py +174 -0
- ocr_workbench/export.py +114 -0
- ocr_workbench/registry.py +119 -0
- requirements.txt +5 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: indigo
|
| 5 |
-
colorTo:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|