Joseph Pollack commited on
Track image and font assets with Git LFS so Hugging Face Spaces accepts the push.
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +7 -0
- .gitignore +6 -0
- README.md +75 -6
- app.py +232 -0
- backend/__init__.py +1 -0
- backend/adapters/__init__.py +1 -0
- backend/adapters/glm.py +123 -0
- backend/adapters/ovis.py +107 -0
- backend/bbox.py +94 -0
- backend/config.py +97 -0
- backend/documents.py +61 -0
- backend/load_models.py +61 -0
- backend/orchestrator.py +184 -0
- backend/spaces_shim.py +23 -0
- backend/stream_protocol.py +50 -0
- dist/assets/__vite-browser-external-DYxpcVy9-BIHI7g3E.js +1 -0
- dist/assets/index-VoO5HFYT.css +1 -0
- dist/assets/index-hmRzNS5f.js +0 -0
- dist/brand/mark.png +3 -0
- dist/examples/handwriting-sample.jpg +3 -0
- dist/examples/paper-sample.png +3 -0
- dist/examples/table-sample.webp +3 -0
- dist/favicon.ico +3 -0
- dist/index.html +47 -0
- dist/vendor/mathjax/output/chtml/fonts/tex.js +0 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_AMS-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Bold.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Fraktur-Bold.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Fraktur-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Bold.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Italic.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-BoldItalic.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-Italic.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Bold.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Italic.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Script-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size1-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size2-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size3-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size4-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Typewriter-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Vector-Bold.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Vector-Regular.woff +3 -0
- dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Zero.woff +3 -0
- dist/vendor/mathjax/tex-chtml-full.js +0 -0
- frontend/index.html +46 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,10 @@ 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 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
*.ico filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
*.woff filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
references/
|
| 2 |
+
.cursor/
|
| 3 |
+
.git/cursor/
|
| 4 |
+
frontend/node_modules/
|
| 5 |
+
**/__pycache__/
|
| 6 |
+
*.pyc
|
README.md
CHANGED
|
@@ -1,15 +1,84 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.20.0
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
license: mit
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: OCR Capacity Building as a Service
|
| 3 |
+
emoji: 🏥
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.20.0
|
| 8 |
+
python_version: "3.12.12"
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
license: mit
|
| 12 |
+
hardware: zero-gpu
|
| 13 |
+
startup_duration_timeout: 30m
|
| 14 |
+
models:
|
| 15 |
+
- ATH-MaaS/OvisOCR2
|
| 16 |
+
- zai-org/GLM-OCR
|
| 17 |
+
preload_from_hub:
|
| 18 |
+
- ATH-MaaS/OvisOCR2
|
| 19 |
+
- zai-org/GLM-OCR
|
| 20 |
+
short_description: Open OCR API for Ebola contact-tracing docs in DRC (OvisOCR2 + GLM-OCR).
|
| 21 |
---
|
| 22 |
|
| 23 |
+
# OCR Capacity Building as a Service
|
| 24 |
+
|
| 25 |
+
Open OCR API for Ebola contact-tracing documents in the DRC. Choose between
|
| 26 |
+
[ATH-MaaS/OvisOCR2](https://huggingface.co/ATH-MaaS/OvisOCR2) and
|
| 27 |
+
[zai-org/GLM-OCR](https://huggingface.co/zai-org/GLM-OCR), upload a page image or
|
| 28 |
+
multi-page PDF, and stream Markdown (with LaTeX formulas and HTML tables).
|
| 29 |
+
|
| 30 |
+
**OvisOCR2** emits figure placeholders that this Space materializes as cropped
|
| 31 |
+
visual regions. **GLM-OCR** returns text recognition output without bbox crops.
|
| 32 |
+
|
| 33 |
+
## Supported inputs
|
| 34 |
+
|
| 35 |
+
- Images: PNG, JPEG, WebP
|
| 36 |
+
- PDFs: up to 50 pages (rasterized locally; up to 4 pages per ZeroGPU lease)
|
| 37 |
+
|
| 38 |
+
## API
|
| 39 |
+
|
| 40 |
+
Streaming endpoint: `/run_ocr`
|
| 41 |
+
|
| 42 |
+
| Arg | Type | Default | Description |
|
| 43 |
+
|-----|------|---------|-------------|
|
| 44 |
+
| `image_path` | FileData | required | Uploaded image or PDF |
|
| 45 |
+
| `page_index` | int | `0` | 0-based start page for this lease |
|
| 46 |
+
| `page_count` | int | `4` | Max pages in this GPU batch |
|
| 47 |
+
| `model_choice` | str | `"OvisOCR2"` | `"OvisOCR2"` or `"GLM-OCR"` |
|
| 48 |
+
| `prompt` | str | `""` | Override; empty → model default |
|
| 49 |
+
|
| 50 |
+
```python
|
| 51 |
+
from gradio_client import Client, handle_file
|
| 52 |
+
|
| 53 |
+
client = Client("OWNER/ocr-ebola")
|
| 54 |
+
job = client.submit(
|
| 55 |
+
handle_file("form.pdf"),
|
| 56 |
+
0,
|
| 57 |
+
4,
|
| 58 |
+
"OvisOCR2",
|
| 59 |
+
"",
|
| 60 |
+
api_name="/run_ocr",
|
| 61 |
+
)
|
| 62 |
+
for chunk in job:
|
| 63 |
+
print(chunk["event"], chunk.get("current_page"), chunk.get("char_count"))
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
Also available as an MCP tool named `run_ocr`. Health check: `GET /healthz`.
|
| 67 |
+
|
| 68 |
+
## Local run
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
# Backend deps (Space installs requirements.txt automatically)
|
| 72 |
+
pip install -r requirements.txt
|
| 73 |
+
|
| 74 |
+
# Frontend (build once; commit dist/ for Spaces)
|
| 75 |
+
cd frontend && npm install && npm run build && cd ..
|
| 76 |
+
|
| 77 |
+
# Mock stream without loading weights (Windows PowerShell: $env:OCR_TEST_MODE=1)
|
| 78 |
+
OCR_TEST_MODE=1 python app.py
|
| 79 |
+
|
| 80 |
+
# Real models (needs CUDA)
|
| 81 |
+
python app.py
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
Open `http://127.0.0.1:7860`.
|
app.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import mimetypes
|
| 4 |
+
import os
|
| 5 |
+
from collections.abc import Iterator
|
| 6 |
+
from typing import Any
|
| 7 |
+
from urllib.parse import urlsplit
|
| 8 |
+
|
| 9 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 10 |
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
| 11 |
+
|
| 12 |
+
from backend.spaces_shim import spaces # noqa: E402 (must precede torch)
|
| 13 |
+
|
| 14 |
+
import gradio as gr # noqa: E402
|
| 15 |
+
from fastapi import HTTPException # noqa: E402
|
| 16 |
+
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
| 17 |
+
from fastapi.responses import FileResponse, JSONResponse, Response # noqa: E402
|
| 18 |
+
from gradio.data_classes import FileData # noqa: E402
|
| 19 |
+
from starlette.staticfiles import StaticFiles # noqa: E402
|
| 20 |
+
|
| 21 |
+
from backend.config import ( # noqa: E402
|
| 22 |
+
DIST_DIR,
|
| 23 |
+
GPU_DURATION_CEILING,
|
| 24 |
+
GPU_DURATION_FLOOR,
|
| 25 |
+
GPU_SECONDS_PER_PAGE,
|
| 26 |
+
MAX_PDF_PAGES,
|
| 27 |
+
MODEL_GLM,
|
| 28 |
+
MODEL_OVIS,
|
| 29 |
+
PAGES_PER_GPU_REQUEST,
|
| 30 |
+
TEST_MODE,
|
| 31 |
+
)
|
| 32 |
+
from backend.documents import document_info # noqa: E402
|
| 33 |
+
from backend.load_models import load_models, models_loaded # noqa: E402
|
| 34 |
+
from backend.orchestrator import _file_path, run_ocr_batch # noqa: E402
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def server_config() -> tuple[int, str | None, str | None]:
|
| 38 |
+
port = int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", "7860")))
|
| 39 |
+
configured_root = (
|
| 40 |
+
os.getenv("OCR_ROOT_PATH", "").strip() or os.getenv("GRADIO_ROOT_PATH", "").strip()
|
| 41 |
+
)
|
| 42 |
+
public_url = None
|
| 43 |
+
root_path = None
|
| 44 |
+
if configured_root.startswith(("http://", "https://")):
|
| 45 |
+
public_url = configured_root
|
| 46 |
+
path = urlsplit(configured_root).path.rstrip("/")
|
| 47 |
+
root_path = path or None
|
| 48 |
+
elif configured_root:
|
| 49 |
+
root_path = configured_root.rstrip("/") or None
|
| 50 |
+
return port, root_path, public_url
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
SERVER_PORT, ROOT_PATH, PUBLIC_URL = server_config()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class CachedStaticFiles(StaticFiles):
|
| 57 |
+
"""Serve immutable production assets from the browser cache after first load."""
|
| 58 |
+
|
| 59 |
+
async def get_response(self, path: str, scope: dict[str, Any]) -> Any:
|
| 60 |
+
response = await super().get_response(path, scope)
|
| 61 |
+
if response.status_code == 200:
|
| 62 |
+
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
| 63 |
+
return response
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
EXAMPLE_ASSETS = (
|
| 67 |
+
{
|
| 68 |
+
path.name: (
|
| 69 |
+
path.read_bytes(),
|
| 70 |
+
mimetypes.guess_type(path.name)[0] or "application/octet-stream",
|
| 71 |
+
)
|
| 72 |
+
for path in (DIST_DIR / "examples").iterdir()
|
| 73 |
+
if path.is_file()
|
| 74 |
+
}
|
| 75 |
+
if (DIST_DIR / "examples").is_dir()
|
| 76 |
+
else {}
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
load_models()
|
| 80 |
+
|
| 81 |
+
app = gr.Server()
|
| 82 |
+
app.add_middleware(
|
| 83 |
+
CORSMiddleware,
|
| 84 |
+
allow_origins=["http://127.0.0.1:4173", "http://localhost:4173"],
|
| 85 |
+
allow_credentials=True,
|
| 86 |
+
allow_methods=["*"],
|
| 87 |
+
allow_headers=["*"],
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _gpu_duration(
|
| 92 |
+
image_path: FileData | dict[str, Any],
|
| 93 |
+
page_index: int = 0,
|
| 94 |
+
page_count: int = PAGES_PER_GPU_REQUEST,
|
| 95 |
+
model_choice: str = "OvisOCR2",
|
| 96 |
+
prompt: str = "",
|
| 97 |
+
) -> int:
|
| 98 |
+
del model_choice, prompt
|
| 99 |
+
configured_duration = os.getenv("OCR_GPU_DURATION", "").strip()
|
| 100 |
+
if configured_duration:
|
| 101 |
+
return int(configured_duration)
|
| 102 |
+
|
| 103 |
+
requested_count = max(1, min(PAGES_PER_GPU_REQUEST, int(page_count)))
|
| 104 |
+
try:
|
| 105 |
+
path = _file_path(image_path)
|
| 106 |
+
_, total_pages = document_info(path)
|
| 107 |
+
remaining_pages = max(1, total_pages - int(page_index))
|
| 108 |
+
requested_count = min(requested_count, remaining_pages)
|
| 109 |
+
except Exception:
|
| 110 |
+
pass
|
| 111 |
+
|
| 112 |
+
return max(
|
| 113 |
+
GPU_DURATION_FLOOR,
|
| 114 |
+
min(GPU_DURATION_CEILING, requested_count * GPU_SECONDS_PER_PAGE),
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@spaces.GPU(duration=_gpu_duration)
|
| 119 |
+
def _run_ocr_gpu(
|
| 120 |
+
image_path: FileData,
|
| 121 |
+
page_index: int = 0,
|
| 122 |
+
page_count: int = PAGES_PER_GPU_REQUEST,
|
| 123 |
+
model_choice: str = "OvisOCR2",
|
| 124 |
+
prompt: str = "",
|
| 125 |
+
) -> Iterator[dict[str, Any]]:
|
| 126 |
+
yield from run_ocr_batch(
|
| 127 |
+
image_path,
|
| 128 |
+
page_index=page_index,
|
| 129 |
+
page_count=page_count,
|
| 130 |
+
model_choice=model_choice,
|
| 131 |
+
prompt=prompt,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@app.api(name="run_ocr", concurrency_limit=1, time_limit=300)
|
| 136 |
+
@app.mcp.tool(name="run_ocr")
|
| 137 |
+
def run_ocr(
|
| 138 |
+
image_path: FileData,
|
| 139 |
+
page_index: int = 0,
|
| 140 |
+
page_count: int = PAGES_PER_GPU_REQUEST,
|
| 141 |
+
model_choice: str = "OvisOCR2",
|
| 142 |
+
prompt: str = "",
|
| 143 |
+
) -> Iterator[dict[str, Any]]:
|
| 144 |
+
"""Stream OCR Markdown for a bounded batch of document pages."""
|
| 145 |
+
yield from _run_ocr_gpu(
|
| 146 |
+
image_path,
|
| 147 |
+
page_index=page_index,
|
| 148 |
+
page_count=page_count,
|
| 149 |
+
model_choice=model_choice,
|
| 150 |
+
prompt=prompt,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@app.get("/healthz")
|
| 155 |
+
def healthz() -> JSONResponse:
|
| 156 |
+
loaded = models_loaded()
|
| 157 |
+
return JSONResponse(
|
| 158 |
+
{
|
| 159 |
+
"status": "ok",
|
| 160 |
+
"models": {
|
| 161 |
+
"OvisOCR2": MODEL_OVIS,
|
| 162 |
+
"GLM-OCR": MODEL_GLM,
|
| 163 |
+
},
|
| 164 |
+
"loaded": loaded,
|
| 165 |
+
"backend": "mock" if TEST_MODE else "transformers",
|
| 166 |
+
"max_pdf_pages": MAX_PDF_PAGES,
|
| 167 |
+
"pages_per_gpu_request": PAGES_PER_GPU_REQUEST,
|
| 168 |
+
"gpu_seconds_per_page": GPU_SECONDS_PER_PAGE,
|
| 169 |
+
"gpu_duration_floor": GPU_DURATION_FLOOR,
|
| 170 |
+
"gpu_duration_ceiling": GPU_DURATION_CEILING,
|
| 171 |
+
"root_path": ROOT_PATH,
|
| 172 |
+
"public_url": PUBLIC_URL,
|
| 173 |
+
}
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@app.get("/examples/{filename}")
|
| 178 |
+
def example_asset(filename: str) -> Response:
|
| 179 |
+
asset = EXAMPLE_ASSETS.get(filename)
|
| 180 |
+
if asset is None:
|
| 181 |
+
raise HTTPException(status_code=404, detail="Example not found")
|
| 182 |
+
content, media_type = asset
|
| 183 |
+
return Response(
|
| 184 |
+
content=content,
|
| 185 |
+
media_type=media_type,
|
| 186 |
+
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
if DIST_DIR.is_dir():
|
| 191 |
+
for route, directory in (
|
| 192 |
+
("/assets", DIST_DIR / "assets"),
|
| 193 |
+
("/brand", DIST_DIR / "brand"),
|
| 194 |
+
("/vendor", DIST_DIR / "vendor"),
|
| 195 |
+
):
|
| 196 |
+
if directory.is_dir():
|
| 197 |
+
app.mount(
|
| 198 |
+
route,
|
| 199 |
+
CachedStaticFiles(directory=directory),
|
| 200 |
+
name=route.strip("/").replace("/", "-"),
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
@app.get("/")
|
| 205 |
+
def homepage() -> FileResponse:
|
| 206 |
+
index_path = DIST_DIR / "index.html"
|
| 207 |
+
if not index_path.is_file():
|
| 208 |
+
raise RuntimeError(
|
| 209 |
+
"Frontend build missing. Run `cd frontend && npm ci && npm run build` "
|
| 210 |
+
"before launching app.py."
|
| 211 |
+
)
|
| 212 |
+
return FileResponse(index_path, headers={"Cache-Control": "no-cache"})
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@app.get("/favicon.ico")
|
| 216 |
+
def favicon() -> FileResponse:
|
| 217 |
+
favicon_path = DIST_DIR / "favicon.ico"
|
| 218 |
+
if not favicon_path.is_file():
|
| 219 |
+
raise HTTPException(status_code=404, detail="Favicon not found")
|
| 220 |
+
return FileResponse(
|
| 221 |
+
favicon_path,
|
| 222 |
+
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
if __name__ == "__main__":
|
| 227 |
+
app.launch(
|
| 228 |
+
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
|
| 229 |
+
server_port=SERVER_PORT,
|
| 230 |
+
root_path=ROOT_PATH,
|
| 231 |
+
show_error=True,
|
| 232 |
+
)
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""OCR Capacity Building backend package."""
|
backend/adapters/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Model-specific streaming OCR adapters."""
|
backend/adapters/glm.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import tempfile
|
| 5 |
+
import threading
|
| 6 |
+
import time
|
| 7 |
+
from collections.abc import Iterator
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
from PIL import Image, ImageOps
|
| 11 |
+
|
| 12 |
+
from backend.bbox import clean_truncated_repeats
|
| 13 |
+
from backend.config import (
|
| 14 |
+
MAX_NEW_TOKENS,
|
| 15 |
+
MOCK_MARKDOWN,
|
| 16 |
+
STREAM_MAX_INTERVAL,
|
| 17 |
+
STREAM_MIN_CHARS,
|
| 18 |
+
TEST_MODE,
|
| 19 |
+
)
|
| 20 |
+
from backend import load_models
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def infer_stream(page_image: Image.Image, prompt: str) -> Iterator[str]:
|
| 24 |
+
if TEST_MODE:
|
| 25 |
+
# GLM path: no bbox tags in mock
|
| 26 |
+
mock = MOCK_MARKDOWN.replace(
|
| 27 |
+
'<img src="images/bbox_120_130_880_420.jpg" />',
|
| 28 |
+
"(figure omitted)",
|
| 29 |
+
)
|
| 30 |
+
for end in range(64, len(mock) + 64, 64):
|
| 31 |
+
yield mock[:end]
|
| 32 |
+
return
|
| 33 |
+
|
| 34 |
+
processor = load_models.glm_processor
|
| 35 |
+
model = load_models.glm_model
|
| 36 |
+
if processor is None or model is None:
|
| 37 |
+
raise RuntimeError("GLM-OCR is not loaded.")
|
| 38 |
+
|
| 39 |
+
from transformers import TextIteratorStreamer
|
| 40 |
+
|
| 41 |
+
if page_image.mode in ("RGBA", "LA", "P"):
|
| 42 |
+
page_image = page_image.convert("RGB")
|
| 43 |
+
page_image = ImageOps.exif_transpose(page_image)
|
| 44 |
+
|
| 45 |
+
tmp_path: str | None = None
|
| 46 |
+
try:
|
| 47 |
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
|
| 48 |
+
page_image.save(tmp.name, "PNG")
|
| 49 |
+
tmp_path = tmp.name
|
| 50 |
+
tmp.close()
|
| 51 |
+
|
| 52 |
+
messages = [
|
| 53 |
+
{
|
| 54 |
+
"role": "user",
|
| 55 |
+
"content": [
|
| 56 |
+
{"type": "image", "url": tmp_path},
|
| 57 |
+
{"type": "text", "text": prompt},
|
| 58 |
+
],
|
| 59 |
+
}
|
| 60 |
+
]
|
| 61 |
+
inputs = processor.apply_chat_template(
|
| 62 |
+
messages,
|
| 63 |
+
tokenize=True,
|
| 64 |
+
add_generation_prompt=True,
|
| 65 |
+
return_dict=True,
|
| 66 |
+
return_tensors="pt",
|
| 67 |
+
)
|
| 68 |
+
inputs.pop("token_type_ids", None)
|
| 69 |
+
inputs = {
|
| 70 |
+
key: value.to(model.device) if hasattr(value, "to") else value
|
| 71 |
+
for key, value in inputs.items()
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
streamer = TextIteratorStreamer(
|
| 75 |
+
processor.tokenizer if hasattr(processor, "tokenizer") else processor,
|
| 76 |
+
skip_prompt=True,
|
| 77 |
+
skip_special_tokens=True,
|
| 78 |
+
)
|
| 79 |
+
errors: list[BaseException] = []
|
| 80 |
+
|
| 81 |
+
def generate() -> None:
|
| 82 |
+
try:
|
| 83 |
+
with torch.inference_mode():
|
| 84 |
+
model.generate(
|
| 85 |
+
**inputs,
|
| 86 |
+
streamer=streamer,
|
| 87 |
+
max_new_tokens=MAX_NEW_TOKENS,
|
| 88 |
+
)
|
| 89 |
+
except BaseException as error:
|
| 90 |
+
errors.append(error)
|
| 91 |
+
try:
|
| 92 |
+
streamer.end()
|
| 93 |
+
except Exception:
|
| 94 |
+
pass
|
| 95 |
+
|
| 96 |
+
worker = threading.Thread(target=generate, name="glm-ocr-generate", daemon=True)
|
| 97 |
+
worker.start()
|
| 98 |
+
text = ""
|
| 99 |
+
last_yielded = ""
|
| 100 |
+
last_yield_time = time.monotonic()
|
| 101 |
+
for fragment in streamer:
|
| 102 |
+
text += fragment
|
| 103 |
+
now = time.monotonic()
|
| 104 |
+
if (
|
| 105 |
+
len(text) - len(last_yielded) >= STREAM_MIN_CHARS
|
| 106 |
+
or now - last_yield_time >= STREAM_MAX_INTERVAL
|
| 107 |
+
):
|
| 108 |
+
yield text
|
| 109 |
+
last_yielded = text
|
| 110 |
+
last_yield_time = now
|
| 111 |
+
|
| 112 |
+
worker.join()
|
| 113 |
+
if errors:
|
| 114 |
+
raise RuntimeError("GLM-OCR generation failed.") from errors[0]
|
| 115 |
+
final_text = clean_truncated_repeats(text.strip())
|
| 116 |
+
if final_text and final_text != last_yielded:
|
| 117 |
+
yield final_text
|
| 118 |
+
finally:
|
| 119 |
+
if tmp_path and os.path.exists(tmp_path):
|
| 120 |
+
try:
|
| 121 |
+
os.unlink(tmp_path)
|
| 122 |
+
except OSError:
|
| 123 |
+
pass
|
backend/adapters/ovis.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import threading
|
| 4 |
+
import time
|
| 5 |
+
from collections.abc import Iterator
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from PIL import Image
|
| 10 |
+
|
| 11 |
+
from backend.bbox import clean_truncated_repeats
|
| 12 |
+
from backend.config import (
|
| 13 |
+
MAX_NEW_TOKENS,
|
| 14 |
+
MOCK_MARKDOWN,
|
| 15 |
+
STREAM_MAX_INTERVAL,
|
| 16 |
+
STREAM_MIN_CHARS,
|
| 17 |
+
TEST_MODE,
|
| 18 |
+
)
|
| 19 |
+
from backend import load_models
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def generation_token_ids(processor: Any) -> dict[str, int]:
|
| 23 |
+
tokenizer = processor.tokenizer
|
| 24 |
+
return {
|
| 25 |
+
"eos_token_id": int(tokenizer.eos_token_id),
|
| 26 |
+
"pad_token_id": int(tokenizer.pad_token_id),
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def infer_stream(page_image: Image.Image, prompt: str) -> Iterator[str]:
|
| 31 |
+
if TEST_MODE:
|
| 32 |
+
for end in range(64, len(MOCK_MARKDOWN) + 64, 64):
|
| 33 |
+
yield MOCK_MARKDOWN[:end]
|
| 34 |
+
return
|
| 35 |
+
|
| 36 |
+
processor = load_models.ovis_processor
|
| 37 |
+
model = load_models.ovis_model
|
| 38 |
+
if processor is None or model is None:
|
| 39 |
+
raise RuntimeError("OvisOCR2 is not loaded.")
|
| 40 |
+
|
| 41 |
+
from transformers import TextIteratorStreamer
|
| 42 |
+
|
| 43 |
+
messages = [
|
| 44 |
+
{
|
| 45 |
+
"role": "user",
|
| 46 |
+
"content": [
|
| 47 |
+
{"type": "image", "image": page_image},
|
| 48 |
+
{"type": "text", "text": prompt},
|
| 49 |
+
],
|
| 50 |
+
}
|
| 51 |
+
]
|
| 52 |
+
inputs = processor.apply_chat_template(
|
| 53 |
+
messages,
|
| 54 |
+
tokenize=True,
|
| 55 |
+
add_generation_prompt=True,
|
| 56 |
+
return_dict=True,
|
| 57 |
+
return_tensors="pt",
|
| 58 |
+
enable_thinking=False,
|
| 59 |
+
).to(model.device)
|
| 60 |
+
|
| 61 |
+
streamer = TextIteratorStreamer(
|
| 62 |
+
processor.tokenizer,
|
| 63 |
+
skip_prompt=True,
|
| 64 |
+
skip_special_tokens=True,
|
| 65 |
+
clean_up_tokenization_spaces=False,
|
| 66 |
+
)
|
| 67 |
+
errors: list[BaseException] = []
|
| 68 |
+
|
| 69 |
+
def generate() -> None:
|
| 70 |
+
try:
|
| 71 |
+
with torch.inference_mode():
|
| 72 |
+
model.generate(
|
| 73 |
+
**inputs,
|
| 74 |
+
streamer=streamer,
|
| 75 |
+
max_new_tokens=MAX_NEW_TOKENS,
|
| 76 |
+
do_sample=False,
|
| 77 |
+
temperature=None,
|
| 78 |
+
top_p=None,
|
| 79 |
+
top_k=None,
|
| 80 |
+
**generation_token_ids(processor),
|
| 81 |
+
)
|
| 82 |
+
except BaseException as error:
|
| 83 |
+
errors.append(error)
|
| 84 |
+
streamer.on_finalized_text("", stream_end=True)
|
| 85 |
+
|
| 86 |
+
worker = threading.Thread(target=generate, name="ovisocr2-generate", daemon=True)
|
| 87 |
+
worker.start()
|
| 88 |
+
text = ""
|
| 89 |
+
last_yielded = ""
|
| 90 |
+
last_yield_time = time.monotonic()
|
| 91 |
+
for fragment in streamer:
|
| 92 |
+
text += fragment
|
| 93 |
+
now = time.monotonic()
|
| 94 |
+
if (
|
| 95 |
+
len(text) - len(last_yielded) >= STREAM_MIN_CHARS
|
| 96 |
+
or now - last_yield_time >= STREAM_MAX_INTERVAL
|
| 97 |
+
):
|
| 98 |
+
yield text
|
| 99 |
+
last_yielded = text
|
| 100 |
+
last_yield_time = now
|
| 101 |
+
|
| 102 |
+
worker.join()
|
| 103 |
+
if errors:
|
| 104 |
+
raise RuntimeError("OvisOCR2 generation failed.") from errors[0]
|
| 105 |
+
final_text = clean_truncated_repeats(text.strip())
|
| 106 |
+
if final_text and final_text != last_yielded:
|
| 107 |
+
yield final_text
|
backend/bbox.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import html
|
| 5 |
+
import io
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
from PIL import Image
|
| 9 |
+
|
| 10 |
+
BBOX_IMAGE_PATTERN = re.compile(
|
| 11 |
+
r'<img\s+src=["\']images/bbox_(\d+)_(\d+)_(\d+)_(\d+)\.jpg["\']\s*/?>',
|
| 12 |
+
flags=re.IGNORECASE,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
UNMATERIALIZED_BBOX_IMAGE_PATTERN = re.compile(
|
| 16 |
+
r'<img\b[^>]*\bsrc=["\']images/bbox_[^"\']+["\'][^>]*>',
|
| 17 |
+
flags=re.IGNORECASE,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def clean_truncated_repeats(
|
| 22 |
+
text: str,
|
| 23 |
+
min_text_len: int = 8000,
|
| 24 |
+
max_period: int = 200,
|
| 25 |
+
min_period: int = 1,
|
| 26 |
+
min_repeat_chars: int = 100,
|
| 27 |
+
min_repeat_times: int = 5,
|
| 28 |
+
) -> str:
|
| 29 |
+
"""Remove a repeated suffix created when generation reaches its token ceiling."""
|
| 30 |
+
n = len(text)
|
| 31 |
+
if n < min_text_len:
|
| 32 |
+
return text
|
| 33 |
+
|
| 34 |
+
max_period = min(max_period, n - 1)
|
| 35 |
+
for unit_len in range(min_period, max_period + 1):
|
| 36 |
+
if text[n - 1] != text[n - 1 - unit_len]:
|
| 37 |
+
continue
|
| 38 |
+
match_len = 1
|
| 39 |
+
idx = n - 2
|
| 40 |
+
while idx >= unit_len and text[idx] == text[idx - unit_len]:
|
| 41 |
+
match_len += 1
|
| 42 |
+
idx -= 1
|
| 43 |
+
total_len = match_len + unit_len
|
| 44 |
+
repeat_times = total_len // unit_len
|
| 45 |
+
tail_len = total_len % unit_len
|
| 46 |
+
if repeat_times >= min_repeat_times and total_len >= min_repeat_chars:
|
| 47 |
+
return text[: n - total_len + unit_len] + text[n - tail_len :]
|
| 48 |
+
return text
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def neutralize_unmaterialized_bbox_images(markdown: str) -> str:
|
| 52 |
+
"""Render placeholder examples as code instead of issuing broken requests."""
|
| 53 |
+
|
| 54 |
+
def replace(match: re.Match[str]) -> str:
|
| 55 |
+
escaped = html.escape(match.group(0), quote=False)
|
| 56 |
+
return f'<code class="unresolved-image-reference">{escaped}</code>'
|
| 57 |
+
|
| 58 |
+
return UNMATERIALIZED_BBOX_IMAGE_PATTERN.sub(replace, markdown)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def stream_safe_markdown(markdown: str) -> str:
|
| 62 |
+
"""Avoid broken image requests until a page's bbox crops are materialized."""
|
| 63 |
+
return neutralize_unmaterialized_bbox_images(
|
| 64 |
+
BBOX_IMAGE_PATTERN.sub(
|
| 65 |
+
'<div class="visual-placeholder">Preparing visual region…</div>',
|
| 66 |
+
markdown,
|
| 67 |
+
)
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def materialize_bbox_images(markdown: str, page_image: Image.Image) -> str:
|
| 72 |
+
"""Replace bbox image placeholders in rendered output with safe data-URI crops."""
|
| 73 |
+
width, height = page_image.size
|
| 74 |
+
|
| 75 |
+
def replace(match: re.Match[str]) -> str:
|
| 76 |
+
left, top, right, bottom = (int(value) for value in match.groups())
|
| 77 |
+
x1 = max(0, min(width, round(left * width / 1000)))
|
| 78 |
+
y1 = max(0, min(height, round(top * height / 1000)))
|
| 79 |
+
x2 = max(0, min(width, round(right * width / 1000)))
|
| 80 |
+
y2 = max(0, min(height, round(bottom * height / 1000)))
|
| 81 |
+
if x2 <= x1 or y2 <= y1:
|
| 82 |
+
return match.group(0)
|
| 83 |
+
|
| 84 |
+
crop = page_image.crop((x1, y1, x2, y2)).convert("RGB")
|
| 85 |
+
crop.thumbnail((1200, 1200), Image.Resampling.BILINEAR)
|
| 86 |
+
buffer = io.BytesIO()
|
| 87 |
+
crop.save(buffer, format="JPEG", quality=85, optimize=False)
|
| 88 |
+
payload = base64.b64encode(buffer.getvalue()).decode("ascii")
|
| 89 |
+
return (
|
| 90 |
+
f'<img src="data:image/jpeg;base64,{payload}" alt="Visual region" '
|
| 91 |
+
'loading="lazy" decoding="async" />'
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
return neutralize_unmaterialized_bbox_images(BBOX_IMAGE_PATTERN.sub(replace, markdown))
|
backend/config.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Literal
|
| 6 |
+
|
| 7 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 8 |
+
DIST_DIR = BASE_DIR / "dist"
|
| 9 |
+
|
| 10 |
+
ModelChoice = Literal["OvisOCR2", "GLM-OCR"]
|
| 11 |
+
|
| 12 |
+
MODEL_OVIS = "ATH-MaaS/OvisOCR2"
|
| 13 |
+
MODEL_GLM = "zai-org/GLM-OCR"
|
| 14 |
+
|
| 15 |
+
TEST_MODE = os.getenv("OCR_TEST_MODE", os.getenv("OVISOCR_TEST_MODE", "0")).lower() in {
|
| 16 |
+
"1",
|
| 17 |
+
"true",
|
| 18 |
+
"yes",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
MAX_NEW_TOKENS = int(os.getenv("OCR_MAX_NEW_TOKENS", "16384"))
|
| 22 |
+
MAX_PDF_PAGES = int(os.getenv("OCR_MAX_PDF_PAGES", "50"))
|
| 23 |
+
PAGES_PER_GPU_REQUEST = max(1, min(5, int(os.getenv("OCR_PAGES_PER_GPU_REQUEST", "4"))))
|
| 24 |
+
GPU_SECONDS_PER_PAGE = max(15, int(os.getenv("OCR_GPU_SECONDS_PER_PAGE", "30")))
|
| 25 |
+
GPU_DURATION_FLOOR = max(15, int(os.getenv("OCR_GPU_DURATION_FLOOR", "45")))
|
| 26 |
+
GPU_DURATION_CEILING = max(
|
| 27 |
+
GPU_DURATION_FLOOR,
|
| 28 |
+
int(os.getenv("OCR_GPU_DURATION_CEILING", "120")),
|
| 29 |
+
)
|
| 30 |
+
PDF_RENDER_SCALE = float(os.getenv("OCR_PDF_RENDER_SCALE", "2.0"))
|
| 31 |
+
STREAM_MIN_CHARS = int(os.getenv("OCR_STREAM_MIN_CHARS", "64"))
|
| 32 |
+
STREAM_MAX_INTERVAL = float(os.getenv("OCR_STREAM_MAX_INTERVAL", "0.25"))
|
| 33 |
+
MIN_PIXELS = 448 * 448
|
| 34 |
+
MAX_PIXELS = 2880 * 2880
|
| 35 |
+
|
| 36 |
+
OVIS_OCR_PROMPT = (
|
| 37 |
+
"\nExtract all readable content from the image in natural human reading order "
|
| 38 |
+
"and output the result as a single Markdown document. For charts or images, "
|
| 39 |
+
'represent them using an HTML image tag: <img src="images/bbox_{left}_{top}_{right}_{bottom}.jpg" />, '
|
| 40 |
+
"where left, top, right, bottom are bounding box coordinates scaled to [0, 1000). "
|
| 41 |
+
"Format formulas as LaTeX. Format tables as HTML: <table>...</table>. "
|
| 42 |
+
"Transcribe all other text as standard Markdown. Preserve the original text "
|
| 43 |
+
"without translation or paraphrasing."
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
GLM_DEFAULT_PROMPT = "Text Recognition:"
|
| 47 |
+
GLM_TASK_PROMPTS = {
|
| 48 |
+
"Text": "Text Recognition:",
|
| 49 |
+
"Formula": "Formula Recognition:",
|
| 50 |
+
"Table": "Table Recognition:",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
MOCK_MARKDOWN = r"""# Contact tracing — sample page
|
| 54 |
+
|
| 55 |
+
| Field | Value |
|
| 56 |
+
|-------|-------|
|
| 57 |
+
| Case ID | EVD-2024-0042 |
|
| 58 |
+
| Nom | Mbuyi, Jean |
|
| 59 |
+
| Contact | +243 81 000 0000 |
|
| 60 |
+
| Localité | Beni, Nord-Kivu |
|
| 61 |
+
|
| 62 |
+
## Notes
|
| 63 |
+
|
| 64 |
+
Fever onset $t_0$ reported 3 days prior to interview.
|
| 65 |
+
|
| 66 |
+
\[
|
| 67 |
+
R_t = \frac{C_{t}}{C_{t-1}}
|
| 68 |
+
\]
|
| 69 |
+
|
| 70 |
+
<img src="images/bbox_120_130_880_420.jpg" />
|
| 71 |
+
|
| 72 |
+
Source: field form (mock)."""
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def default_prompt(model_choice: ModelChoice) -> str:
|
| 76 |
+
if model_choice == "OvisOCR2":
|
| 77 |
+
return OVIS_OCR_PROMPT
|
| 78 |
+
if model_choice == "GLM-OCR":
|
| 79 |
+
return GLM_DEFAULT_PROMPT
|
| 80 |
+
raise ValueError(f"Unknown model_choice: {model_choice}")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def resolve_prompt(model_choice: ModelChoice, prompt: str) -> str:
|
| 84 |
+
text = (prompt or "").strip()
|
| 85 |
+
if not text:
|
| 86 |
+
return default_prompt(model_choice)
|
| 87 |
+
if model_choice == "GLM-OCR" and text in GLM_TASK_PROMPTS:
|
| 88 |
+
return GLM_TASK_PROMPTS[text]
|
| 89 |
+
return text
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def hub_id_for(model_choice: ModelChoice) -> str:
|
| 93 |
+
if model_choice == "OvisOCR2":
|
| 94 |
+
return MODEL_OVIS
|
| 95 |
+
if model_choice == "GLM-OCR":
|
| 96 |
+
return MODEL_GLM
|
| 97 |
+
raise ValueError(f"Unknown model_choice: {model_choice}")
|
backend/documents.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import io
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import fitz
|
| 8 |
+
from PIL import Image, ImageOps
|
| 9 |
+
|
| 10 |
+
from backend.config import MAX_PDF_PAGES, PDF_RENDER_SCALE
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def document_info(path: str) -> tuple[str, int]:
|
| 14 |
+
suffix = Path(path).suffix.lower()
|
| 15 |
+
try:
|
| 16 |
+
with Path(path).open("rb") as file:
|
| 17 |
+
header = file.read(5)
|
| 18 |
+
except OSError as error:
|
| 19 |
+
raise ValueError("The uploaded document could not be read.") from error
|
| 20 |
+
|
| 21 |
+
if suffix == ".pdf" or header == b"%PDF-":
|
| 22 |
+
with fitz.open(path) as document:
|
| 23 |
+
total_pages = document.page_count
|
| 24 |
+
if total_pages < 1:
|
| 25 |
+
raise ValueError("The uploaded PDF has no pages.")
|
| 26 |
+
if total_pages > MAX_PDF_PAGES:
|
| 27 |
+
raise ValueError(
|
| 28 |
+
f"This demo accepts up to {MAX_PDF_PAGES} PDF pages; received {total_pages}."
|
| 29 |
+
)
|
| 30 |
+
return "pdf", total_pages
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
with Image.open(path) as source:
|
| 34 |
+
source.verify()
|
| 35 |
+
except Exception as error:
|
| 36 |
+
raise ValueError("Please upload a valid PNG, JPEG, WebP, or PDF file.") from error
|
| 37 |
+
return "image", 1
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def load_document_page(path: str, document_type: str, page_index: int) -> Image.Image:
|
| 41 |
+
if document_type == "pdf":
|
| 42 |
+
with fitz.open(path) as document:
|
| 43 |
+
page = document.load_page(page_index)
|
| 44 |
+
pixmap = page.get_pixmap(
|
| 45 |
+
matrix=fitz.Matrix(PDF_RENDER_SCALE, PDF_RENDER_SCALE),
|
| 46 |
+
colorspace=fitz.csRGB,
|
| 47 |
+
alpha=False,
|
| 48 |
+
)
|
| 49 |
+
return Image.frombytes("RGB", (pixmap.width, pixmap.height), pixmap.samples)
|
| 50 |
+
|
| 51 |
+
with Image.open(path) as source:
|
| 52 |
+
return ImageOps.exif_transpose(source).convert("RGB")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def page_preview_data_uri(page_image: Image.Image) -> str:
|
| 56 |
+
preview = page_image.copy().convert("RGB")
|
| 57 |
+
preview.thumbnail((1400, 1800), Image.Resampling.BILINEAR)
|
| 58 |
+
buffer = io.BytesIO()
|
| 59 |
+
preview.save(buffer, format="JPEG", quality=82, optimize=False)
|
| 60 |
+
payload = base64.b64encode(buffer.getvalue()).decode("ascii")
|
| 61 |
+
return f"data:image/jpeg;base64,{payload}"
|
backend/load_models.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
from backend.config import (
|
| 8 |
+
MAX_PIXELS,
|
| 9 |
+
MIN_PIXELS,
|
| 10 |
+
MODEL_GLM,
|
| 11 |
+
MODEL_OVIS,
|
| 12 |
+
TEST_MODE,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
ovis_processor: Any = None
|
| 16 |
+
ovis_model: Any = None
|
| 17 |
+
glm_processor: Any = None
|
| 18 |
+
glm_model: Any = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load_models() -> None:
|
| 22 |
+
"""Eager-load both OCR models onto CUDA (ZeroGPU contract)."""
|
| 23 |
+
global ovis_processor, ovis_model, glm_processor, glm_model
|
| 24 |
+
if TEST_MODE:
|
| 25 |
+
return
|
| 26 |
+
|
| 27 |
+
from transformers import (
|
| 28 |
+
AutoModelForImageTextToText,
|
| 29 |
+
AutoProcessor,
|
| 30 |
+
Qwen3_5ForConditionalGeneration,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
ovis_processor = AutoProcessor.from_pretrained(
|
| 34 |
+
MODEL_OVIS,
|
| 35 |
+
min_pixels=MIN_PIXELS,
|
| 36 |
+
max_pixels=MAX_PIXELS,
|
| 37 |
+
)
|
| 38 |
+
ovis_model = Qwen3_5ForConditionalGeneration.from_pretrained(
|
| 39 |
+
MODEL_OVIS,
|
| 40 |
+
dtype=torch.bfloat16,
|
| 41 |
+
attn_implementation="sdpa",
|
| 42 |
+
).to("cuda")
|
| 43 |
+
ovis_model.eval()
|
| 44 |
+
|
| 45 |
+
glm_processor = AutoProcessor.from_pretrained(MODEL_GLM, trust_remote_code=True)
|
| 46 |
+
glm_model = AutoModelForImageTextToText.from_pretrained(
|
| 47 |
+
MODEL_GLM,
|
| 48 |
+
torch_dtype=torch.bfloat16,
|
| 49 |
+
trust_remote_code=True,
|
| 50 |
+
).to("cuda")
|
| 51 |
+
glm_model.eval()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def models_loaded() -> dict[str, bool]:
|
| 55 |
+
if TEST_MODE:
|
| 56 |
+
return {"ovis": True, "glm": True, "test_mode": True}
|
| 57 |
+
return {
|
| 58 |
+
"ovis": ovis_processor is not None and ovis_model is not None,
|
| 59 |
+
"glm": glm_processor is not None and glm_model is not None,
|
| 60 |
+
"test_mode": False,
|
| 61 |
+
}
|
backend/orchestrator.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from collections.abc import Iterator
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from typing import assert_never
|
| 8 |
+
|
| 9 |
+
from gradio.data_classes import FileData
|
| 10 |
+
|
| 11 |
+
from backend.adapters import glm as glm_adapter
|
| 12 |
+
from backend.adapters import ovis as ovis_adapter
|
| 13 |
+
from backend.bbox import clean_truncated_repeats, materialize_bbox_images, stream_safe_markdown
|
| 14 |
+
from backend.config import (
|
| 15 |
+
PAGES_PER_GPU_REQUEST,
|
| 16 |
+
TEST_MODE,
|
| 17 |
+
ModelChoice,
|
| 18 |
+
resolve_prompt,
|
| 19 |
+
)
|
| 20 |
+
from backend.documents import document_info, load_document_page, page_preview_data_uri
|
| 21 |
+
from backend.stream_protocol import stream_payload
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _file_path(file_data: FileData | dict[str, Any]) -> str:
|
| 25 |
+
if isinstance(file_data, dict):
|
| 26 |
+
path = file_data.get("path")
|
| 27 |
+
else:
|
| 28 |
+
path = getattr(file_data, "path", None)
|
| 29 |
+
if not path:
|
| 30 |
+
raise ValueError("No uploaded document was provided.")
|
| 31 |
+
return str(path)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def run_ocr_batch(
|
| 35 |
+
image_path: FileData | dict[str, Any],
|
| 36 |
+
page_index: int = 0,
|
| 37 |
+
page_count: int = PAGES_PER_GPU_REQUEST,
|
| 38 |
+
model_choice: str = "OvisOCR2",
|
| 39 |
+
prompt: str = "",
|
| 40 |
+
) -> Iterator[dict[str, Any]]:
|
| 41 |
+
"""Stream a bounded group of pages within one ZeroGPU reservation."""
|
| 42 |
+
started = time.perf_counter()
|
| 43 |
+
choice: ModelChoice
|
| 44 |
+
if model_choice == "OvisOCR2":
|
| 45 |
+
choice = "OvisOCR2"
|
| 46 |
+
elif model_choice == "GLM-OCR":
|
| 47 |
+
choice = "GLM-OCR"
|
| 48 |
+
else:
|
| 49 |
+
raise ValueError(f"Unsupported model_choice: {model_choice}")
|
| 50 |
+
|
| 51 |
+
path = _file_path(image_path)
|
| 52 |
+
document_type, total_pages = document_info(path)
|
| 53 |
+
page_index = int(page_index)
|
| 54 |
+
if page_index < 0 or page_index >= total_pages:
|
| 55 |
+
raise ValueError(
|
| 56 |
+
f"Requested PDF page {page_index + 1}, but this document has {total_pages} pages."
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
requested_count = max(1, min(PAGES_PER_GPU_REQUEST, int(page_count)))
|
| 60 |
+
batch_end_index = min(total_pages, page_index + requested_count)
|
| 61 |
+
batch_start_page = page_index + 1
|
| 62 |
+
batch_end_page = batch_end_index
|
| 63 |
+
completed_pages: list[dict[str, Any]] = []
|
| 64 |
+
active_prompt = resolve_prompt(choice, prompt)
|
| 65 |
+
backend_name = "mock" if TEST_MODE else "transformers"
|
| 66 |
+
|
| 67 |
+
print(
|
| 68 |
+
f"[ocr] {choice} batch start pages {batch_start_page}-{batch_end_page}/{total_pages}",
|
| 69 |
+
flush=True,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
for current_index in range(page_index, batch_end_index):
|
| 73 |
+
page_number = current_index + 1
|
| 74 |
+
page_image = load_document_page(path, document_type, current_index)
|
| 75 |
+
page_started = time.perf_counter()
|
| 76 |
+
current = {
|
| 77 |
+
"page_number": page_number,
|
| 78 |
+
"markdown": "",
|
| 79 |
+
"render_markdown": "",
|
| 80 |
+
"status": "streaming",
|
| 81 |
+
"elapsed_seconds": 0.0,
|
| 82 |
+
}
|
| 83 |
+
yield stream_payload(
|
| 84 |
+
event="page_start",
|
| 85 |
+
pages=[current],
|
| 86 |
+
current_page=page_number,
|
| 87 |
+
total_pages=total_pages,
|
| 88 |
+
document_type=document_type,
|
| 89 |
+
started=started,
|
| 90 |
+
model_choice=choice,
|
| 91 |
+
page_preview=page_preview_data_uri(page_image),
|
| 92 |
+
batch_start_page=batch_start_page,
|
| 93 |
+
batch_end_page=batch_end_page,
|
| 94 |
+
backend=backend_name,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
if choice == "OvisOCR2":
|
| 98 |
+
stream = ovis_adapter.infer_stream(page_image, active_prompt)
|
| 99 |
+
elif choice == "GLM-OCR":
|
| 100 |
+
stream = glm_adapter.infer_stream(page_image, active_prompt)
|
| 101 |
+
else:
|
| 102 |
+
assert_never(choice)
|
| 103 |
+
|
| 104 |
+
markdown = ""
|
| 105 |
+
for partial in stream:
|
| 106 |
+
markdown = partial
|
| 107 |
+
if choice == "OvisOCR2":
|
| 108 |
+
render = stream_safe_markdown(markdown)
|
| 109 |
+
else:
|
| 110 |
+
render = markdown
|
| 111 |
+
current = {
|
| 112 |
+
"page_number": page_number,
|
| 113 |
+
"markdown": markdown,
|
| 114 |
+
"render_markdown": render,
|
| 115 |
+
"status": "streaming",
|
| 116 |
+
"elapsed_seconds": round(time.perf_counter() - page_started, 3),
|
| 117 |
+
}
|
| 118 |
+
yield stream_payload(
|
| 119 |
+
event="stream",
|
| 120 |
+
pages=[current],
|
| 121 |
+
current_page=page_number,
|
| 122 |
+
total_pages=total_pages,
|
| 123 |
+
document_type=document_type,
|
| 124 |
+
started=started,
|
| 125 |
+
model_choice=choice,
|
| 126 |
+
batch_start_page=batch_start_page,
|
| 127 |
+
batch_end_page=batch_end_page,
|
| 128 |
+
backend=backend_name,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
markdown = clean_truncated_repeats(markdown.strip())
|
| 132 |
+
if not markdown:
|
| 133 |
+
raise RuntimeError(f"The model returned an empty result for page {page_number}.")
|
| 134 |
+
|
| 135 |
+
if choice == "OvisOCR2":
|
| 136 |
+
render_final = materialize_bbox_images(markdown, page_image)
|
| 137 |
+
elif choice == "GLM-OCR":
|
| 138 |
+
render_final = markdown
|
| 139 |
+
else:
|
| 140 |
+
assert_never(choice)
|
| 141 |
+
|
| 142 |
+
completed_page = {
|
| 143 |
+
"page_number": page_number,
|
| 144 |
+
"markdown": markdown,
|
| 145 |
+
"render_markdown": render_final,
|
| 146 |
+
"status": "complete",
|
| 147 |
+
"elapsed_seconds": round(time.perf_counter() - page_started, 3),
|
| 148 |
+
}
|
| 149 |
+
completed_pages.append(completed_page)
|
| 150 |
+
print(
|
| 151 |
+
f"[ocr] page {page_number}/{total_pages} complete "
|
| 152 |
+
f"({len(markdown)} chars, {completed_page['elapsed_seconds']}s)",
|
| 153 |
+
flush=True,
|
| 154 |
+
)
|
| 155 |
+
yield stream_payload(
|
| 156 |
+
event="page_complete",
|
| 157 |
+
pages=[completed_page],
|
| 158 |
+
current_page=page_number,
|
| 159 |
+
total_pages=total_pages,
|
| 160 |
+
document_type=document_type,
|
| 161 |
+
started=started,
|
| 162 |
+
model_choice=choice,
|
| 163 |
+
batch_start_page=batch_start_page,
|
| 164 |
+
batch_end_page=batch_end_page,
|
| 165 |
+
backend=backend_name,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
print(
|
| 169 |
+
f"[ocr] batch complete pages {batch_start_page}-{batch_end_page}/{total_pages}",
|
| 170 |
+
flush=True,
|
| 171 |
+
)
|
| 172 |
+
yield stream_payload(
|
| 173 |
+
event="complete",
|
| 174 |
+
pages=completed_pages,
|
| 175 |
+
current_page=batch_end_page,
|
| 176 |
+
total_pages=total_pages,
|
| 177 |
+
document_type=document_type,
|
| 178 |
+
started=started,
|
| 179 |
+
model_choice=choice,
|
| 180 |
+
batch_complete=True,
|
| 181 |
+
batch_start_page=batch_start_page,
|
| 182 |
+
batch_end_page=batch_end_page,
|
| 183 |
+
backend=backend_name,
|
| 184 |
+
)
|
backend/spaces_shim.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Callable
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
import spaces # Must be imported before torch on Hugging Face ZeroGPU.
|
| 9 |
+
except ImportError: # Local development uses a no-op decorator.
|
| 10 |
+
|
| 11 |
+
class _LocalSpaces:
|
| 12 |
+
@staticmethod
|
| 13 |
+
def GPU(*decorator_args: Any, **decorator_kwargs: Any) -> Callable:
|
| 14 |
+
def decorate(function: Callable) -> Callable:
|
| 15 |
+
return function
|
| 16 |
+
|
| 17 |
+
if decorator_args and callable(decorator_args[0]) and len(decorator_args) == 1:
|
| 18 |
+
return decorator_args[0]
|
| 19 |
+
return decorate
|
| 20 |
+
|
| 21 |
+
spaces = _LocalSpaces() # type: ignore[assignment]
|
| 22 |
+
|
| 23 |
+
__all__ = ["spaces"]
|
backend/stream_protocol.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from backend.config import ModelChoice, hub_id_for
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def combine_pages(pages: list[dict[str, Any]], field: str) -> str:
|
| 10 |
+
if len(pages) <= 1:
|
| 11 |
+
return pages[0].get(field, "") if pages else ""
|
| 12 |
+
return "\n\n---\n\n".join(
|
| 13 |
+
f"<!-- Page {page['page_number']} -->\n\n{page.get(field, '')}" for page in pages
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def stream_payload(
|
| 18 |
+
*,
|
| 19 |
+
event: str,
|
| 20 |
+
pages: list[dict[str, Any]],
|
| 21 |
+
current_page: int,
|
| 22 |
+
total_pages: int,
|
| 23 |
+
document_type: str,
|
| 24 |
+
started: float,
|
| 25 |
+
model_choice: ModelChoice,
|
| 26 |
+
page_preview: str | None = None,
|
| 27 |
+
batch_complete: bool = False,
|
| 28 |
+
batch_start_page: int | None = None,
|
| 29 |
+
batch_end_page: int | None = None,
|
| 30 |
+
backend: str = "transformers",
|
| 31 |
+
) -> dict[str, Any]:
|
| 32 |
+
return {
|
| 33 |
+
"event": event,
|
| 34 |
+
"markdown": combine_pages(pages, "markdown"),
|
| 35 |
+
"render_markdown": combine_pages(pages, "render_markdown"),
|
| 36 |
+
"pages": pages,
|
| 37 |
+
"current_page": current_page,
|
| 38 |
+
"total_pages": total_pages,
|
| 39 |
+
"document_type": document_type,
|
| 40 |
+
"page_preview": page_preview,
|
| 41 |
+
"batch_complete": batch_complete,
|
| 42 |
+
"batch_start_page": batch_start_page,
|
| 43 |
+
"batch_end_page": batch_end_page,
|
| 44 |
+
"char_count": sum(len(page.get("markdown", "")) for page in pages),
|
| 45 |
+
"elapsed_seconds": round(time.perf_counter() - started, 3),
|
| 46 |
+
"model": hub_id_for(model_choice),
|
| 47 |
+
"model_choice": model_choice,
|
| 48 |
+
"backend": backend,
|
| 49 |
+
"mode": "base",
|
| 50 |
+
}
|
dist/assets/__vite-browser-external-DYxpcVy9-BIHI7g3E.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
const e={};export{e as default};
|
dist/assets/index-VoO5HFYT.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
@import"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap";:root{--bg0: #071820;--bg1: #0b3d4a;--bg2: #125a63;--ink: #f2f7f8;--muted: #a7c2c7;--accent: #2ec4b6;--accent-2: #f4a261;--danger: #e76f51;--panel: rgba(8, 28, 34, .72);--line: rgba(174, 214, 220, .22);--shadow: 0 24px 60px rgba(0, 0, 0, .35);font-family:Manrope,sans-serif;color:var(--ink);background:var(--bg0)}*{box-sizing:border-box}html,body,#root{margin:0;min-height:100%}body{min-height:100vh;background:radial-gradient(1200px 600px at 10% -10%,rgba(46,196,182,.18),transparent 60%),radial-gradient(900px 500px at 90% 0%,rgba(244,162,97,.12),transparent 55%),linear-gradient(165deg,#071820,#0b3d4a 48%,#083038);background-attachment:fixed}.app{max-width:1400px;margin:0 auto;padding:2rem 1.25rem 3rem}.hero{margin-bottom:1.5rem}.eyebrow{margin:0 0 .5rem;letter-spacing:.12em;text-transform:uppercase;font-size:.75rem;color:var(--accent);font-weight:700}h1{margin:0;font-size:clamp(2rem,4vw,3.4rem);line-height:1.05;font-weight:800;letter-spacing:-.03em;max-width:16ch}.subtitle{margin:.85rem 0 0;max-width:42rem;color:var(--muted);font-size:1.05rem}.guidance{margin:.65rem 0 0;color:var(--ink);font-weight:600}.cta-row{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;margin-top:1.25rem}.btn{border:0;border-radius:999px;padding:.7rem 1.2rem;font:inherit;font-weight:700;cursor:pointer;transition:transform .15s ease,opacity .15s ease}.btn:disabled{opacity:.45;cursor:not-allowed}.btn:not(:disabled):hover{transform:translateY(-1px)}.btn.primary{background:var(--accent);color:#042229}.btn.secondary{background:transparent;color:var(--ink);border:1px solid var(--line)}.btn.danger{background:var(--danger);color:#fff}.model-toggle,.view-toggle{display:inline-flex;border:1px solid var(--line);border-radius:999px;overflow:hidden;background:#0003}.model-toggle button,.view-toggle button{border:0;background:transparent;color:var(--muted);font:inherit;font-weight:650;padding:.65rem .95rem;cursor:pointer}.model-toggle button.active,.view-toggle button.active{background:#2ec4b633;color:var(--ink)}.prompt-toggle{margin-top:.9rem;background:none;border:0;color:var(--muted);font:inherit;text-decoration:underline;cursor:pointer;padding:0}.prompt-box{display:block;width:min(100%,52rem);margin-top:.65rem;border-radius:12px;border:1px solid var(--line);background:#00000047;color:var(--ink);padding:.85rem 1rem;font:inherit;resize:vertical}.status{margin:.9rem 0 0;color:var(--accent-2);font-weight:600}.workspace{display:grid;grid-template-columns:minmax(280px,.9fr) minmax(320px,1.1fr);gap:1rem;min-height:min(70vh,820px)}.pane{background:var(--panel);border:1px solid var(--line);border-radius:18px;box-shadow:var(--shadow);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);overflow:hidden;display:flex;flex-direction:column;min-height:520px}.pane-label{font-size:.75rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:700}.preview-pane{padding:1rem}.preview-image{width:100%;max-height:62vh;object-fit:contain;border-radius:12px;background:#00000040;margin-top:.75rem}.preview-fallback{margin-top:.75rem;min-height:280px;display:grid;place-content:center;gap:.4rem;text-align:center;color:var(--muted);border:1px dashed var(--line);border-radius:12px;padding:1rem}.examples{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:auto;padding-top:1rem}.example-chip{border:1px solid var(--line);background:#ffffff0a;color:var(--ink);border-radius:999px;padding:.4rem .8rem;font:inherit;font-size:.85rem;cursor:pointer}.example-chip:hover:not(:disabled){border-color:var(--accent)}.result-toolbar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:1rem 1rem .5rem}.markdown-source,.markdown-render{margin:0;padding:1rem 1.1rem 1.4rem;overflow:auto;flex:1}.markdown-source{font-family:JetBrains Mono,ui-monospace,monospace;font-size:.82rem;line-height:1.55;white-space:pre-wrap;color:#d7ecef}.markdown-render{line-height:1.65}.markdown-render :is(h1,h2,h3){line-height:1.2;margin:1rem 0 .5rem}.markdown-render table{border-collapse:collapse;width:100%;margin:.75rem 0;font-size:.92rem}.markdown-render th,.markdown-render td{border:1px solid var(--line);padding:.4rem .55rem}.markdown-render img{max-width:100%;border-radius:8px;margin:.75rem 0}.markdown-render .visual-placeholder{padding:.75rem;border:1px dashed var(--line);border-radius:8px;color:var(--muted);margin:.75rem 0}.markdown-render .muted{color:var(--muted)}@media(max-width:960px){.workspace{grid-template-columns:1fr}h1{max-width:none}}
|
dist/assets/index-hmRzNS5f.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/brand/mark.png
ADDED
|
Git LFS Details
|
dist/examples/handwriting-sample.jpg
ADDED
|
Git LFS Details
|
dist/examples/paper-sample.png
ADDED
|
Git LFS Details
|
dist/examples/table-sample.webp
ADDED
|
Git LFS Details
|
dist/favicon.ico
ADDED
|
|
Git LFS Details
|
dist/index.html
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<meta name="theme-color" content="#0b3d4a" />
|
| 7 |
+
<meta
|
| 8 |
+
name="description"
|
| 9 |
+
content="Streaming OCR for Ebola contact-tracing documents — OvisOCR2 and GLM-OCR."
|
| 10 |
+
/>
|
| 11 |
+
<link rel="icon" href="./favicon.ico" />
|
| 12 |
+
<title>OCR Capacity Building as a Service</title>
|
| 13 |
+
<script>
|
| 14 |
+
window.MathJax = {
|
| 15 |
+
options: {
|
| 16 |
+
skipHtmlTags: [
|
| 17 |
+
"script",
|
| 18 |
+
"noscript",
|
| 19 |
+
"style",
|
| 20 |
+
"textarea",
|
| 21 |
+
"code",
|
| 22 |
+
"annotation",
|
| 23 |
+
"annotation-xml",
|
| 24 |
+
],
|
| 25 |
+
},
|
| 26 |
+
tex: {
|
| 27 |
+
inlineMath: [
|
| 28 |
+
["\\(", "\\)"],
|
| 29 |
+
["$", "$"],
|
| 30 |
+
],
|
| 31 |
+
displayMath: [
|
| 32 |
+
["\\[", "\\]"],
|
| 33 |
+
],
|
| 34 |
+
processEscapes: true,
|
| 35 |
+
},
|
| 36 |
+
chtml: { matchFontHeight: false },
|
| 37 |
+
startup: { typeset: false },
|
| 38 |
+
};
|
| 39 |
+
</script>
|
| 40 |
+
<script defer src="./vendor/mathjax/tex-chtml-full.js"></script>
|
| 41 |
+
<script type="module" crossorigin src="./assets/index-hmRzNS5f.js"></script>
|
| 42 |
+
<link rel="stylesheet" crossorigin href="./assets/index-VoO5HFYT.css">
|
| 43 |
+
</head>
|
| 44 |
+
<body>
|
| 45 |
+
<div id="root"></div>
|
| 46 |
+
</body>
|
| 47 |
+
</html>
|
dist/vendor/mathjax/output/chtml/fonts/tex.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_AMS-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3de784d07b9fa8f104c10928a878ee879cf3305cae5195cba663c9c2bb0195eb
|
| 3 |
+
size 40808
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Bold.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:af04542b29eaac04550a140c5f1760a649783989426f2540855bf4157819367d
|
| 3 |
+
size 9908
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:26683bf201fb258a2237d9754616de9d4ecf4cc1cd39dd1902476df7d75f1d16
|
| 3 |
+
size 9600
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Fraktur-Bold.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:721921bab0d001ebff0206c24cb5de6ca136467bf1843a7aa32030ba061d1e92
|
| 3 |
+
size 22340
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Fraktur-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:870673df72e70f87c91a5a317d558c2c3b54392264ad79bbadc6d424ae8765fe
|
| 3 |
+
size 21480
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Bold.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:88b98cad3688915e50da55553ff6ad185e0dce134b47f176e91b100f8a9b175c
|
| 3 |
+
size 34464
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Italic.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:355254db9ca10a09a3b5f0929d74eb4670f44fbe864c07526a06213e0a0caf6c
|
| 3 |
+
size 20832
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1cb1c39ea642f26a4dfed230b4aea1c3c218689421f6e9c0a7c1811693c4fa07
|
| 3 |
+
size 34160
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-BoldItalic.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8ea8dbb1b02e6f730f55b4cb5d413b785b9f5c39807d0a0fa7da206a0824a457
|
| 3 |
+
size 19776
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-Italic.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a009bea404f7a500ded48f8b9ad9cf16e12504b3195dd9e25975289b8256b0f0
|
| 3 |
+
size 19360
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c01d3321e89b403c4b811aa153c4e618eda3421f92d8a072a02c8d190782a191
|
| 3 |
+
size 19288
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Bold.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:32792104b5ef69eded905b6d1598ed05d8087684d38e7a94d52e3c38ba16f47e
|
| 3 |
+
size 15944
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Italic.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fc6ddf5df402b263cfb158aed8e89972542c34b719cd87b1db30461985f7bd5b
|
| 3 |
+
size 14628
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b418136e3b384baaadecb70bd3c48a8da9825210130b2897db808c44efc883cb
|
| 3 |
+
size 12660
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Script-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:af96f67d7accf5fd2a4a682d0b9f8b339f8ea6fe34c310c1694c8ba7f6ddc96f
|
| 3 |
+
size 11852
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size1-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c49810b53ecc0d87d8028762c518924197dd9d3f905b08f99ea241301085b9cb
|
| 3 |
+
size 5792
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size2-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:30e889b58cbc51adfbb038ab1a96dc4025aa3542a3cff7712fc55ece510675e2
|
| 3 |
+
size 5464
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size3-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5cda41563a095bd70c78e2dda13d0f8cb922c71c90fffd0a0044c65173a66e83
|
| 3 |
+
size 3244
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size4-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3bc6ecaae7ecf6f8d7f8ea07bddbaca8601e2cb6e89d6aef58e78d9f6d8a398f
|
| 3 |
+
size 5148
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Typewriter-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c56da8d69f1a0208b8e0703656c3264b6dd748bd452524c82b0385b60f6a68c1
|
| 3 |
+
size 17604
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Vector-Bold.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:36e0d72d8a7afc696a3e7a5c7369807634640de9b02257bca447bdf126221a27
|
| 3 |
+
size 1116
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Vector-Regular.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:72bc573386dd1d48c5bbd286302ca9e3400c5eb0f298e7cfbf945b9d08fc688f
|
| 3 |
+
size 1136
|
dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Zero.woff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:481e39042508ae313a60618af1e37146ab93e9324c98e4c78b8f17fe55d41e0b
|
| 3 |
+
size 1368
|
dist/vendor/mathjax/tex-chtml-full.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/index.html
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<meta name="theme-color" content="#0b3d4a" />
|
| 7 |
+
<meta
|
| 8 |
+
name="description"
|
| 9 |
+
content="Streaming OCR for Ebola contact-tracing documents — OvisOCR2 and GLM-OCR."
|
| 10 |
+
/>
|
| 11 |
+
<link rel="icon" href="./favicon.ico" />
|
| 12 |
+
<title>OCR Capacity Building as a Service</title>
|
| 13 |
+
<script>
|
| 14 |
+
window.MathJax = {
|
| 15 |
+
options: {
|
| 16 |
+
skipHtmlTags: [
|
| 17 |
+
"script",
|
| 18 |
+
"noscript",
|
| 19 |
+
"style",
|
| 20 |
+
"textarea",
|
| 21 |
+
"code",
|
| 22 |
+
"annotation",
|
| 23 |
+
"annotation-xml",
|
| 24 |
+
],
|
| 25 |
+
},
|
| 26 |
+
tex: {
|
| 27 |
+
inlineMath: [
|
| 28 |
+
["\\(", "\\)"],
|
| 29 |
+
["$", "$"],
|
| 30 |
+
],
|
| 31 |
+
displayMath: [
|
| 32 |
+
["\\[", "\\]"],
|
| 33 |
+
],
|
| 34 |
+
processEscapes: true,
|
| 35 |
+
},
|
| 36 |
+
chtml: { matchFontHeight: false },
|
| 37 |
+
startup: { typeset: false },
|
| 38 |
+
};
|
| 39 |
+
</script>
|
| 40 |
+
<script defer src="./vendor/mathjax/tex-chtml-full.js"></script>
|
| 41 |
+
</head>
|
| 42 |
+
<body>
|
| 43 |
+
<div id="root"></div>
|
| 44 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 45 |
+
</body>
|
| 46 |
+
</html>
|