Joseph Pollack commited on
Commit
8839278
·
unverified ·
1 Parent(s): 4ceb344

Track image and font assets with Git LFS so Hugging Face Spaces accepts the push.

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +7 -0
  2. .gitignore +6 -0
  3. README.md +75 -6
  4. app.py +232 -0
  5. backend/__init__.py +1 -0
  6. backend/adapters/__init__.py +1 -0
  7. backend/adapters/glm.py +123 -0
  8. backend/adapters/ovis.py +107 -0
  9. backend/bbox.py +94 -0
  10. backend/config.py +97 -0
  11. backend/documents.py +61 -0
  12. backend/load_models.py +61 -0
  13. backend/orchestrator.py +184 -0
  14. backend/spaces_shim.py +23 -0
  15. backend/stream_protocol.py +50 -0
  16. dist/assets/__vite-browser-external-DYxpcVy9-BIHI7g3E.js +1 -0
  17. dist/assets/index-VoO5HFYT.css +1 -0
  18. dist/assets/index-hmRzNS5f.js +0 -0
  19. dist/brand/mark.png +3 -0
  20. dist/examples/handwriting-sample.jpg +3 -0
  21. dist/examples/paper-sample.png +3 -0
  22. dist/examples/table-sample.webp +3 -0
  23. dist/favicon.ico +3 -0
  24. dist/index.html +47 -0
  25. dist/vendor/mathjax/output/chtml/fonts/tex.js +0 -0
  26. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_AMS-Regular.woff +3 -0
  27. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Bold.woff +3 -0
  28. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Calligraphic-Regular.woff +3 -0
  29. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Fraktur-Bold.woff +3 -0
  30. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Fraktur-Regular.woff +3 -0
  31. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Bold.woff +3 -0
  32. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Italic.woff +3 -0
  33. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Main-Regular.woff +3 -0
  34. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-BoldItalic.woff +3 -0
  35. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-Italic.woff +3 -0
  36. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Math-Regular.woff +3 -0
  37. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Bold.woff +3 -0
  38. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Italic.woff +3 -0
  39. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_SansSerif-Regular.woff +3 -0
  40. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Script-Regular.woff +3 -0
  41. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size1-Regular.woff +3 -0
  42. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size2-Regular.woff +3 -0
  43. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size3-Regular.woff +3 -0
  44. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Size4-Regular.woff +3 -0
  45. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Typewriter-Regular.woff +3 -0
  46. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Vector-Bold.woff +3 -0
  47. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Vector-Regular.woff +3 -0
  48. dist/vendor/mathjax/output/chtml/fonts/woff-v2/MathJax_Zero.woff +3 -0
  49. dist/vendor/mathjax/tex-chtml-full.js +0 -0
  50. 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: Ocr Ebola
3
- emoji: 🏢
4
  colorFrom: blue
5
- colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.20.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
- short_description: open api for ebola contact tracing in `DRCongo`
 
 
 
 
 
 
 
 
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: 3596b42289ca16eed1a84492613cba17702e87e3a08f197e83dea9cb57b91ff1
  • Pointer size: 130 Bytes
  • Size of remote file: 16.2 kB
dist/examples/handwriting-sample.jpg ADDED

Git LFS Details

  • SHA256: 6f02262a295680b5ea217409ebe4b61127323d69491132621d3fc4f7e824abd2
  • Pointer size: 131 Bytes
  • Size of remote file: 565 kB
dist/examples/paper-sample.png ADDED

Git LFS Details

  • SHA256: fc92cd29cd1bb164b3b2cc434a5e6f9325a20b3c719c45d9051fa9f854f8aa04
  • Pointer size: 131 Bytes
  • Size of remote file: 293 kB
dist/examples/table-sample.webp ADDED

Git LFS Details

  • SHA256: 0382fd496240def76152256d35e8f4d402c71db42fa10ba37d6dee2dfe68de73
  • Pointer size: 130 Bytes
  • Size of remote file: 85 kB
dist/favicon.ico ADDED

Git LFS Details

  • SHA256: dacfefd09ca9b6bf540d5982772cc79a92a96c743eab0a9d4dcb49249bd7b5f9
  • Pointer size: 130 Bytes
  • Size of remote file: 41.4 kB
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>