ssdataanalysis commited on
Commit
ce10d18
·
verified ·
1 Parent(s): 7a9d7ba

Fix README content mapping

Browse files
Files changed (1) hide show
  1. README.md +135 -1683
README.md CHANGED
@@ -1,1683 +1,135 @@
1
- import base64
2
- import gc
3
- import json
4
- import os
5
- import re
6
- import time
7
- from concurrent.futures import ThreadPoolExecutor, as_completed
8
- from dataclasses import dataclass
9
- from io import BytesIO
10
- from collections import OrderedDict
11
- from collections.abc import Mapping
12
- import threading
13
- from typing import Dict, List, Optional, Tuple
14
-
15
- import gradio as gr
16
- from PIL import Image
17
-
18
- try:
19
- import transformers
20
- hf_pipeline = transformers.pipeline # pragma: no cover
21
- AutoImageProcessor = getattr(transformers, "AutoImageProcessor", None) # pragma: no cover
22
- AutoModel = getattr(transformers, "AutoModel", None) # pragma: no cover
23
- AutoModelForCausalLM = getattr(transformers, "AutoModelForCausalLM", None) # pragma: no cover
24
- AutoModelForImageTextToText = getattr(transformers, "AutoModelForImageTextToText", None) # pragma: no cover
25
- AutoModelForSeq2SeqLM = getattr(transformers, "AutoModelForSeq2SeqLM", None) # pragma: no cover
26
- AutoModelForVision2Seq = getattr(transformers, "AutoModelForVision2Seq", None) # pragma: no cover
27
- AutoModelForVisionEncoderDecoder = getattr(transformers, "AutoModelForVisionEncoderDecoder", None) # pragma: no cover
28
- AutoModelForConditionalGeneration = getattr(transformers, "AutoModelForConditionalGeneration", None) # pragma: no cover
29
- AutoModelForDocumentQuestionAnswering = getattr(transformers, "AutoModelForDocumentQuestionAnswering", None) # pragma: no cover
30
- AutoTokenizer = getattr(transformers, "AutoTokenizer", None) # pragma: no cover
31
- AutoProcessor = getattr(transformers, "AutoProcessor", None) # pragma: no cover
32
- AutoConfig = getattr(transformers, "AutoConfig", None) # pragma: no cover
33
- TRANSFORMERS_IMPORT_ERROR = None # pragma: no cover
34
- except Exception as exc: # pragma: no cover
35
- hf_pipeline = None # pragma: no cover
36
- AutoImageProcessor = None # pragma: no cover
37
- AutoModel = None # pragma: no cover
38
- AutoModelForCausalLM = None # pragma: no cover
39
- AutoModelForImageTextToText = None # pragma: no cover
40
- AutoModelForSeq2SeqLM = None # pragma: no cover
41
- AutoModelForVision2Seq = None # pragma: no cover
42
- AutoModelForVisionEncoderDecoder = None # pragma: no cover
43
- AutoModelForConditionalGeneration = None # pragma: no cover
44
- AutoModelForDocumentQuestionAnswering = None # pragma: no cover
45
- AutoTokenizer = None # pragma: no cover
46
- AutoProcessor = None # pragma: no cover
47
- AutoConfig = None # pragma: no cover
48
- TRANSFORMERS_IMPORT_ERROR = repr(exc)
49
-
50
- try:
51
- import torch
52
- except Exception: # pragma: no cover
53
- torch = None # pragma: no cover
54
-
55
- try:
56
- import pytesseract
57
- except Exception: # pragma: no cover
58
- pytesseract = None
59
-
60
- try:
61
- import spaces
62
- except Exception: # pragma: no cover
63
- spaces = None
64
-
65
- try:
66
- from starlette.templating import Jinja2Templates
67
-
68
- _original_get_template = Jinja2Templates.get_template
69
- _original_template_response = Jinja2Templates.TemplateResponse
70
-
71
- def _normalize_template_name(template_name):
72
- if isinstance(template_name, dict):
73
- template_name = template_name.get("template_name", None) or template_name.get("name", None)
74
- if isinstance(template_name, (list, tuple)):
75
- template_name = template_name[0] if template_name else None
76
- if not template_name:
77
- template_name = "frontend/index.html"
78
- if template_name in {"index.html", "share.html"}:
79
- template_name = f"frontend/{template_name}"
80
- return template_name or "frontend/index.html"
81
-
82
- def _safe_get_template(self, name, *args, **kwargs):
83
- if isinstance(name, dict):
84
- return _original_get_template(self, _normalize_template_name(name), *args, **kwargs)
85
- return _original_get_template(self, name, *args, **kwargs)
86
-
87
- def _safe_template_response(self, name, context=None, *args, **kwargs):
88
- template_name = name
89
- request = kwargs.pop("request", None)
90
- if hasattr(name, "scope"):
91
- request = name
92
- template_name = context
93
- context = args[0] if args else None
94
- args = args[1:] if args else ()
95
- elif request is None and isinstance(context, Mapping):
96
- request = context.get("request")
97
- if not request and args:
98
- request = args[0] if hasattr(args[0], "scope") else None
99
- if request is not None:
100
- args = args[1:]
101
-
102
- if isinstance(template_name, dict):
103
- template_name = _normalize_template_name(template_name)
104
-
105
- try:
106
- if context is None:
107
- context = {}
108
- elif isinstance(context, Mapping):
109
- context = dict(context)
110
- else:
111
- context = dict(context)
112
- except Exception:
113
- context = {}
114
-
115
- if isinstance(context, dict):
116
- context.setdefault(
117
- "config",
118
- {
119
- "body_css": {},
120
- "title": "Hebrew OCR Document Comparator",
121
- "simple_description": "Private OCR comparator for Hebrew documents",
122
- "thumbnail": "",
123
- },
124
- )
125
- context.setdefault("gradio_api_info", {})
126
-
127
- if request is None:
128
- return _original_template_response(self, template_name, context, *args, **kwargs)
129
- if args:
130
- return _original_template_response(self, request, template_name, context, *args, **kwargs)
131
- return _original_template_response(self, request, template_name, context=context, *args, **kwargs)
132
-
133
- Jinja2Templates.get_template = _safe_get_template
134
- Jinja2Templates.TemplateResponse = _safe_template_response
135
- except Exception:
136
- pass
137
-
138
-
139
- if spaces is not None:
140
- @spaces.GPU
141
- def _ensure_zero_gpu_lease() -> None:
142
- return None
143
-
144
- ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
145
- REGISTRY_PATH = os.path.join(ROOT_DIR, "model_registry.json")
146
- DEFAULT_OCR_PROMPT = (
147
- "You are a high-accuracy OCR engine for Hebrew documents that may contain printed and handwritten text. "
148
- "Return the exact document text in Hebrew, preserving line breaks and spacing. "
149
- "Do not add explanation or JSON."
150
- )
151
- ZERO_GPU_MAX_WORKERS = 4
152
- ZERO_GPU_SAFE_HEADROOM = 0.62
153
- ZERO_GPU_MAX_HEADROOM = 0.82
154
- ZERO_GPU_MIN_GUARDED_FREE_GB = 1.0
155
- ZERO_GPU_MODEL_FALLBACK_GB_SAFE = 2.6
156
- ZERO_GPU_MODEL_FALLBACK_GB_MAX = 2.0
157
- ZERO_GPU_MAX_SAFE_SELECTED = 6
158
- DEFAULT_MAX_TOKENS = 4096
159
- DEFAULT_PRECHECK_TIMEOUT_SEC = 12
160
- DIRECT_MODEL_CACHE_MAX = 1
161
- LOCAL_PIPELINE_CACHE_MAX = 1
162
- TESSERACT_EXECUTABLE_PATHS = ("/usr/bin/tesseract", "/usr/local/bin/tesseract", "/opt/conda/bin/tesseract", "/bin/tesseract")
163
-
164
-
165
- def _inference_device() -> int:
166
- if torch is not None and torch.cuda.is_available():
167
- return 0
168
- return -1
169
-
170
-
171
- def _inference_device_label() -> str:
172
- if torch is not None and torch.cuda.is_available():
173
- return "cuda"
174
- return "cpu"
175
-
176
-
177
- def _inference_dtype():
178
- if torch is None:
179
- return None
180
- if hasattr(torch, "bfloat16"):
181
- return torch.bfloat16
182
- if hasattr(torch, "float16"):
183
- return torch.float16
184
- return None
185
-
186
-
187
- def _env_flag_true(name: str, default: bool = False) -> bool:
188
- raw = os.getenv(name)
189
- if raw is None:
190
- return default
191
- return raw.strip().lower() in {"1", "true", "yes", "on", "y"}
192
-
193
-
194
- def _allow_cpu_fallback() -> bool:
195
- return _env_flag_true("ALLOW_CPU_FALLBACK", default=False)
196
-
197
-
198
- def _strict_cuda_required() -> bool:
199
- return not _allow_cpu_fallback()
200
-
201
-
202
- def _ensure_cuda_available(context: str = "Inference") -> None:
203
- if torch is None:
204
- raise RuntimeError(f"{context} requires PyTorch, but PyTorch is not available in this Space.")
205
- if not torch.cuda.is_available():
206
- raise RuntimeError(
207
- f"{context} requires CUDA, but no CUDA device is available. "
208
- "Run this Space on ZeroGPU and unset ALLOW_CPU_FALLBACK, or set ALLOW_CPU_FALLBACK=1."
209
- )
210
-
211
-
212
- def _assert_model_on_cuda(model, model_id: str, context: str) -> None:
213
- if not _strict_cuda_required():
214
- return
215
- if torch is None or not torch.cuda.is_available():
216
- _ensure_cuda_available(context)
217
-
218
- if model is None:
219
- raise RuntimeError(f"{context}: loaded model is missing for {model_id}.")
220
-
221
- try:
222
- device_map = getattr(model, "hf_device_map", None)
223
- if isinstance(device_map, Mapping):
224
- non_cuda = {
225
- str(value)
226
- for value in device_map.values()
227
- if str(value) not in {"0", "cuda", "cuda:0", "cuda:1", "cuda:2", "cuda:3"}
228
- and str(value) != "None"
229
- }
230
- if non_cuda:
231
- raise RuntimeError(f"{context}: {model_id} model-map is not on CUDA (found: {sorted(non_cuda)}).")
232
- except Exception:
233
- # If this check itself fails, continue to tensor-level validation below.
234
- pass
235
-
236
- try:
237
- labels = {str(getattr(param, "device", "")) for param in model.parameters() if hasattr(param, "device")}
238
- if not labels:
239
- return
240
- non_cuda = [label for label in labels if not str(label).startswith("cuda") and str(label) != "0"]
241
- if non_cuda:
242
- raise RuntimeError(
243
- f"{context}: {model_id} is not on CUDA (tensor devices: {sorted(labels)}). "
244
- "Set ALLOW_CPU_FALLBACK=1 only if you want CPU execution."
245
- )
246
- except Exception:
247
- # If model has no parameters or doesn't expose devices this way, don't block.
248
- return
249
-
250
-
251
- def _inference_torch_kwargs_for_model(strict_cuda: bool = False) -> Tuple[dict, list]:
252
- kwargs_priority = []
253
- dtype = _inference_dtype()
254
- if dtype is None:
255
- kwargs_priority.append({})
256
- return dtype, kwargs_priority
257
-
258
- if strict_cuda and torch is not None and torch.cuda.is_available():
259
- kwargs_priority.append({"torch_dtype": dtype, "low_cpu_mem_usage": True})
260
- if dtype != getattr(torch, "float16", None):
261
- kwargs_priority.append({"torch_dtype": torch.float16, "low_cpu_mem_usage": True})
262
- kwargs_priority.append({"torch_dtype": torch.float16})
263
- kwargs_priority.append({"torch_dtype": dtype})
264
- kwargs_priority.append({})
265
- else:
266
- kwargs_priority.append({"torch_dtype": dtype, "device_map": "auto", "low_cpu_mem_usage": True})
267
- if dtype != getattr(torch, "float16", None):
268
- kwargs_priority.append({"torch_dtype": torch.float16, "device_map": "auto", "low_cpu_mem_usage": True})
269
- kwargs_priority.append({"torch_dtype": torch.float16})
270
- kwargs_priority.append({"torch_dtype": dtype, "device_map": "auto"})
271
- kwargs_priority.append({"torch_dtype": dtype})
272
- kwargs_priority.append({"torch_dtype": torch.float16})
273
- kwargs_priority.append({})
274
- return dtype, kwargs_priority
275
-
276
-
277
- def _pipeline_cache_key(model_id: str, task: str, device: str, dtype: object, trust_remote_code: bool = False) -> Tuple[str, str, str, object, bool]:
278
- return (model_id, task, device, str(dtype), trust_remote_code)
279
-
280
- TRUST_REMOTE_CODE_MODELS = {
281
- "microsoft/Phi-4-multimodal-instruct",
282
- "PaddlePaddle/PaddleOCR-VL",
283
- "PaddlePaddle/PaddleOCR-VL-1.5",
284
- "PaddlePaddle/PaddleOCR-VL-1.6",
285
- "datalab-to/chandra-ocr-2",
286
- "datalab-to/surya-ocr-2",
287
- "ronylicha/gigapdf-ocr-hebrew",
288
- "liskcell/qunie-v7-mini",
289
- "0cve0/openmlkitocr",
290
- "waraja/tzefa-word-ocr-trocr",
291
- "liskcell/qunie-v7-pico",
292
- "cyttic/exp10-trocr-hebrew-matan-full",
293
- "cyttic/exp23-directfit-unfrozen",
294
- "cyttic/heb-verifier17-connected",
295
- "cyttic/exp26-composed1m",
296
- "deepseek-ai/deepseek-ocr",
297
- "deepseek-ai/deepseek-ocr-2",
298
- "coherelabs/aya-vision-8b",
299
- "coherelabs/aya-vision-32b",
300
- }
301
- TRUST_REMOTE_CODE_MODELS = {m.lower() for m in TRUST_REMOTE_CODE_MODELS}
302
-
303
- PIPELINE_TASK_HINTS = {
304
- "qwen/qwen3-vl-8b-instruct": "image-to-text",
305
- "qwen/qwen3-vl-4b-instruct": "image-to-text",
306
- "qwen/qwen3-vl-8b-thinking": "image-to-text",
307
- "qwen/qwen3-vl-4b-thinking": "image-to-text",
308
- "qwen/qwen3-vl-30b-a3b-instruct": "image-to-text",
309
- "qwen/qwen3-vl-30b-a3b-thinking": "image-to-text",
310
- "google/gemma-4-e4b-it": "image-to-text",
311
- "google/gemma-4-12b-it": "image-to-text",
312
- "google/gemma-4-26b-a4b-it": "image-to-text",
313
- "google/gemma-4-31b-it": "image-to-text",
314
- "ronylicha/gigapdf-ocr-hebrew": "image-to-text",
315
- "liskcell/qunie-v7-mini": "image-to-text",
316
- "0cve0/openmlkitocr": "image-to-text",
317
- "waraja/tzefa-word-ocr-trocr": "image-to-text",
318
- "liskcell/qunie-v7-pico": "image-to-text",
319
- "paddlepaddle/paddleocr-vl": "image-to-text",
320
- "paddlepaddle/paddleocr-vl-1.5": "image-to-text",
321
- "paddlepaddle/paddleocr-vl-1.6": "image-to-text",
322
- "datalab-to/chandra-ocr-2": "image-to-text",
323
- "datalab-to/surya-ocr-2": "image-to-text",
324
- "deepseek-ai/deepseek-ocr": "image-to-text",
325
- "deepseek-ai/deepseek-ocr-2": "image-to-text",
326
- "cyttic/exp10-trocr-hebrew-matan-full": "image-to-text",
327
- "cyttic/exp23-directfit-unfrozen": "image-to-text",
328
- "cyttic/heb-verifier17-connected": "image-to-text",
329
- "cyttic/exp26-composed1m": "image-to-text",
330
- }
331
-
332
- GATED_MODELS_REQUIRING_TOKEN = {
333
- "coherelabs/aya-vision-8b",
334
- "coherelabs/aya-vision-32b",
335
- }
336
- GATED_MODELS_REQUIRING_TOKEN = {m.lower() for m in GATED_MODELS_REQUIRING_TOKEN}
337
-
338
- NON_HF_OR_NON_TRANSFORMERS_MODELS = {
339
- "ronylicha/gigapdf-ocr-hebrew": (
340
- "This checkpoint is not a standard Transformers OCR package (ONNX/RTen assets only)."
341
- ),
342
- "liskcell/qunie-v7-mini": (
343
- "This checkpoint appears distributed as a non-standard GGUF/ONNX package and cannot be loaded through "
344
- "local Transformers in this Space."
345
- ),
346
- "liskcell/qunie-v7-pico": (
347
- "This checkpoint appears distributed as a non-standard GGUF/ONNX package and cannot be loaded through "
348
- "local Transformers in this Space."
349
- ),
350
- "0cve0/openmlkitocr": (
351
- "This repository is not structured as a standard Hugging Face Transformers OCR model."
352
- ),
353
- }
354
-
355
- MODEL_PRECHECK_CACHE: Dict[str, Tuple[bool, str]] = {}
356
- _MODEL_PRECHECK_LOCK = threading.Lock()
357
-
358
- _LOCAL_PIPELINE_CACHE: OrderedDict = OrderedDict()
359
- _LOCAL_DIRECT_CACHE: OrderedDict = OrderedDict()
360
- _LOCAL_PIPELINE_LOCK = threading.Lock()
361
- _LOCAL_DIRECT_LOCK = threading.Lock()
362
-
363
-
364
- def load_registry() -> List[dict]:
365
- with open(REGISTRY_PATH, "r", encoding="utf-8") as f:
366
- payload = json.load(f)
367
- return payload.get("models", [])
368
-
369
-
370
- def _safe_load_registry() -> List[dict]:
371
- models = load_registry()
372
- if not isinstance(models, list):
373
- raise RuntimeError("Invalid model_registry.json format: expected a list under 'models'.")
374
- for model in models:
375
- if not isinstance(model, dict):
376
- continue
377
- if "id" not in model:
378
- raise RuntimeError("Invalid registry entry: missing 'id'.")
379
- if "model_id" not in model and model.get("provider") != "tesseract":
380
- raise RuntimeError(f"Invalid registry entry '{model.get('id')}': missing 'model_id'.")
381
- return models
382
-
383
-
384
- def image_to_data_uri(image_bytes: bytes) -> str:
385
- return f"data:image/png;base64,{base64.b64encode(image_bytes).decode('ascii')}"
386
-
387
-
388
- def normalize_text_for_metrics(text: str) -> str:
389
- if text is None:
390
- return ""
391
- text = text.replace("\u200c", "") # remove Hebrew ligature marks
392
- text = re.sub(r"\s+", " ", text.strip())
393
- return text
394
-
395
-
396
- def edit_distance(a: str, b: str) -> int:
397
- if not a:
398
- return len(b)
399
- if not b:
400
- return len(a)
401
- prev = list(range(len(b) + 1))
402
- for i, ca in enumerate(a, 1):
403
- curr = [i] + [0] * len(b)
404
- for j, cb in enumerate(b, 1):
405
- cost = 0 if ca == cb else 1
406
- curr[j] = min(
407
- prev[j] + 1,
408
- curr[j - 1] + 1,
409
- prev[j - 1] + cost,
410
- )
411
- prev = curr
412
- return prev[-1]
413
-
414
-
415
- def compute_cer_wer(reference: str, hypothesis: str) -> Tuple[Optional[float], Optional[float]]:
416
- if not reference:
417
- return None, None
418
- ref_norm = normalize_text_for_metrics(reference)
419
- hyp_norm = normalize_text_for_metrics(hypothesis)
420
- ref_chars = ref_norm
421
- hyp_chars = hyp_norm
422
- ref_words = ref_norm.split(" ") if ref_norm else []
423
- hyp_words = hyp_norm.split(" ") if hyp_norm else []
424
- cer = edit_distance(ref_chars, hyp_chars) / max(1, len(ref_chars))
425
- wer = edit_distance(" ".join(ref_words), " ".join(hyp_words)) / max(1, len(ref_words))
426
- return cer, wer
427
-
428
-
429
- def parse_output(output) -> str:
430
- if output is None:
431
- return ""
432
- if isinstance(output, str):
433
- return output.strip()
434
- if isinstance(output, bytes):
435
- return output.decode("utf-8", errors="ignore").strip()
436
- if isinstance(output, list):
437
- if not output:
438
- return ""
439
- if len(output) == 1:
440
- return parse_output(output[0])
441
- texts = [parse_output(item) for item in output if parse_output(item)]
442
- return "\n\n".join(texts)
443
- if isinstance(output, dict):
444
- for key in ("text", "generated_text", "answer", "output", "prediction"):
445
- if key in output and isinstance(output[key], str):
446
- return output[key].strip()
447
- if "choices" in output and isinstance(output["choices"], list):
448
- choice0 = output["choices"][0]
449
- if isinstance(choice0, dict) and "message" in choice0:
450
- msg = choice0["message"]
451
- if isinstance(msg, dict) and isinstance(msg.get("content"), str):
452
- return msg["content"].strip()
453
- return json.dumps(output, ensure_ascii=False)
454
- if hasattr(output, "choices"):
455
- choice = output.choices[0]
456
- if hasattr(choice, "message") and hasattr(choice.message, "content"):
457
- return (choice.message.content or "").strip()
458
- if isinstance(choice, dict) and isinstance(choice.get("message"), dict):
459
- return str(choice["message"].get("content", "")).strip()
460
- return str(output)
461
-
462
-
463
- def _sanitize_hf_token(hf_token: str) -> str:
464
- if hf_token:
465
- return hf_token.strip()
466
- return os.getenv("HF_TOKEN", "").strip()
467
-
468
-
469
- def _model_id_value(entry: dict) -> str:
470
- return str(entry.get("model_id", "") or "").strip()
471
-
472
-
473
- def _model_id_norm(entry: dict) -> str:
474
- return _model_id_value(entry).lower()
475
-
476
-
477
- def _model_requires_trust_remote_code(entry: dict) -> bool:
478
- if entry.get("trust_remote_code") is True:
479
- return True
480
- model_id = _model_id_norm(entry)
481
- if not model_id:
482
- return False
483
- if model_id in TRUST_REMOTE_CODE_MODELS:
484
- return True
485
- return False
486
-
487
-
488
- def _model_pipeline_task(entry: dict) -> str:
489
- task = (entry.get("pipeline_task") or entry.get("task") or "").strip().lower()
490
- if task and task != "auto":
491
- return task
492
- model_id = _model_id_norm(entry)
493
- return PIPELINE_TASK_HINTS.get(model_id, "auto")
494
-
495
-
496
- def _precheck_cache_key(entry: dict, hf_token: str) -> str:
497
- return f"{_model_id_norm(entry)}|{_sanitize_hf_token(hf_token) or 'anon'}"
498
-
499
-
500
- def _classify_precheck_exception(exc: Exception) -> str:
501
- text = str(exc).lower()
502
- if "gated repo" in text or "requires authentication" in text or "401" in text:
503
- return "Repository is gated. Provide a valid HF token with access."
504
- if "403" in text or "authorization" in text:
505
- return "Authorization failed for this repository. HF token may be missing or lacks access."
506
- if "qwen3_5" in text or "gemma4_unified" in text:
507
- return (
508
- "Model architecture requires a newer Transformers runtime than this Space currently has. "
509
- "Rebuild after updating transformers from source."
510
- )
511
- if "tokenizersbackend" in text:
512
- return (
513
- "Tokenizer backend unavailable in this environment. Rebuild after dependency refresh "
514
- "(tokenizers / transformers)."
515
- )
516
- if "404" in text or "not found" in text or "config.json" in text:
517
- return "Model config is not available in standard Transformers format."
518
- if "could not infer task" in text or "document-question-answering" in text:
519
- return "Model task/config mismatch for current local Transformers runtime."
520
- return _format_dependency_error(exc)
521
-
522
-
523
- def _get_model_precheck(entry: dict, hf_token: str) -> Tuple[bool, str]:
524
- provider = str(entry.get("provider", "local_transformer")).lower()
525
- if provider != "local_transformer":
526
- return True, ""
527
- model_id = _model_id_norm(entry)
528
- if not model_id:
529
- return False, "Missing model_id."
530
- if model_id in NON_HF_OR_NON_TRANSFORMERS_MODELS:
531
- return False, NON_HF_OR_NON_TRANSFORMERS_MODELS[model_id]
532
-
533
- cache_key = _precheck_cache_key(entry, hf_token)
534
- with _MODEL_PRECHECK_LOCK:
535
- cached = MODEL_PRECHECK_CACHE.get(cache_key)
536
- if cached is not None:
537
- return cached
538
-
539
- if AutoConfig is None:
540
- result = (False, "Transformers AutoConfig is not available in this runtime.")
541
- with _MODEL_PRECHECK_LOCK:
542
- MODEL_PRECHECK_CACHE[cache_key] = result
543
- return result
544
-
545
- token = _sanitize_hf_token(hf_token) or None
546
- try:
547
- # Lightweight preflight to reject incompatible repos before heavy pipeline/model loading.
548
- AutoConfig.from_pretrained(
549
- model_id,
550
- token=token,
551
- trust_remote_code=_model_requires_trust_remote_code(entry),
552
- )
553
- except Exception as exc: # pragma: no cover
554
- result = (False, _classify_precheck_exception(exc))
555
- with _MODEL_PRECHECK_LOCK:
556
- MODEL_PRECHECK_CACHE[cache_key] = result
557
- return result
558
-
559
- result = (True, "")
560
- with _MODEL_PRECHECK_LOCK:
561
- MODEL_PRECHECK_CACHE[cache_key] = result
562
- return result
563
-
564
-
565
- def _transformers_runtime_message() -> Optional[str]:
566
- if hf_pipeline is not None:
567
- return None
568
- if TRANSFORMERS_IMPORT_ERROR:
569
- return f"transformers import failed in this Space: {TRANSFORMERS_IMPORT_ERROR}"
570
- return "transformers is not installed in this Space."
571
-
572
-
573
- def _format_dependency_error(exc: Exception) -> str:
574
- text = str(exc)
575
- if "TokenizersBackend" in text and "not currently imported" in text:
576
- return (
577
- "Tokenizer backend import failed. "
578
- "Rebuild after updating `tokenizers` and `transformers` in requirements."
579
- )
580
- if "requires the following packages" in text and "addict" in text:
581
- return "Missing dependency: addict. Rebuild after adding `addict` to requirements."
582
- if "requires the following packages" in text and "torchvision" in text:
583
- return "Missing dependency: torchvision. Rebuild after adding `torchvision` to requirements."
584
- return text
585
-
586
-
587
- def _runtime_dependency_warning() -> Optional[str]:
588
- messages = []
589
- if pytesseract is None:
590
- messages.append("pytesseract is not installed. Install with `pytesseract` in requirements.")
591
- if pytesseract is not None and not _tesseract_binary_available():
592
- messages.append(
593
- "The tesseract binary is not available in PATH. Add tesseract packages to `apt.txt` and rebuild."
594
- )
595
- if torch is None:
596
- messages.append("PyTorch is not installed; local models cannot run.")
597
- return " | ".join(messages) if messages else None
598
-
599
-
600
- def _required_token_message(entry: dict, hf_token: str) -> Optional[str]:
601
- model_id = _model_id_norm(entry)
602
- if model_id in GATED_MODELS_REQUIRING_TOKEN and not _sanitize_hf_token(hf_token):
603
- return (
604
- f"{entry.get('name', model_id)} appears to require an HF token in this space. "
605
- "Set HF_TOKEN to a token with access to the gated model."
606
- )
607
- return None
608
-
609
-
610
- def _tesseract_binary_available() -> bool:
611
- import shutil
612
-
613
- for candidate in TESSERACT_EXECUTABLE_PATHS:
614
- if os.path.exists(candidate):
615
- return True
616
- if shutil.which("tesseract"):
617
- return True
618
- return False
619
-
620
-
621
- def _decode_image(image_bytes: bytes) -> Image.Image:
622
- image = Image.open(BytesIO(image_bytes))
623
- if image.mode != "RGB":
624
- image = image.convert("RGB")
625
- return image
626
-
627
-
628
- def _load_image_bytes(image_file) -> bytes:
629
- if image_file is None:
630
- raise RuntimeError("No file was uploaded.")
631
- if isinstance(image_file, (list, tuple)):
632
- if not image_file:
633
- raise RuntimeError("No file was uploaded.")
634
- image_file = image_file[0]
635
-
636
- if isinstance(image_file, dict):
637
- image_file = (
638
- image_file.get("path")
639
- or image_file.get("name")
640
- or image_file.get("url")
641
- or image_file.get("file_path")
642
- )
643
-
644
- if isinstance(image_file, bytes):
645
- return image_file
646
-
647
- if hasattr(image_file, "read"):
648
- try:
649
- image_bytes = image_file.read()
650
- if image_bytes:
651
- return image_bytes
652
- except Exception as exc:
653
- raise RuntimeError(f"Could not read uploaded file object: {exc}") from exc
654
- raise RuntimeError("Uploaded file object is empty.")
655
-
656
- if isinstance(image_file, Image.Image):
657
- buffer = BytesIO()
658
- image_file.convert("RGB").save(buffer, format="PNG")
659
- return buffer.getvalue()
660
-
661
- if not isinstance(image_file, str):
662
- if hasattr(image_file, "name") and isinstance(getattr(image_file, "name"), str):
663
- image_file = getattr(image_file, "name")
664
- else:
665
- raise RuntimeError("Uploaded image is not a valid file path.")
666
-
667
- if not os.path.exists(image_file):
668
- raise RuntimeError("Uploaded file is missing from disk.")
669
-
670
- filename = os.path.basename(image_file).lower()
671
- if filename.endswith(".pdf"):
672
- raise RuntimeError(
673
- "PDF files are not supported yet in this Space build. Export the document to PNG/JPG and upload again."
674
- )
675
-
676
- try:
677
- with open(image_file, "rb") as f:
678
- return f.read()
679
- except Exception as exc:
680
- raise RuntimeError(f"Could not read uploaded file: {exc}") from exc
681
-
682
-
683
- def _normalize_model_selection(selected_model_ids) -> List[str]:
684
- if selected_model_ids is None:
685
- return []
686
- if isinstance(selected_model_ids, str):
687
- try:
688
- loaded = json.loads(selected_model_ids)
689
- if isinstance(loaded, list):
690
- selected_model_ids = loaded
691
- else:
692
- selected_model_ids = [selected_model_ids]
693
- except Exception:
694
- if "," in selected_model_ids:
695
- parts = [entry.strip() for entry in selected_model_ids.split(",") if entry.strip()]
696
- selected_model_ids = parts
697
- else:
698
- selected_model_ids = [selected_model_ids]
699
- elif isinstance(selected_model_ids, set):
700
- selected_model_ids = list(selected_model_ids)
701
- elif not isinstance(selected_model_ids, (list, tuple)):
702
- selected_model_ids = [str(selected_model_ids)]
703
-
704
- return [str(item) for item in selected_model_ids if item]
705
-
706
-
707
- def _build_local_pipeline(model_id: str, hf_token: str, task: str, trust_remote_code: bool = False):
708
- if hf_pipeline is None:
709
- raise RuntimeError(_transformers_runtime_message() or "transformers is not installed in this space.")
710
-
711
- token = _sanitize_hf_token(hf_token) or None
712
- device = _inference_device()
713
- strict_cuda = _strict_cuda_required() and torch is not None and torch.cuda.is_available()
714
- if strict_cuda:
715
- _ensure_cuda_available(f"Local pipeline load for {model_id}")
716
- base_kwargs = {"model": model_id, "token": token, "trust_remote_code": trust_remote_code}
717
- if device >= 0:
718
- base_kwargs["device"] = device
719
-
720
- if task and task != "auto":
721
- task_candidates = [task]
722
- else:
723
- task_candidates = [
724
- "image-to-text",
725
- "image-text-to-text",
726
- "document-question-answering",
727
- ]
728
-
729
- seen_tasks = set()
730
- task_candidates = [t for t in task_candidates if t and not (t in seen_tasks or seen_tasks.add(t))]
731
- last_error = None
732
-
733
- preferred_dtype, dtype_chain = _inference_torch_kwargs_for_model(strict_cuda=strict_cuda)
734
- if preferred_dtype is not None:
735
- base_kwargs["torch_dtype"] = preferred_dtype
736
-
737
- for candidate_task in task_candidates:
738
- for dtype_kwargs in dtype_chain:
739
- cleaned_kwargs = {k: v for k, v in {**base_kwargs, **dtype_kwargs}.items() if v is not None}
740
- try:
741
- candidate_pipeline = hf_pipeline(candidate_task, **cleaned_kwargs)
742
- if strict_cuda:
743
- candidate_model = getattr(candidate_pipeline, "model", None)
744
- if candidate_model is not None and torch is not None and torch.cuda.is_available():
745
- try:
746
- candidate_model = candidate_model.to("cuda")
747
- if hasattr(candidate_pipeline, "model"):
748
- candidate_pipeline.model = candidate_model
749
- except Exception:
750
- pass
751
- _assert_model_on_cuda(candidate_model, model_id, f"pipeline load for task {candidate_task}")
752
- return candidate_pipeline
753
- except Exception as exc: # pragma: no cover
754
- last_error = exc
755
-
756
- if task and task != "auto":
757
- return _build_local_pipeline(model_id, hf_token, "auto", trust_remote_code=trust_remote_code)
758
-
759
- raise RuntimeError(f"Failed to load local pipeline for {model_id}: {last_error}")
760
-
761
-
762
- def _load_direct_components(model_id: str, hf_token: str, trust_remote_code: bool = False):
763
- if AutoProcessor is None and AutoImageProcessor is None and AutoTokenizer is None:
764
- return None, None
765
- token = _sanitize_hf_token(hf_token) or None
766
- component = (model_id, trust_remote_code)
767
- with _LOCAL_DIRECT_LOCK:
768
- cached = _LOCAL_DIRECT_CACHE.get(component)
769
- if cached is not None:
770
- _LOCAL_DIRECT_CACHE.move_to_end(component)
771
- return cached
772
-
773
- processor = None
774
- processor_error: Optional[Exception] = None
775
- for processor_ctor in [AutoProcessor, AutoImageProcessor]:
776
- if processor_ctor is None:
777
- continue
778
- try:
779
- processor = processor_ctor.from_pretrained(
780
- model_id,
781
- token=token,
782
- trust_remote_code=trust_remote_code,
783
- )
784
- break
785
- except Exception as exc: # pragma: no cover
786
- processor_error = exc
787
-
788
- if processor is None:
789
- if AutoTokenizer is not None:
790
- try:
791
- processor = AutoTokenizer.from_pretrained(model_id, token=token, trust_remote_code=trust_remote_code)
792
- except Exception as exc: # pragma: no cover
793
- if processor_error is None:
794
- processor_error = exc
795
- if processor is None:
796
- error_message = (
797
- f"Failed to load processor for {model_id}: {processor_error}"
798
- if processor_error is not None
799
- else f"Failed to load processor for {model_id}: no compatible processor classes available."
800
- )
801
- raise RuntimeError(error_message)
802
-
803
- strict_cuda = _strict_cuda_required() and torch is not None and torch.cuda.is_available()
804
- if strict_cuda:
805
- _ensure_cuda_available(f"Direct model load for {model_id}")
806
-
807
- if torch is None:
808
- raise RuntimeError("PyTorch is required for direct model loading.")
809
-
810
- model_errors = []
811
- model = None
812
- preferred_dtype, model_kwargs_chain = _inference_torch_kwargs_for_model(strict_cuda=strict_cuda)
813
- if preferred_dtype is not None:
814
- base_kwargs = {
815
- "token": token,
816
- "trust_remote_code": trust_remote_code,
817
- }
818
- else:
819
- base_kwargs = {
820
- "token": token,
821
- "trust_remote_code": trust_remote_code,
822
- }
823
- model_classes = [
824
- AutoModelForImageTextToText,
825
- AutoModelForVision2Seq,
826
- AutoModelForVisionEncoderDecoder,
827
- AutoModelForSeq2SeqLM,
828
- AutoModelForCausalLM,
829
- AutoModelForConditionalGeneration,
830
- AutoModelForDocumentQuestionAnswering,
831
- AutoModel,
832
- ]
833
- for model_class in model_classes:
834
- if model_class is None:
835
- continue
836
- try:
837
- for model_kwargs in model_kwargs_chain:
838
- candidate_kwargs = {k: v for k, v in {**base_kwargs, **model_kwargs}.items() if v is not None}
839
- try:
840
- model = model_class.from_pretrained(
841
- model_id,
842
- **candidate_kwargs,
843
- )
844
- break
845
- except TypeError as exc:
846
- model_errors.append(f"{getattr(model_class, '__name__', str(model_class))}: {exc}")
847
- continue
848
- except Exception as exc: # pragma: no cover
849
- model_errors.append(f"{getattr(model_class, '__name__', str(model_class))}: {exc}")
850
- if model is not None:
851
- break
852
- except Exception as exc: # pragma: no cover
853
- model_errors.append(f"{getattr(model_class, '__name__', str(model_class))}: {exc}")
854
-
855
- if model is None:
856
- raise RuntimeError(f"Direct loading failed for {model_id}. " + " | ".join(model_errors))
857
-
858
- if strict_cuda:
859
- try:
860
- model = model.to("cuda")
861
- except Exception as exc:
862
- raise RuntimeError(f"Failed to place {model_id} on CUDA: {exc}")
863
- _assert_model_on_cuda(model, model_id, "direct model load")
864
- elif torch.cuda.is_available():
865
- model = model.to("cuda")
866
-
867
- with _LOCAL_DIRECT_LOCK:
868
- _LOCAL_DIRECT_CACHE[component] = (model, processor)
869
- if len(_LOCAL_DIRECT_CACHE) > DIRECT_MODEL_CACHE_MAX:
870
- _, evicted = _LOCAL_DIRECT_CACHE.popitem(last=False)
871
- try:
872
- model_ref, _ = evicted
873
- del model_ref
874
- except Exception:
875
- pass
876
- if torch is not None and torch.cuda.is_available():
877
- torch.cuda.empty_cache()
878
- return model, processor
879
-
880
-
881
- def _direct_infer(model_id: str, image_bytes: bytes, prompt: str, hf_token: str, params: dict, trust_remote_code: bool = False) -> str:
882
- if torch is None:
883
- raise RuntimeError("PyTorch is required for direct model inference.")
884
- model, processor = _load_direct_components(model_id, hf_token, trust_remote_code=trust_remote_code)
885
- image = _decode_image(image_bytes)
886
-
887
- input_candidates = []
888
- if prompt:
889
- input_candidates.extend(
890
- [
891
- lambda: processor(text=prompt, images=image, return_tensors="pt"),
892
- lambda: processor(images=image, text=prompt, return_tensors="pt"),
893
- lambda: processor(prompt, image, return_tensors="pt"),
894
- lambda: processor(prompt, return_tensors="pt"),
895
- ]
896
- )
897
- input_candidates.append(lambda: processor(images=image, return_tensors="pt"))
898
-
899
- prepared_inputs = None
900
- prep_error = None
901
- for builder in input_candidates:
902
- try:
903
- candidate = builder()
904
- if isinstance(candidate, Mapping) and candidate:
905
- prepared_inputs = {}
906
- for key, value in candidate.items():
907
- if hasattr(value, "to"):
908
- prepared_inputs[key] = value.to(model.device)
909
- elif isinstance(value, (list, tuple)):
910
- prepared_inputs[key] = value
911
- if "images" in prepared_inputs and "pixel_values" not in prepared_inputs:
912
- prepared_inputs["pixel_values"] = prepared_inputs["images"]
913
- prepared_inputs.pop("images", None)
914
- if prepared_inputs:
915
- break
916
- except Exception as exc: # pragma: no cover
917
- prep_error = exc
918
-
919
- if prepared_inputs is None:
920
- raise RuntimeError(f"Could not prepare inputs for {model_id}: {prep_error}")
921
-
922
- gen_kwargs = {}
923
- if "max_new_tokens" in params:
924
- gen_kwargs["max_new_tokens"] = params["max_new_tokens"]
925
- if "temperature" in params:
926
- gen_kwargs["temperature"] = params["temperature"]
927
- if "top_p" in params:
928
- gen_kwargs["top_p"] = params["top_p"]
929
- if "top_k" in params:
930
- gen_kwargs["top_k"] = params["top_k"]
931
- if "do_sample" in params:
932
- gen_kwargs["do_sample"] = params["do_sample"]
933
- if "num_beams" in params:
934
- gen_kwargs["num_beams"] = params["num_beams"]
935
- if not gen_kwargs and hasattr(model, "generation_config"):
936
- try:
937
- gen_kwargs = {
938
- "max_new_tokens": getattr(model.generation_config, "max_new_tokens", None),
939
- "temperature": getattr(model.generation_config, "temperature", None),
940
- "top_p": getattr(model.generation_config, "top_p", None),
941
- "top_k": getattr(model.generation_config, "top_k", None),
942
- "num_beams": getattr(model.generation_config, "num_beams", None),
943
- }
944
- gen_kwargs = {k: v for k, v in gen_kwargs.items() if v is not None}
945
- except Exception:
946
- gen_kwargs = {}
947
-
948
- with torch.no_grad():
949
- generated = model.generate(**prepared_inputs, **gen_kwargs)
950
-
951
- if isinstance(generated, torch.Tensor):
952
- decoded = processor.batch_decode(generated, skip_special_tokens=True)
953
- return parse_output(decoded)
954
- if isinstance(generated, (list, tuple)):
955
- return parse_output(generated)
956
- return parse_output(str(generated))
957
-
958
-
959
- def _get_local_pipeline(model_id: str, hf_token: str, task: str, trust_remote_code: bool = False):
960
- device = "gpu" if (torch is not None and torch.cuda.is_available()) else "cpu"
961
- dtype = _inference_dtype()
962
- key = _pipeline_cache_key(model_id, task or "auto", device, dtype, trust_remote_code)
963
- with _LOCAL_PIPELINE_LOCK:
964
- cached = _LOCAL_PIPELINE_CACHE.get(key)
965
- if cached is not None:
966
- _LOCAL_PIPELINE_CACHE.move_to_end(key)
967
- return cached
968
-
969
- pipeline = _build_local_pipeline(model_id, hf_token, task, trust_remote_code=trust_remote_code)
970
-
971
- with _LOCAL_PIPELINE_LOCK:
972
- _LOCAL_PIPELINE_CACHE[key] = pipeline
973
- if len(_LOCAL_PIPELINE_CACHE) > LOCAL_PIPELINE_CACHE_MAX:
974
- _, evicted = _LOCAL_PIPELINE_CACHE.popitem(last=False)
975
- try:
976
- model = getattr(evicted, "model", None)
977
- if model is not None:
978
- del model
979
- del evicted
980
- except Exception:
981
- pass
982
- if torch is not None and torch.cuda.is_available():
983
- torch.cuda.empty_cache()
984
- gc.collect()
985
- gc.collect()
986
- return pipeline
987
-
988
-
989
- def _clear_local_pipeline_cache() -> None:
990
- global _LOCAL_PIPELINE_CACHE
991
- with _LOCAL_PIPELINE_LOCK:
992
- pipelines = list(_LOCAL_PIPELINE_CACHE.values())
993
- _LOCAL_PIPELINE_CACHE.clear()
994
- for pipeline in pipelines:
995
- try:
996
- if hasattr(pipeline, "model"):
997
- del pipeline.model
998
- if hasattr(pipeline, "processor"):
999
- del pipeline.processor
1000
- if hasattr(pipeline, "tokenizer"):
1001
- del pipeline.tokenizer
1002
- except Exception:
1003
- pass
1004
- gc.collect()
1005
- if torch is not None and torch.cuda.is_available():
1006
- torch.cuda.empty_cache()
1007
-
1008
-
1009
- def _clear_direct_cache() -> None:
1010
- global _LOCAL_DIRECT_CACHE
1011
- with _LOCAL_DIRECT_LOCK:
1012
- directs = list(_LOCAL_DIRECT_CACHE.values())
1013
- _LOCAL_DIRECT_CACHE.clear()
1014
- for model, _ in directs:
1015
- try:
1016
- del model
1017
- except Exception:
1018
- pass
1019
- gc.collect()
1020
- if torch is not None and torch.cuda.is_available():
1021
- torch.cuda.empty_cache()
1022
-
1023
-
1024
- def run_local_model(entry: dict, image_bytes: bytes, prompt: str, hf_token: str) -> str:
1025
- if hf_pipeline is None:
1026
- raise RuntimeError(_transformers_runtime_message() or "transformers is not installed in this space.")
1027
- if torch is None:
1028
- raise RuntimeError("PyTorch is required for local model inference.")
1029
-
1030
- model_id = entry["model_id"]
1031
- task = _model_pipeline_task(entry)
1032
- trust_remote_code = _model_requires_trust_remote_code(entry)
1033
- if _strict_cuda_required():
1034
- _ensure_cuda_available(f"Local OCR execution for {model_id}")
1035
- try:
1036
- pipeline = _get_local_pipeline(model_id, hf_token, task, trust_remote_code=trust_remote_code)
1037
- except Exception as exc:
1038
- pipeline = None
1039
- pipeline_error = exc
1040
- else:
1041
- pipeline_error = None
1042
- params = sanitize_generation_params(entry.get("parameters", {}))
1043
- image = _decode_image(image_bytes)
1044
-
1045
- if "extra_body" in params and isinstance(params["extra_body"], Mapping):
1046
- body = params.pop("extra_body")
1047
- if isinstance(body, Mapping):
1048
- supported_body_keys = {
1049
- "top_p",
1050
- "top_k",
1051
- "temperature",
1052
- "do_sample",
1053
- "num_beams",
1054
- "repetition_penalty",
1055
- "max_new_tokens",
1056
- "max_length",
1057
- "min_length",
1058
- "enable_thinking",
1059
- "seed",
1060
- "return_dict_in_generate",
1061
- "output_scores",
1062
- }
1063
- for key, value in body.items():
1064
- if isinstance(key, str) and key in supported_body_keys:
1065
- params.setdefault(key, value)
1066
-
1067
- if pipeline is not None:
1068
- try:
1069
- if _strict_cuda_required():
1070
- _assert_model_on_cuda(getattr(pipeline, "model", None), model_id, "pipeline execution")
1071
- else:
1072
- pipeline_model = getattr(pipeline, "model", None)
1073
- if pipeline_model is not None and torch is not None and torch.cuda.is_available():
1074
- pipeline_model = pipeline_model.to("cuda")
1075
- if hasattr(pipeline, "model"):
1076
- pipeline.model = pipeline_model
1077
- except Exception:
1078
- # Keep the failure path resilient; direct fallback may still succeed.
1079
- pass
1080
-
1081
- calls = []
1082
- if prompt:
1083
- calls.extend(
1084
- [
1085
- lambda: pipeline({"image": image, "text": prompt}, **params),
1086
- lambda: pipeline({"image": image, "question": prompt}, **params),
1087
- lambda: pipeline({"text": prompt, "image": image}, **params),
1088
- lambda: pipeline({"images": image, "text": prompt}, **params),
1089
- lambda: pipeline(image, text=prompt, **params),
1090
- lambda: pipeline(image, question=prompt, **params),
1091
- lambda: pipeline(image, **params),
1092
- lambda: pipeline({"image": image}, **params),
1093
- ]
1094
- )
1095
- else:
1096
- calls.extend([lambda: pipeline(image, **params), lambda: pipeline({"image": image}, **params)])
1097
-
1098
- for call in calls:
1099
- try:
1100
- output = call()
1101
- parsed = parse_output(output)
1102
- if parsed:
1103
- return parsed
1104
- except Exception as exc: # pragma: no cover
1105
- pipeline_error = _format_dependency_error(exc)
1106
- continue
1107
-
1108
- try:
1109
- direct_output = _direct_infer(
1110
- model_id,
1111
- image_bytes,
1112
- prompt,
1113
- hf_token,
1114
- params,
1115
- trust_remote_code=trust_remote_code,
1116
- )
1117
- parsed = parse_output(direct_output)
1118
- if parsed:
1119
- return parsed
1120
- except Exception as exc: # pragma: no cover
1121
- if pipeline_error is None:
1122
- pipeline_error = _format_dependency_error(exc)
1123
- else:
1124
- pipeline_error = RuntimeError(f"{pipeline_error}; direct fallback failed: {_format_dependency_error(exc)}")
1125
-
1126
- raise RuntimeError(f"Local model inference failed for {model_id}: {pipeline_error}")
1127
-
1128
-
1129
- def run_tesseract(image_bytes: bytes, params: dict) -> str:
1130
- if pytesseract is None:
1131
- raise RuntimeError("pytesseract is not installed in this Space.")
1132
- if not _tesseract_binary_available():
1133
- raise RuntimeError(
1134
- "pytesseract is installed but the tesseract executable is not available in PATH. "
1135
- "The Space should install tesseract via apt.txt, please confirm a clean rebuild."
1136
- )
1137
- for candidate in TESSERACT_EXECUTABLE_PATHS:
1138
- if os.path.exists(candidate):
1139
- try:
1140
- pytesseract.pytesseract.tesseract_cmd = candidate
1141
- except Exception:
1142
- pass
1143
- image = Image.open(BytesIO(image_bytes))
1144
- lang = params.get("lang", "heb+eng")
1145
- psm = params.get("psm", 6)
1146
- oem = params.get("oem", 1)
1147
- extra = params.get("extra_config", "").strip()
1148
- cfg_bits = [f"--psm {int(psm)}", f"--oem {int(oem)}"]
1149
- if extra:
1150
- cfg_bits.append(extra)
1151
- config = " ".join(cfg_bits).strip()
1152
- return pytesseract.image_to_string(image, lang=lang, config=config).strip()
1153
-
1154
-
1155
- @dataclass
1156
- class RunnerResult:
1157
- model_id: str
1158
- label: str
1159
- provider: str
1160
- status: str
1161
- output: str
1162
- latency_sec: Optional[float]
1163
- char_count: int
1164
- cer: Optional[float]
1165
- wer: Optional[float]
1166
- notes: str
1167
-
1168
-
1169
- def sanitize_generation_params(raw: dict) -> dict:
1170
- params = {}
1171
- if not raw:
1172
- return {"max_new_tokens": DEFAULT_MAX_TOKENS}
1173
- for k, v in raw.items():
1174
- if v is None:
1175
- continue
1176
- if k == "max_new_tokens":
1177
- requested = int(v)
1178
- params["max_new_tokens"] = max(DEFAULT_MAX_TOKENS, requested)
1179
- elif k == "max_tokens":
1180
- requested = int(v)
1181
- params["max_new_tokens"] = max(DEFAULT_MAX_TOKENS, requested)
1182
- elif k == "temperature":
1183
- params["temperature"] = float(v)
1184
- elif k == "top_p":
1185
- params["top_p"] = float(v)
1186
- elif k == "top_k":
1187
- params["top_k"] = int(v)
1188
- elif k == "repetition_penalty":
1189
- params["repetition_penalty"] = float(v)
1190
- elif k == "frequency_penalty":
1191
- params["frequency_penalty"] = float(v)
1192
- elif k == "presence_penalty":
1193
- params["presence_penalty"] = float(v)
1194
- elif k == "seed":
1195
- params["seed"] = int(v)
1196
- elif k == "extra_body":
1197
- params["extra_body"] = v
1198
- else:
1199
- params[k] = v
1200
- return params
1201
-
1202
-
1203
- def run_single_model(entry: dict, image_bytes: bytes, data_uri: str, hf_token: str, timeout_sec: int = 120) -> RunnerResult:
1204
- provider = entry.get("provider", "local_transformer").lower()
1205
- if provider != "tesseract":
1206
- provider = "local_transformer"
1207
- model_id = entry["model_id"]
1208
- label = entry.get("name", model_id)
1209
- prompt = entry.get("prompt", DEFAULT_OCR_PROMPT)
1210
- params = sanitize_generation_params(entry.get("parameters", {}))
1211
- notes = entry.get("notes", "")
1212
- notes = f"{notes} [runtime: {_inference_device_label()}]" if notes else f"runtime: {_inference_device_label()}"
1213
- start = time.perf_counter()
1214
- output = ""
1215
-
1216
- try:
1217
- token_message = _required_token_message(entry, hf_token)
1218
- if token_message:
1219
- raise RuntimeError(token_message)
1220
- if provider == "tesseract":
1221
- output = run_tesseract(image_bytes, params)
1222
- status = "ok"
1223
- elif provider in {"hf_chat", "hf_image_to_text", "local_transformer", "local"}:
1224
- output = run_local_model(entry, image_bytes, prompt, hf_token)
1225
- status = "ok"
1226
- else:
1227
- raise RuntimeError(f"Unsupported provider '{provider}'")
1228
- except Exception as exc: # pragma: no cover
1229
- status = "error"
1230
- output = f"{type(exc).__name__}: {exc}"
1231
-
1232
- duration = round(time.perf_counter() - start, 3)
1233
- output = output or ""
1234
- return RunnerResult(
1235
- model_id=entry["id"],
1236
- label=label,
1237
- provider=provider,
1238
- status=status,
1239
- output=output,
1240
- latency_sec=duration,
1241
- char_count=len(output),
1242
- cer=None,
1243
- wer=None,
1244
- notes=notes,
1245
- )
1246
-
1247
-
1248
- def _get_runtime_profile() -> Dict[str, object]:
1249
- profile = {
1250
- "has_cuda": False,
1251
- "gpu_name": None,
1252
- "gpu_vram_gb": None,
1253
- "gpu_free_vram_gb": None,
1254
- "gpu_reserved_vram_gb": None,
1255
- "gpu_allocated_vram_gb": None,
1256
- "provider": "cpu",
1257
- }
1258
- env_hardware = os.getenv("SPACE_HARDWARE", "").lower() or os.getenv("HF_SPACE_HARDWARE", "").lower()
1259
- if env_hardware:
1260
- profile["provider"] = env_hardware
1261
- try:
1262
- import torch
1263
-
1264
- if torch.cuda.is_available():
1265
- profile["has_cuda"] = True
1266
- props = torch.cuda.get_device_properties(0)
1267
- profile["gpu_name"] = props.name
1268
- profile["gpu_vram_gb"] = round(props.total_memory / (1024 ** 3), 1)
1269
- try:
1270
- free_mem, total_mem = torch.cuda.mem_get_info(0)
1271
- profile["gpu_free_vram_gb"] = round(free_mem / (1024 ** 3), 1)
1272
- profile["gpu_vram_gb"] = round(total_mem / (1024 ** 3), 1)
1273
- except Exception:
1274
- free_approx = max(0, props.total_memory - torch.cuda.memory_reserved(0))
1275
- profile["gpu_free_vram_gb"] = round(free_approx / (1024 ** 3), 1)
1276
- profile["gpu_reserved_vram_gb"] = round(torch.cuda.memory_reserved(0) / (1024 ** 3), 1)
1277
- profile["gpu_allocated_vram_gb"] = round(torch.cuda.memory_allocated(0) / (1024 ** 3), 1)
1278
- if "a10g" in (props.name or "").lower() or "l4" in (props.name or "").lower():
1279
- profile["provider"] = "zero_gpu"
1280
- except Exception:
1281
- pass
1282
- return profile
1283
-
1284
-
1285
- def _runtime_capacity_note(runtime: Dict[str, object]) -> str:
1286
- if not runtime.get("has_cuda"):
1287
- return "Runtime compute mode: CPU-only."
1288
- if runtime.get("gpu_name"):
1289
- total = runtime.get("gpu_vram_gb")
1290
- free = runtime.get("gpu_free_vram_gb")
1291
- allocated = runtime.get("gpu_allocated_vram_gb")
1292
- reserved = runtime.get("gpu_reserved_vram_gb")
1293
- if free is None:
1294
- return f"Runtime compute mode: {runtime['gpu_name']} with {total}GB total VRAM."
1295
- return (
1296
- f"Runtime compute mode: {runtime['gpu_name']} | total {total}GB | "
1297
- f"free {free}GB | allocated {allocated}GB | reserved {reserved}GB."
1298
- )
1299
- return "Runtime compute mode: GPU detected but profile unavailable."
1300
-
1301
-
1302
- def _estimate_model_vram_gb(entry: dict, *, compute_mode: str) -> float:
1303
- if not isinstance(entry, dict):
1304
- return ZERO_GPU_MODEL_FALLBACK_GB_SAFE
1305
- provider = (entry.get("provider") or "").lower()
1306
- if provider == "tesseract":
1307
- return 0.2
1308
- model_override = entry.get("estimated_vram_gb")
1309
- if isinstance(model_override, (int, float)) and model_override > 0:
1310
- return float(model_override)
1311
- model_id = (entry.get("model_id", "") or "").lower()
1312
- fallback = ZERO_GPU_MODEL_FALLBACK_GB_MAX if compute_mode == "max" else ZERO_GPU_MODEL_FALLBACK_GB_SAFE
1313
- if "31b" in model_id or "32b" in model_id:
1314
- return 22.0
1315
- if "30b_a3b" in model_id:
1316
- return 20.0
1317
- if "26b" in model_id:
1318
- return 16.0
1319
- if "12b" in model_id:
1320
- return 8.0
1321
- if "8b" in model_id:
1322
- return 6.0
1323
- if "4b" in model_id or "e4b" in model_id:
1324
- return 4.0
1325
- if "a3b" in model_id:
1326
- return 10.0
1327
- if "3b" in model_id:
1328
- return 2.5
1329
- return fallback
1330
-
1331
-
1332
- def _execution_plan(selected_entries: List[dict], compute_mode: str = "safe") -> Tuple[int, str]:
1333
- runtime = _get_runtime_profile()
1334
- selected_count = len(selected_entries)
1335
- capacity_note = _runtime_capacity_note(runtime)
1336
- if selected_count <= 1:
1337
- return 1, capacity_note
1338
-
1339
- mode_is_max = compute_mode == "max"
1340
- estimates = [_estimate_model_vram_gb(entry, compute_mode=compute_mode) for entry in selected_entries]
1341
- estimated_gb = round(sum(estimates), 1)
1342
- provider = runtime.get("provider")
1343
- notes = []
1344
- max_workers = min(ZERO_GPU_MAX_WORKERS, selected_count)
1345
-
1346
- gpu_name = str(runtime.get("gpu_name", "")).lower()
1347
- is_zero_gpu = (
1348
- provider == "zero_gpu"
1349
- or "zero-a10g" in str(provider)
1350
- or "a10g" in gpu_name
1351
- or "l4" in gpu_name
1352
- )
1353
- if is_zero_gpu:
1354
- if compute_mode in {"sequential", "safe"}:
1355
- max_workers = 1
1356
- notes.append("Safe/Sequential mode runs one model at a time on ZeroGPU.")
1357
- elif runtime.get("gpu_free_vram_gb") and runtime.get("gpu_vram_gb") and selected_count:
1358
- # Keep a hard headroom + runtime overhead to avoid zeroGPU OOM/rate failures.
1359
- headroom = float(runtime["gpu_free_vram_gb"]) - ZERO_GPU_MIN_GUARDED_FREE_GB
1360
- headroom = max(0.0, headroom)
1361
- headroom *= ZERO_GPU_MAX_HEADROOM if mode_is_max else ZERO_GPU_SAFE_HEADROOM
1362
- sorted_estimates = sorted(estimates)
1363
- fit_workers = 0
1364
- running = 0.0
1365
- for est in sorted_estimates:
1366
- # Conservative per-run margin for HTTP/image payload/responses/runtime overhead.
1367
- needed = est + 0.8
1368
- if fit_workers < ZERO_GPU_MAX_WORKERS and fit_workers + 1 <= selected_count and running + needed <= headroom:
1369
- running += needed
1370
- fit_workers += 1
1371
- else:
1372
- break
1373
- if fit_workers <= 0 and sorted_estimates:
1374
- fit_workers = 1
1375
- if fit_workers < selected_count:
1376
- notes.append(
1377
- f"ZeroGPU {'max' if mode_is_max else 'safe'} mode: {fit_workers}/{selected_count} models can be "
1378
- f"run in parallel with current free memory."
1379
- )
1380
- max_workers = min(max_workers, fit_workers)
1381
- elif runtime.get("gpu_vram_gb") and estimated_gb >= runtime["gpu_vram_gb"] * 0.7:
1382
- notes.append(
1383
- f"Estimated total selected model VRAM ({estimated_gb:.1f}GB) is above 70% of available GPU ({runtime['gpu_vram_gb']}GB)."
1384
- )
1385
- max_workers = 1
1386
- if mode_is_max and max_workers > 1 and selected_count > 6:
1387
- # Keep API call fanout bounded for many queued tasks.
1388
- max_workers = min(max_workers, 2)
1389
- notes.append("Max-compute mode capped at 2 concurrent workers when many models are selected.")
1390
- if max_workers > ZERO_GPU_MAX_SAFE_SELECTED:
1391
- max_workers = ZERO_GPU_MAX_SAFE_SELECTED
1392
-
1393
- if runtime.get("has_cuda") and estimated_gb and estimated_gb > 0:
1394
- if estimated_gb > 24:
1395
- notes.append(
1396
- f"Estimated total model VRAM {estimated_gb:.1f}GB is high. Running sequentially to be conservative."
1397
- )
1398
- max_workers = 1
1399
-
1400
- if selected_count > ZERO_GPU_MAX_SAFE_SELECTED and is_zero_gpu:
1401
- notes.append("Running many models at once increases timeout risk on ZeroGPU.")
1402
-
1403
- notes.append(capacity_note)
1404
- return max_workers, " | ".join(notes)
1405
-
1406
-
1407
- def run_comparison(
1408
- image_file,
1409
- selected_model_ids,
1410
- ground_truth_text,
1411
- hf_token,
1412
- compute_mode,
1413
- ) -> Tuple[str, str, str]:
1414
- try:
1415
- models = _safe_load_registry()
1416
- except Exception as exc: # pragma: no cover
1417
- return (
1418
- "Configuration error: failed to load model registry.",
1419
- "[]",
1420
- json.dumps({"error": str(exc)}, ensure_ascii=False),
1421
- )
1422
- if not image_file:
1423
- return "Upload an image first.", "[]", json.dumps({"error": "No image provided"}, ensure_ascii=False)
1424
- try:
1425
- image_bytes = _load_image_bytes(image_file)
1426
- except Exception as exc:
1427
- error_text = f"Failed to load uploaded image: {exc}"
1428
- return error_text, "[]", json.dumps({"error": error_text}, ensure_ascii=False)
1429
- selected_model_ids = _normalize_model_selection(selected_model_ids)
1430
- if not selected_model_ids:
1431
- selected_model_ids = []
1432
-
1433
- results: List[RunnerResult] = []
1434
- selected = set(selected_model_ids)
1435
- selected_entries = []
1436
- for entry in models:
1437
- if entry["id"] not in selected:
1438
- continue
1439
- if not entry.get("enabled", True):
1440
- continue
1441
- precheck_ok, precheck_message = _get_model_precheck(entry, hf_token)
1442
- if not precheck_ok:
1443
- entry_provider = entry.get("provider", "local_transformer")
1444
- if entry_provider != "tesseract":
1445
- entry_provider = "local_transformer"
1446
- results.append(
1447
- RunnerResult(
1448
- model_id=entry["id"],
1449
- label=entry.get("name", entry["id"]),
1450
- provider=entry_provider,
1451
- status="unsupported",
1452
- output=precheck_message,
1453
- latency_sec=None,
1454
- char_count=0,
1455
- cer=None,
1456
- wer=None,
1457
- notes=entry.get("notes", ""),
1458
- )
1459
- )
1460
- continue
1461
- selected_entries.append(entry)
1462
- if spaces is not None and selected_entries:
1463
- try:
1464
- _ensure_zero_gpu_lease()
1465
- except Exception:
1466
- # If the lease can’t be acquired (for example due to infra edge cases),
1467
- # allow the request to continue; model calls will fail fast with clear errors.
1468
- pass
1469
-
1470
- max_workers, execution_warning = _execution_plan(selected_entries, compute_mode=compute_mode)
1471
- hf_token = (hf_token or os.getenv("HF_TOKEN") or "").strip()
1472
- futures_map = {}
1473
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
1474
- for entry in models:
1475
- if not entry.get("enabled", True):
1476
- entry_provider = entry.get("provider", "local_transformer")
1477
- if entry_provider != "tesseract":
1478
- entry_provider = "local_transformer"
1479
- results.append(
1480
- RunnerResult(
1481
- model_id=entry["id"],
1482
- label=entry.get("name", entry["id"]),
1483
- provider=entry_provider,
1484
- status="disabled",
1485
- output="",
1486
- latency_sec=None,
1487
- char_count=0,
1488
- cer=None,
1489
- wer=None,
1490
- notes=entry.get("notes", ""),
1491
- )
1492
- )
1493
- continue
1494
- if entry["id"] not in selected:
1495
- entry_provider = entry.get("provider", "local_transformer")
1496
- if entry_provider != "tesseract":
1497
- entry_provider = "local_transformer"
1498
- results.append(
1499
- RunnerResult(
1500
- model_id=entry["id"],
1501
- label=entry.get("name", entry["id"]),
1502
- provider=entry_provider,
1503
- status="skipped",
1504
- output="",
1505
- latency_sec=None,
1506
- char_count=0,
1507
- cer=None,
1508
- wer=None,
1509
- notes=entry.get("notes", ""),
1510
- )
1511
- )
1512
- continue
1513
- if entry not in selected_entries:
1514
- continue
1515
- futures_map[executor.submit(run_single_model, entry, image_bytes, "", hf_token)] = entry
1516
-
1517
- for future in as_completed(futures_map):
1518
- try:
1519
- result = future.result()
1520
- if ground_truth_text:
1521
- cer, wer = compute_cer_wer(ground_truth_text, result.output)
1522
- result.cer = cer
1523
- result.wer = wer
1524
- results.append(result)
1525
- except Exception as exc: # pragma: no cover
1526
- results.append(
1527
- RunnerResult(
1528
- model_id="__worker_error__",
1529
- label="Worker failure",
1530
- provider="local_transformer",
1531
- status="error",
1532
- output=f"Unexpected worker failure: {exc}",
1533
- latency_sec=None,
1534
- char_count=0,
1535
- cer=None,
1536
- wer=None,
1537
- notes="",
1538
- )
1539
- )
1540
-
1541
- order_map = {model["id"]: idx for idx, model in enumerate(models)}
1542
- results.sort(key=lambda r: order_map.get(r.model_id, 1_000_000))
1543
-
1544
- summary = []
1545
- if execution_warning:
1546
- summary.append(f"**Execution mode:** {execution_warning}")
1547
- for result in results:
1548
- if result.status == "ok":
1549
- metrics = []
1550
- if result.cer is not None:
1551
- metrics.append(f"CER {result.cer:.4f}")
1552
- if result.wer is not None:
1553
- metrics.append(f"WER {result.wer:.4f}")
1554
- metric_str = " | ".join(metrics) if metrics else "N/A"
1555
- summary.append(
1556
- f"- **{result.label}**: {result.status} · {result.latency_sec:.2f}s · {result.char_count} chars · {metric_str}"
1557
- )
1558
- else:
1559
- summary.append(f"- **{result.label}**: {result.status} · {result.notes or result.output[:120]}")
1560
-
1561
- table_rows = []
1562
- for result in results:
1563
- row_output = result.output
1564
- if len(row_output) > 400:
1565
- row_output = row_output[:397] + "..."
1566
- table_rows.append(
1567
- {
1568
- "Model": result.label,
1569
- "Provider": result.provider,
1570
- "Status": result.status,
1571
- "Time (s)": result.latency_sec,
1572
- "Chars": result.char_count,
1573
- "CER": result.cer,
1574
- "WER": result.wer,
1575
- "Output preview": row_output,
1576
- "Notes": result.notes,
1577
- }
1578
- )
1579
-
1580
- json_payload = [r.__dict__ for r in results]
1581
- markdown = "# OCR comparison results\n\n" + ("\n".join(summary) if summary else "No models selected.")
1582
- return (
1583
- markdown,
1584
- json.dumps(table_rows, ensure_ascii=False, indent=2),
1585
- json.dumps(json_payload, ensure_ascii=False, indent=2),
1586
- )
1587
-
1588
-
1589
- def refresh_model_choices():
1590
- try:
1591
- models = _safe_load_registry()
1592
- except Exception:
1593
- return [("Model registry is unavailable", "__registry_error__")], []
1594
- options = [(m["name"], m["id"]) for m in models if m.get("enabled", True)]
1595
- default_checked = [m["id"] for m in models if m.get("enabled", True)]
1596
- return options, default_checked
1597
-
1598
-
1599
- def build_ui():
1600
- options, default_checked = refresh_model_choices()
1601
- runtime_warning = _transformers_runtime_message()
1602
- dependency_warning = _runtime_dependency_warning()
1603
- if runtime_warning is None:
1604
- runtime_warning = dependency_warning
1605
- elif dependency_warning:
1606
- runtime_warning = f"{runtime_warning}\n\n{dependency_warning}"
1607
- with gr.Blocks(title="Hebrew/English OCR Model Comparison (Zero GPU)") as demo:
1608
- gr.Markdown(
1609
- """
1610
- # Private Zero-GPU HF Space: Hebrew OCR Comparator
1611
-
1612
- Upload one document image and run only the models you check.
1613
- The benchmark is built for **Hebrew documents containing printed + handwritten text**.
1614
- Unchecked models will be skipped and **not executed**.
1615
- All OCR inference is executed locally in this Space on ZeroGPU (no external inference API calls).
1616
- By default, CPU fallback is disabled so models that cannot stay on CUDA fail with clear errors.
1617
- To allow CPU fallback (slower, for compatibility only), set `ALLOW_CPU_FALLBACK=1`.
1618
- """
1619
- )
1620
- if runtime_warning:
1621
- gr.Markdown(f"### Runtime warning\n{runtime_warning}")
1622
- with gr.Row():
1623
- with gr.Column(scale=2):
1624
- image_input = gr.Image(type="filepath", label="Document image (printed and handwritten Hebrew, English optional)")
1625
- hf_token = gr.Textbox(
1626
- label="HF_TOKEN (optional)",
1627
- type="password",
1628
- value=os.getenv("HF_TOKEN", ""),
1629
- info="Needed only for private/gated model downloads into this Space.",
1630
- )
1631
- ground_truth = gr.Textbox(
1632
- label="Ground truth (optional)",
1633
- lines=5,
1634
- placeholder="Paste exact expected text for CER/WER",
1635
- )
1636
- with gr.Column(scale=3):
1637
- compute_mode = gr.Radio(
1638
- label="Compute mode",
1639
- choices=[
1640
- ("Safe (recommended, avoid overrun)", "safe"),
1641
- ("Max compute (faster)", "max"),
1642
- ("Sequential only", "sequential"),
1643
- ],
1644
- value="safe",
1645
- )
1646
- model_selector = gr.CheckboxGroup(
1647
- label="Models to run",
1648
- choices=options,
1649
- value=default_checked,
1650
- interactive=True,
1651
- )
1652
- run_btn = gr.Button("Run selected models", variant="primary")
1653
- with gr.Row():
1654
- status_md = gr.Markdown("## Results")
1655
- results_md = gr.Markdown("")
1656
- results_df = gr.JSON(
1657
- label="Result rows (JSON)",
1658
- )
1659
- results_rows_json = gr.Textbox(
1660
- label="Result rows (JSON)",
1661
- lines=12,
1662
- interactive=False,
1663
- )
1664
- raw_json = gr.Textbox(
1665
- label="Full outputs (JSON)",
1666
- lines=12,
1667
- interactive=False,
1668
- )
1669
- run_btn.click(
1670
- fn=run_comparison,
1671
- inputs=[image_input, model_selector, ground_truth, hf_token, compute_mode],
1672
- outputs=[results_md, results_rows_json, raw_json],
1673
- api_name="run_comparison",
1674
- )
1675
- return demo
1676
-
1677
-
1678
- if __name__ == "__main__":
1679
- if not os.path.exists(REGISTRY_PATH):
1680
- raise RuntimeError("Missing model_registry.json. Create it before running the app.")
1681
- demo = build_ui()
1682
- port = int(os.getenv("PORT", "7860"))
1683
- demo.launch(server_name="0.0.0.0", server_port=port)
 
1
+ ---
2
+ title: Hebrew OCR Document Comparator
3
+ emoji: 🧾
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: 5.30.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
+ # Hebrew/English OCR Document Comparator (Zero-GPU)
14
+
15
+ This Space is configured specifically for **Hebrew document OCR** (printed + handwritten),
16
+ with an optional English fallback when mixed language pages include English strings.
17
+
18
+ The scheduler is designed for ZeroGPU safety: by default it runs only the maximum number of selected models that fit in memory headroom and will queue the rest in automatic batches. All model inference runs locally inside your ZeroGPU runtime.
19
+
20
+ ## What this space includes
21
+
22
+ - Checkbox-controlled model runner (unchecked models are skipped).
23
+ - Per-model configuration file: `model_registry.json` (easy to add/remove models).
24
+ - Ground-truth textbox for quick CER/WER scoring.
25
+ - Local OCR inference for all listed models (Transformers runtime + Tesseract fallback).
26
+ - Per-run preflight now validates each model’s config before execution. Any selected model that is not
27
+ loadable in this Space’s local Transformers environment is marked as `unsupported` immediately and skipped,
28
+ which prevents internal errors from unsupported repositories (for example ONNX/GGUF-only repos).
29
+ - `requirements.txt` and `apt.txt` already include OCR dependencies, plus updated transformer runtime dependencies that improve model compatibility.
30
+ - `.huggingface/metadata` preset for Gradio spaces.
31
+
32
+ ## Make it a private Zero-GPU Hugging Face Space
33
+
34
+ 1. In your Hugging Face account, create a new Space.
35
+ 2. Set **Visibility: Private**.
36
+ 3. Set **Space SDK: Gradio**.
37
+ 4. Choose **Zero GPU / a10g** hardware (`zero-a10g`).
38
+ 5. Upload all files from this directory (`app.py`, `model_registry.json`, `requirements.txt`, `apt.txt`, `.huggingface/metadata`, `README.md`).
39
+ 6. On first run, use `HF_TOKEN` from environment only if a selected model is private/gated and needs authenticated download.
40
+ 7. ZeroGPU spaces do not currently support changing `sleep-time`, so the runtime keeps its platform default idle behavior.
41
+ 8. The app keeps GPU work scoped to active runs by using a short activation hook during processing (instead of continuous warmup).
42
+
43
+ ### CLI flow (your current machine is already logged in)
44
+
45
+ If you want CLI-only deploy, run:
46
+
47
+ ```bash
48
+ HF_SPACE=ssdataanalysis/hebrew-ocr-comparator
49
+ hf repos create $HF_SPACE --type space --sdk gradio --private --flavor zero-a10g --exist-ok
50
+ hf upload $HF_SPACE /Users/alexanders/ocr-document-ocr-comparison-space . --type space --commit-message "Initial upload"
51
+ ```
52
+
53
+ The same repo is already created and uploaded from this machine in this folder:
54
+
55
+ `https://huggingface.co/spaces/ssdataanalysis/hebrew-ocr-comparator`
56
+
57
+ ## Model list provided
58
+
59
+ The following models are preloaded in `model_registry.json`:
60
+
61
+ - Qwen3-VL-8B-Instruct
62
+ - Qwen3-VL-8B-Thinking
63
+ - Qwen3-VL-4B-Instruct
64
+ - Qwen3-VL-4B-Thinking
65
+ - Qwen3-VL-30B-A3B-Instruct
66
+ - Qwen3-VL-30B-A3B-Thinking
67
+ - Nemotron-OCR-v2
68
+ - Gemma4 0.4B (E4B) Instruct
69
+ - Gemma4 0.4B (E4B) Thinking
70
+ - Gemma4 12B Instruct
71
+ - Gemma4 12B Thinking
72
+ - Gemma4 26B-A4B Instruct
73
+ - Gemma4 26B-A4B Thinking
74
+ - Gemma4 31B Instruct
75
+ - Gemma4 31B Thinking
76
+ - chandra2 (`datalab-to/chandra-ocr-2`)
77
+ - ronylicha/gigapdf-ocr-hebrew
78
+ - Qunie-V7-mini
79
+ - OpenMLKitOCR
80
+ - Tzefa-Word-OCR-TrOCR
81
+ - Qunie-V7-Pico
82
+ - aya-vision-32b
83
+ - aya-vision-8b
84
+ - Phi-4-multimodal-instruct
85
+ - surya2 (`datalab-to/surya-ocr-2`)
86
+ - PaddleOCR-VL
87
+ - PaddleOCR-VL 1.5
88
+ - PaddleOCR-VL 1.6
89
+ - DeepSeek OCR-1 (`deepseek-ai/DeepSeek-OCR`)
90
+ - DeepSeek OCR-2 (`deepseek-ai/DeepSeek-OCR-2`)
91
+ - cyttic/exp10-trocr-hebrew-matan-full
92
+ - cyttic/exp23-directfit-unfrozen
93
+ - cyttic/heb-verifier17-connected
94
+ - cyttic/exp26-composed1m
95
+ - Tesseract (local)
96
+
97
+ Model outputs now request a high `max_new_tokens` budget by default (`4096`) for stronger coverage of long documents.
98
+
99
+ ## Add/remove models in the future
100
+
101
+ Edit `model_registry.json`:
102
+
103
+ - Set `"enabled": false` to hide a model from the checkbox list.
104
+ - Remove an entry entirely to delete it from the UI.
105
+ - Add a new entry with:
106
+ - `id` (unique stable ID)
107
+ - `name` (UI label)
108
+ - `model_id` (Hugging Face model path)
109
+ - `provider`:
110
+ - `hf_chat` or `hf_image_to_text`: both are mapped to local Transformers execution in this Space
111
+ - `tesseract` (local OCR fallback)
112
+ - Optional per-entry compatibility overrides:
113
+ - `pipeline_task`: set explicit task string when auto-task probing is insufficient.
114
+ - `trust_remote_code`: `true` when the repository needs custom loading code.
115
+ - `parameters` and `prompt` (model-specific tuning)
116
+
117
+ ## Notes and caveats
118
+
119
+ - Some large or gated models may be unavailable without a token.
120
+ - Some checkpoint/config combinations can still be unsupported by the available local Transformers runtime and will show an error row instead of returning output.
121
+ - This version enables `trust_remote_code=True` where required for model families that rely on remote loading code.
122
+ - Tesseract output depends on the `tesseract` binary; this Space now requests `tesseract-ocr`, `tesseract-ocr-eng`, and `tesseract-ocr-heb` in `apt.txt`.
123
+ - For strict production benchmarks, keep an optional fixed random seed in each entry if the model honors it.
124
+ - This app logs only per-run outputs and derived metrics; it does not persist user images.
125
+ - In the UI, use **Compute mode**:
126
+ - **Safe (recommended, avoid overrun)**: conservative concurrency based on free GPU memory.
127
+ - **Max compute (faster)**: more parallelism when runtime headroom is available.
128
+ - **Sequential only**: one model at a time.
129
+
130
+ ## What changed for your requested OCR behavior
131
+
132
+ - Prompts and UI text are aligned to document OCR with an explicit emphasis on **printed + handwritten Hebrew** pages.
133
+ - Run only the models you check in the checkbox list to prevent unnecessary inference calls.
134
+ - README and upload steps are prepared for CLI deployment under your logged-in account.
135
+ - Local inference now uses CUDA-first strict mode by default, with CPU fallback disabled unless `ALLOW_CPU_FALLBACK=1`.