| import base64 |
| import gc |
| import json |
| import os |
| import re |
| import time |
| from concurrent.futures import ThreadPoolExecutor, as_completed |
| from dataclasses import dataclass |
| from io import BytesIO |
| from collections import OrderedDict |
| from collections.abc import Mapping |
| import threading |
| from typing import Dict, List, Optional, Tuple |
|
|
| import gradio as gr |
| from PIL import Image |
|
|
| try: |
| import transformers |
| hf_pipeline = transformers.pipeline |
| AutoImageProcessor = getattr(transformers, "AutoImageProcessor", None) |
| AutoModel = getattr(transformers, "AutoModel", None) |
| AutoModelForCausalLM = getattr(transformers, "AutoModelForCausalLM", None) |
| AutoModelForImageTextToText = getattr(transformers, "AutoModelForImageTextToText", None) |
| AutoModelForSeq2SeqLM = getattr(transformers, "AutoModelForSeq2SeqLM", None) |
| AutoModelForVision2Seq = getattr(transformers, "AutoModelForVision2Seq", None) |
| AutoModelForVisionEncoderDecoder = getattr(transformers, "AutoModelForVisionEncoderDecoder", None) |
| AutoModelForConditionalGeneration = getattr(transformers, "AutoModelForConditionalGeneration", None) |
| AutoModelForDocumentQuestionAnswering = getattr(transformers, "AutoModelForDocumentQuestionAnswering", None) |
| AutoTokenizer = getattr(transformers, "AutoTokenizer", None) |
| AutoProcessor = getattr(transformers, "AutoProcessor", None) |
| AutoConfig = getattr(transformers, "AutoConfig", None) |
| TRANSFORMERS_IMPORT_ERROR = None |
| except Exception as exc: |
| hf_pipeline = None |
| AutoImageProcessor = None |
| AutoModel = None |
| AutoModelForCausalLM = None |
| AutoModelForImageTextToText = None |
| AutoModelForSeq2SeqLM = None |
| AutoModelForVision2Seq = None |
| AutoModelForVisionEncoderDecoder = None |
| AutoModelForConditionalGeneration = None |
| AutoModelForDocumentQuestionAnswering = None |
| AutoTokenizer = None |
| AutoProcessor = None |
| AutoConfig = None |
| TRANSFORMERS_IMPORT_ERROR = repr(exc) |
|
|
| try: |
| import torch |
| except Exception: |
| torch = None |
|
|
| try: |
| import pytesseract |
| except Exception: |
| pytesseract = None |
|
|
| try: |
| import spaces |
| except Exception: |
| spaces = None |
|
|
| try: |
| from starlette.templating import Jinja2Templates |
|
|
| _original_get_template = Jinja2Templates.get_template |
| _original_template_response = Jinja2Templates.TemplateResponse |
|
|
| def _normalize_template_name(template_name): |
| if isinstance(template_name, dict): |
| template_name = template_name.get("template_name", None) or template_name.get("name", None) |
| if isinstance(template_name, (list, tuple)): |
| template_name = template_name[0] if template_name else None |
| if not template_name: |
| template_name = "frontend/index.html" |
| if template_name in {"index.html", "share.html"}: |
| template_name = f"frontend/{template_name}" |
| return template_name or "frontend/index.html" |
|
|
| def _safe_get_template(self, name, *args, **kwargs): |
| if isinstance(name, dict): |
| return _original_get_template(self, _normalize_template_name(name), *args, **kwargs) |
| return _original_get_template(self, name, *args, **kwargs) |
|
|
| def _safe_template_response(self, name, context=None, *args, **kwargs): |
| template_name = name |
| request = kwargs.pop("request", None) |
| if hasattr(name, "scope"): |
| request = name |
| template_name = context |
| context = args[0] if args else None |
| args = args[1:] if args else () |
| elif request is None and isinstance(context, Mapping): |
| request = context.get("request") |
| if not request and args: |
| request = args[0] if hasattr(args[0], "scope") else None |
| if request is not None: |
| args = args[1:] |
|
|
| if isinstance(template_name, dict): |
| template_name = _normalize_template_name(template_name) |
|
|
| try: |
| if context is None: |
| context = {} |
| elif isinstance(context, Mapping): |
| context = dict(context) |
| else: |
| context = dict(context) |
| except Exception: |
| context = {} |
|
|
| if isinstance(context, dict): |
| context.setdefault( |
| "config", |
| { |
| "body_css": {}, |
| "title": "Hebrew OCR Document Comparator", |
| "simple_description": "Private OCR comparator for Hebrew documents", |
| "thumbnail": "", |
| }, |
| ) |
| context.setdefault("gradio_api_info", {}) |
|
|
| if request is None: |
| return _original_template_response(self, template_name, context, *args, **kwargs) |
| if args: |
| return _original_template_response(self, request, template_name, context, *args, **kwargs) |
| return _original_template_response(self, request, template_name, context=context, *args, **kwargs) |
|
|
| Jinja2Templates.get_template = _safe_get_template |
| Jinja2Templates.TemplateResponse = _safe_template_response |
| except Exception: |
| pass |
|
|
|
|
| if spaces is not None: |
| @spaces.GPU |
| def _ensure_zero_gpu_lease() -> None: |
| return None |
|
|
| ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) |
| REGISTRY_PATH = os.path.join(ROOT_DIR, "model_registry.json") |
| DEFAULT_OCR_PROMPT = ( |
| "You are a high-accuracy OCR engine for Hebrew documents that may contain printed and handwritten text. " |
| "Return the exact document text in Hebrew, preserving line breaks and spacing. " |
| "Do not add explanation or JSON." |
| ) |
| ZERO_GPU_MAX_WORKERS = 4 |
| ZERO_GPU_SAFE_HEADROOM = 0.62 |
| ZERO_GPU_MAX_HEADROOM = 0.82 |
| ZERO_GPU_MIN_GUARDED_FREE_GB = 1.0 |
| ZERO_GPU_MODEL_FALLBACK_GB_SAFE = 2.6 |
| ZERO_GPU_MODEL_FALLBACK_GB_MAX = 2.0 |
| ZERO_GPU_MAX_SAFE_SELECTED = 6 |
| DEFAULT_MAX_TOKENS = 4096 |
| DEFAULT_PRECHECK_TIMEOUT_SEC = 12 |
| DIRECT_MODEL_CACHE_MAX = 1 |
| LOCAL_PIPELINE_CACHE_MAX = 1 |
| TESSERACT_EXECUTABLE_PATHS = ("/usr/bin/tesseract", "/usr/local/bin/tesseract", "/opt/conda/bin/tesseract", "/bin/tesseract") |
|
|
|
|
| def _inference_device() -> int: |
| if torch is not None and torch.cuda.is_available(): |
| return 0 |
| return -1 |
|
|
|
|
| def _inference_device_label() -> str: |
| if torch is not None and torch.cuda.is_available(): |
| return "cuda" |
| return "cpu" |
|
|
|
|
| def _inference_dtype(): |
| if torch is None: |
| return None |
| if hasattr(torch, "bfloat16"): |
| return torch.bfloat16 |
| if hasattr(torch, "float16"): |
| return torch.float16 |
| return None |
|
|
|
|
| def _env_flag_true(name: str, default: bool = False) -> bool: |
| raw = os.getenv(name) |
| if raw is None: |
| return default |
| return raw.strip().lower() in {"1", "true", "yes", "on", "y"} |
|
|
|
|
| def _allow_cpu_fallback() -> bool: |
| return _env_flag_true("ALLOW_CPU_FALLBACK", default=False) |
|
|
|
|
| def _strict_cuda_required() -> bool: |
| return not _allow_cpu_fallback() |
|
|
|
|
| def _ensure_cuda_available(context: str = "Inference") -> None: |
| if torch is None: |
| raise RuntimeError(f"{context} requires PyTorch, but PyTorch is not available in this Space.") |
| if not torch.cuda.is_available(): |
| raise RuntimeError( |
| f"{context} requires CUDA, but no CUDA device is available. " |
| "Run this Space on ZeroGPU and unset ALLOW_CPU_FALLBACK, or set ALLOW_CPU_FALLBACK=1." |
| ) |
|
|
|
|
| def _assert_model_on_cuda(model, model_id: str, context: str) -> None: |
| if not _strict_cuda_required(): |
| return |
| if torch is None or not torch.cuda.is_available(): |
| _ensure_cuda_available(context) |
|
|
| if model is None: |
| raise RuntimeError(f"{context}: loaded model is missing for {model_id}.") |
|
|
| try: |
| device_map = getattr(model, "hf_device_map", None) |
| if isinstance(device_map, Mapping): |
| non_cuda = { |
| str(value) |
| for value in device_map.values() |
| if str(value) not in {"0", "cuda", "cuda:0", "cuda:1", "cuda:2", "cuda:3"} |
| and str(value) != "None" |
| } |
| if non_cuda: |
| raise RuntimeError(f"{context}: {model_id} model-map is not on CUDA (found: {sorted(non_cuda)}).") |
| except Exception as exc: |
| raise RuntimeError( |
| f"{context}: {model_id} model-map validation failed for CUDA check ({type(exc).__name__}: {exc}). " |
| "If you intentionally allow CPU execution, set ALLOW_CPU_FALLBACK=1." |
| ) |
|
|
| try: |
| labels = {str(getattr(param, "device", "")) for param in model.parameters() if hasattr(param, "device")} |
| if not labels: |
| raise RuntimeError( |
| f"{context}: {model_id} exposes no torch parameters to validate CUDA placement. " |
| "If you intentionally allow CPU execution, set ALLOW_CPU_FALLBACK=1." |
| ) |
| non_cuda = [label for label in labels if not str(label).startswith("cuda") and str(label) != "0"] |
| if non_cuda: |
| raise RuntimeError( |
| f"{context}: {model_id} is not on CUDA (tensor devices: {sorted(labels)}). " |
| "If you intentionally allow CPU execution, set ALLOW_CPU_FALLBACK=1." |
| ) |
| except RuntimeError: |
| raise |
| except Exception as exc: |
| raise RuntimeError( |
| f"{context}: {model_id} model did not expose cuda-placed tensors ({type(exc).__name__}: {exc}). " |
| "If you intentionally allow CPU execution, set ALLOW_CPU_FALLBACK=1." |
| ) |
|
|
|
|
| def _move_pipeline_to_cuda(pipeline, model_id: str, context: str): |
| if torch is None or not torch.cuda.is_available(): |
| return pipeline |
| candidate_model = getattr(pipeline, "model", None) |
| try: |
| if hasattr(pipeline, "to"): |
| pipeline = pipeline.to("cuda") |
| elif candidate_model is not None: |
| candidate_model = candidate_model.to("cuda") |
| if hasattr(pipeline, "model"): |
| pipeline.model = candidate_model |
| else: |
| raise RuntimeError("pipeline has no move target.") |
| return pipeline |
| except Exception as exc: |
| raise RuntimeError( |
| f"{context}: {model_id} failed to move pipeline to CUDA ({type(exc).__name__}: {exc}). " |
| "If you intentionally allow CPU execution, set ALLOW_CPU_FALLBACK=1." |
| ) |
|
|
|
|
| def _inference_torch_kwargs_for_model(strict_cuda: bool = False) -> Tuple[dict, list]: |
| kwargs_priority = [] |
| dtype = _inference_dtype() |
| if dtype is None: |
| kwargs_priority.append({}) |
| return dtype, kwargs_priority |
|
|
| if strict_cuda and torch is not None and torch.cuda.is_available(): |
| kwargs_priority.append({"torch_dtype": dtype, "low_cpu_mem_usage": True}) |
| if dtype != getattr(torch, "float16", None): |
| kwargs_priority.append({"torch_dtype": torch.float16, "low_cpu_mem_usage": True}) |
| kwargs_priority.append({"torch_dtype": torch.float16}) |
| kwargs_priority.append({"torch_dtype": dtype}) |
| kwargs_priority.append({}) |
| else: |
| kwargs_priority.append({"torch_dtype": dtype, "device_map": "auto", "low_cpu_mem_usage": True}) |
| if dtype != getattr(torch, "float16", None): |
| kwargs_priority.append({"torch_dtype": torch.float16, "device_map": "auto", "low_cpu_mem_usage": True}) |
| kwargs_priority.append({"torch_dtype": torch.float16}) |
| kwargs_priority.append({"torch_dtype": dtype, "device_map": "auto"}) |
| kwargs_priority.append({"torch_dtype": dtype}) |
| kwargs_priority.append({"torch_dtype": torch.float16}) |
| kwargs_priority.append({}) |
| return dtype, kwargs_priority |
|
|
|
|
| def _pipeline_cache_key(model_id: str, task: str, device: str, dtype: object, trust_remote_code: bool = False) -> Tuple[str, str, str, object, bool]: |
| return (model_id, task, device, str(dtype), trust_remote_code) |
|
|
| TRUST_REMOTE_CODE_MODELS = { |
| "microsoft/Phi-4-multimodal-instruct", |
| "PaddlePaddle/PaddleOCR-VL", |
| "PaddlePaddle/PaddleOCR-VL-1.5", |
| "PaddlePaddle/PaddleOCR-VL-1.6", |
| "datalab-to/chandra-ocr-2", |
| "datalab-to/surya-ocr-2", |
| "ronylicha/gigapdf-ocr-hebrew", |
| "liskcell/qunie-v7-mini", |
| "0cve0/openmlkitocr", |
| "waraja/tzefa-word-ocr-trocr", |
| "liskcell/qunie-v7-pico", |
| "cyttic/exp10-trocr-hebrew-matan-full", |
| "cyttic/exp23-directfit-unfrozen", |
| "cyttic/heb-verifier17-connected", |
| "cyttic/exp26-composed1m", |
| "deepseek-ai/deepseek-ocr", |
| "deepseek-ai/deepseek-ocr-2", |
| "coherelabs/aya-vision-8b", |
| "coherelabs/aya-vision-32b", |
| } |
| TRUST_REMOTE_CODE_MODELS = {m.lower() for m in TRUST_REMOTE_CODE_MODELS} |
|
|
| PIPELINE_TASK_HINTS = { |
| "qwen/qwen3-vl-8b-instruct": "image-to-text", |
| "qwen/qwen3-vl-4b-instruct": "image-to-text", |
| "qwen/qwen3-vl-8b-thinking": "image-to-text", |
| "qwen/qwen3-vl-4b-thinking": "image-to-text", |
| "qwen/qwen3-vl-30b-a3b-instruct": "image-to-text", |
| "qwen/qwen3-vl-30b-a3b-thinking": "image-to-text", |
| "google/gemma-4-e4b-it": "image-to-text", |
| "google/gemma-4-12b-it": "image-to-text", |
| "google/gemma-4-26b-a4b-it": "image-to-text", |
| "google/gemma-4-31b-it": "image-to-text", |
| "ronylicha/gigapdf-ocr-hebrew": "image-to-text", |
| "liskcell/qunie-v7-mini": "image-to-text", |
| "0cve0/openmlkitocr": "image-to-text", |
| "waraja/tzefa-word-ocr-trocr": "image-to-text", |
| "liskcell/qunie-v7-pico": "image-to-text", |
| "paddlepaddle/paddleocr-vl": "image-to-text", |
| "paddlepaddle/paddleocr-vl-1.5": "image-to-text", |
| "paddlepaddle/paddleocr-vl-1.6": "image-to-text", |
| "datalab-to/chandra-ocr-2": "image-to-text", |
| "datalab-to/surya-ocr-2": "image-to-text", |
| "deepseek-ai/deepseek-ocr": "image-to-text", |
| "deepseek-ai/deepseek-ocr-2": "image-to-text", |
| "cyttic/exp10-trocr-hebrew-matan-full": "image-to-text", |
| "cyttic/exp23-directfit-unfrozen": "image-to-text", |
| "cyttic/heb-verifier17-connected": "image-to-text", |
| "cyttic/exp26-composed1m": "image-to-text", |
| } |
|
|
| GATED_MODELS_REQUIRING_TOKEN = { |
| "coherelabs/aya-vision-8b", |
| "coherelabs/aya-vision-32b", |
| } |
| GATED_MODELS_REQUIRING_TOKEN = {m.lower() for m in GATED_MODELS_REQUIRING_TOKEN} |
|
|
| NON_HF_OR_NON_TRANSFORMERS_MODELS = { |
| "ronylicha/gigapdf-ocr-hebrew": ( |
| "This checkpoint is not a standard Transformers OCR package (ONNX/RTen assets only)." |
| ), |
| "liskcell/qunie-v7-mini": ( |
| "This checkpoint appears distributed as a non-standard GGUF/ONNX package and cannot be loaded through " |
| "local Transformers in this Space." |
| ), |
| "liskcell/qunie-v7-pico": ( |
| "This checkpoint appears distributed as a non-standard GGUF/ONNX package and cannot be loaded through " |
| "local Transformers in this Space." |
| ), |
| "0cve0/openmlkitocr": ( |
| "This repository is not structured as a standard Hugging Face Transformers OCR model." |
| ), |
| } |
|
|
| MODEL_PRECHECK_CACHE: Dict[str, Tuple[bool, str]] = {} |
| _MODEL_PRECHECK_LOCK = threading.Lock() |
|
|
| _LOCAL_PIPELINE_CACHE: OrderedDict = OrderedDict() |
| _LOCAL_DIRECT_CACHE: OrderedDict = OrderedDict() |
| _LOCAL_PIPELINE_LOCK = threading.Lock() |
| _LOCAL_DIRECT_LOCK = threading.Lock() |
|
|
|
|
| def load_registry() -> List[dict]: |
| with open(REGISTRY_PATH, "r", encoding="utf-8") as f: |
| payload = json.load(f) |
| return payload.get("models", []) |
|
|
|
|
| def _safe_load_registry() -> List[dict]: |
| models = load_registry() |
| if not isinstance(models, list): |
| raise RuntimeError("Invalid model_registry.json format: expected a list under 'models'.") |
| for model in models: |
| if not isinstance(model, dict): |
| continue |
| if "id" not in model: |
| raise RuntimeError("Invalid registry entry: missing 'id'.") |
| if "model_id" not in model and model.get("provider") != "tesseract": |
| raise RuntimeError(f"Invalid registry entry '{model.get('id')}': missing 'model_id'.") |
| return models |
|
|
|
|
| def image_to_data_uri(image_bytes: bytes) -> str: |
| return f"data:image/png;base64,{base64.b64encode(image_bytes).decode('ascii')}" |
|
|
|
|
| def normalize_text_for_metrics(text: str) -> str: |
| if text is None: |
| return "" |
| text = text.replace("\u200c", "") |
| text = re.sub(r"\s+", " ", text.strip()) |
| return text |
|
|
|
|
| def edit_distance(a: str, b: str) -> int: |
| if not a: |
| return len(b) |
| if not b: |
| return len(a) |
| prev = list(range(len(b) + 1)) |
| for i, ca in enumerate(a, 1): |
| curr = [i] + [0] * len(b) |
| for j, cb in enumerate(b, 1): |
| cost = 0 if ca == cb else 1 |
| curr[j] = min( |
| prev[j] + 1, |
| curr[j - 1] + 1, |
| prev[j - 1] + cost, |
| ) |
| prev = curr |
| return prev[-1] |
|
|
|
|
| def compute_cer_wer(reference: str, hypothesis: str) -> Tuple[Optional[float], Optional[float]]: |
| if not reference: |
| return None, None |
| ref_norm = normalize_text_for_metrics(reference) |
| hyp_norm = normalize_text_for_metrics(hypothesis) |
| ref_chars = ref_norm |
| hyp_chars = hyp_norm |
| ref_words = ref_norm.split(" ") if ref_norm else [] |
| hyp_words = hyp_norm.split(" ") if hyp_norm else [] |
| cer = edit_distance(ref_chars, hyp_chars) / max(1, len(ref_chars)) |
| wer = edit_distance(" ".join(ref_words), " ".join(hyp_words)) / max(1, len(ref_words)) |
| return cer, wer |
|
|
|
|
| def parse_output(output) -> str: |
| if output is None: |
| return "" |
| if isinstance(output, str): |
| return output.strip() |
| if isinstance(output, bytes): |
| return output.decode("utf-8", errors="ignore").strip() |
| if isinstance(output, list): |
| if not output: |
| return "" |
| if len(output) == 1: |
| return parse_output(output[0]) |
| texts = [parse_output(item) for item in output if parse_output(item)] |
| return "\n\n".join(texts) |
| if isinstance(output, dict): |
| for key in ("text", "generated_text", "answer", "output", "prediction"): |
| if key in output and isinstance(output[key], str): |
| return output[key].strip() |
| if "choices" in output and isinstance(output["choices"], list): |
| choice0 = output["choices"][0] |
| if isinstance(choice0, dict) and "message" in choice0: |
| msg = choice0["message"] |
| if isinstance(msg, dict) and isinstance(msg.get("content"), str): |
| return msg["content"].strip() |
| return json.dumps(output, ensure_ascii=False) |
| if hasattr(output, "choices"): |
| choice = output.choices[0] |
| if hasattr(choice, "message") and hasattr(choice.message, "content"): |
| return (choice.message.content or "").strip() |
| if isinstance(choice, dict) and isinstance(choice.get("message"), dict): |
| return str(choice["message"].get("content", "")).strip() |
| return str(output) |
|
|
|
|
| def _sanitize_hf_token(hf_token: str) -> str: |
| if hf_token: |
| return hf_token.strip() |
| return os.getenv("HF_TOKEN", "").strip() |
|
|
|
|
| def _model_id_value(entry: dict) -> str: |
| return str(entry.get("model_id", "") or "").strip() |
|
|
|
|
| def _model_id_norm(entry: dict) -> str: |
| return _model_id_value(entry).lower() |
|
|
|
|
| def _model_requires_trust_remote_code(entry: dict) -> bool: |
| if entry.get("trust_remote_code") is True: |
| return True |
| model_id = _model_id_norm(entry) |
| if not model_id: |
| return False |
| if model_id in TRUST_REMOTE_CODE_MODELS: |
| return True |
| return False |
|
|
|
|
| def _model_pipeline_task(entry: dict) -> str: |
| task = (entry.get("pipeline_task") or entry.get("task") or "").strip().lower() |
| if task and task != "auto": |
| return task |
| model_id = _model_id_norm(entry) |
| return PIPELINE_TASK_HINTS.get(model_id, "auto") |
|
|
|
|
| def _precheck_cache_key(entry: dict, hf_token: str) -> str: |
| return f"{_model_id_norm(entry)}|{_sanitize_hf_token(hf_token) or 'anon'}" |
|
|
|
|
| def _classify_precheck_exception(exc: Exception) -> str: |
| text = str(exc).lower() |
| if "gated repo" in text or "requires authentication" in text or "401" in text: |
| return "Repository is gated. Provide a valid HF token with access." |
| if "403" in text or "authorization" in text: |
| return "Authorization failed for this repository. HF token may be missing or lacks access." |
| if "qwen3_5" in text or "gemma4_unified" in text: |
| return ( |
| "Model architecture requires a newer Transformers runtime than this Space currently has. " |
| "Rebuild after updating transformers from source." |
| ) |
| if "tokenizersbackend" in text: |
| return ( |
| "Tokenizer backend unavailable in this environment. Rebuild after dependency refresh " |
| "(tokenizers / transformers)." |
| ) |
| if "404" in text or "not found" in text or "config.json" in text: |
| return "Model config is not available in standard Transformers format." |
| if "could not infer task" in text or "document-question-answering" in text: |
| return "Model task/config mismatch for current local Transformers runtime." |
| return _format_dependency_error(exc) |
|
|
|
|
| def _get_model_precheck(entry: dict, hf_token: str) -> Tuple[bool, str]: |
| provider = str(entry.get("provider", "local_transformer")).lower() |
| if provider != "local_transformer": |
| return True, "" |
| model_id = _model_id_norm(entry) |
| if not model_id: |
| return False, "Missing model_id." |
| if model_id in NON_HF_OR_NON_TRANSFORMERS_MODELS: |
| return False, NON_HF_OR_NON_TRANSFORMERS_MODELS[model_id] |
|
|
| cache_key = _precheck_cache_key(entry, hf_token) |
| with _MODEL_PRECHECK_LOCK: |
| cached = MODEL_PRECHECK_CACHE.get(cache_key) |
| if cached is not None: |
| return cached |
|
|
| if AutoConfig is None: |
| result = (False, "Transformers AutoConfig is not available in this runtime.") |
| with _MODEL_PRECHECK_LOCK: |
| MODEL_PRECHECK_CACHE[cache_key] = result |
| return result |
|
|
| token = _sanitize_hf_token(hf_token) or None |
| try: |
| |
| AutoConfig.from_pretrained( |
| model_id, |
| token=token, |
| trust_remote_code=_model_requires_trust_remote_code(entry), |
| ) |
| except Exception as exc: |
| result = (False, _classify_precheck_exception(exc)) |
| with _MODEL_PRECHECK_LOCK: |
| MODEL_PRECHECK_CACHE[cache_key] = result |
| return result |
|
|
| result = (True, "") |
| with _MODEL_PRECHECK_LOCK: |
| MODEL_PRECHECK_CACHE[cache_key] = result |
| return result |
|
|
|
|
| def _transformers_runtime_message() -> Optional[str]: |
| if hf_pipeline is not None: |
| return None |
| if TRANSFORMERS_IMPORT_ERROR: |
| return f"transformers import failed in this Space: {TRANSFORMERS_IMPORT_ERROR}" |
| return "transformers is not installed in this Space." |
|
|
|
|
| def _format_dependency_error(exc: Exception) -> str: |
| text = str(exc) |
| if "TokenizersBackend" in text and "not currently imported" in text: |
| return ( |
| "Tokenizer backend import failed. " |
| "Rebuild after updating `tokenizers` and `transformers` in requirements." |
| ) |
| if "requires the following packages" in text and "addict" in text: |
| return "Missing dependency: addict. Rebuild after adding `addict` to requirements." |
| if "requires the following packages" in text and "torchvision" in text: |
| return "Missing dependency: torchvision. Rebuild after adding `torchvision` to requirements." |
| return text |
|
|
|
|
| def _runtime_dependency_warning() -> Optional[str]: |
| messages = [] |
| if pytesseract is None: |
| messages.append("pytesseract is not installed. Install with `pytesseract` in requirements.") |
| if pytesseract is not None and not _tesseract_binary_available(): |
| messages.append( |
| "The tesseract binary is not available in PATH. Add tesseract packages to `apt.txt` and rebuild." |
| ) |
| if torch is None: |
| messages.append("PyTorch is not installed; local models cannot run.") |
| elif not getattr(torch, "cuda", None) or not torch.cuda.is_available(): |
| messages.append( |
| "PyTorch is installed but CUDA is not available in this runtime. " |
| "For local ZeroGPU inference, rebuild with a CUDA-enabled PyTorch." |
| ) |
| return " | ".join(messages) if messages else None |
|
|
|
|
| def _required_token_message(entry: dict, hf_token: str) -> Optional[str]: |
| model_id = _model_id_norm(entry) |
| if model_id in GATED_MODELS_REQUIRING_TOKEN and not _sanitize_hf_token(hf_token): |
| return ( |
| f"{entry.get('name', model_id)} appears to require an HF token in this space. " |
| "Set HF_TOKEN to a token with access to the gated model." |
| ) |
| return None |
|
|
|
|
| def _tesseract_binary_available() -> bool: |
| import shutil |
|
|
| for candidate in TESSERACT_EXECUTABLE_PATHS: |
| if os.path.exists(candidate): |
| return True |
| if shutil.which("tesseract"): |
| return True |
| return False |
|
|
|
|
| def _decode_image(image_bytes: bytes) -> Image.Image: |
| image = Image.open(BytesIO(image_bytes)) |
| if image.mode != "RGB": |
| image = image.convert("RGB") |
| return image |
|
|
|
|
| def _load_image_bytes(image_file) -> bytes: |
| if image_file is None: |
| raise RuntimeError("No file was uploaded.") |
| if isinstance(image_file, (list, tuple)): |
| if not image_file: |
| raise RuntimeError("No file was uploaded.") |
| image_file = image_file[0] |
|
|
| if isinstance(image_file, dict): |
| image_file = ( |
| image_file.get("path") |
| or image_file.get("name") |
| or image_file.get("url") |
| or image_file.get("file_path") |
| ) |
|
|
| if isinstance(image_file, bytes): |
| return image_file |
|
|
| if hasattr(image_file, "read"): |
| try: |
| image_bytes = image_file.read() |
| if image_bytes: |
| return image_bytes |
| except Exception as exc: |
| raise RuntimeError(f"Could not read uploaded file object: {exc}") from exc |
| raise RuntimeError("Uploaded file object is empty.") |
|
|
| if isinstance(image_file, Image.Image): |
| buffer = BytesIO() |
| image_file.convert("RGB").save(buffer, format="PNG") |
| return buffer.getvalue() |
|
|
| if not isinstance(image_file, str): |
| if hasattr(image_file, "name") and isinstance(getattr(image_file, "name"), str): |
| image_file = getattr(image_file, "name") |
| else: |
| raise RuntimeError("Uploaded image is not a valid file path.") |
|
|
| if not os.path.exists(image_file): |
| raise RuntimeError("Uploaded file is missing from disk.") |
|
|
| filename = os.path.basename(image_file).lower() |
| if filename.endswith(".pdf"): |
| raise RuntimeError( |
| "PDF files are not supported yet in this Space build. Export the document to PNG/JPG and upload again." |
| ) |
|
|
| try: |
| with open(image_file, "rb") as f: |
| return f.read() |
| except Exception as exc: |
| raise RuntimeError(f"Could not read uploaded file: {exc}") from exc |
|
|
|
|
| def _normalize_model_selection(selected_model_ids) -> List[str]: |
| if selected_model_ids is None: |
| return [] |
| if isinstance(selected_model_ids, str): |
| try: |
| loaded = json.loads(selected_model_ids) |
| if isinstance(loaded, list): |
| selected_model_ids = loaded |
| else: |
| selected_model_ids = [selected_model_ids] |
| except Exception: |
| if "," in selected_model_ids: |
| parts = [entry.strip() for entry in selected_model_ids.split(",") if entry.strip()] |
| selected_model_ids = parts |
| else: |
| selected_model_ids = [selected_model_ids] |
| elif isinstance(selected_model_ids, set): |
| selected_model_ids = list(selected_model_ids) |
| elif not isinstance(selected_model_ids, (list, tuple)): |
| selected_model_ids = [str(selected_model_ids)] |
|
|
| return [str(item) for item in selected_model_ids if item] |
|
|
|
|
| def _build_local_pipeline(model_id: str, hf_token: str, task: str, trust_remote_code: bool = False): |
| if hf_pipeline is None: |
| raise RuntimeError(_transformers_runtime_message() or "transformers is not installed in this space.") |
|
|
| token = _sanitize_hf_token(hf_token) or None |
| device = _inference_device() |
| strict_cuda = _strict_cuda_required() and torch is not None and torch.cuda.is_available() |
| if strict_cuda: |
| _ensure_cuda_available(f"Local pipeline load for {model_id}") |
| base_kwargs = {"model": model_id, "token": token, "trust_remote_code": trust_remote_code} |
| if device >= 0: |
| base_kwargs["device"] = device |
|
|
| if task and task != "auto": |
| task_candidates = [task] |
| else: |
| task_candidates = [ |
| "image-to-text", |
| "image-text-to-text", |
| "document-question-answering", |
| ] |
|
|
| seen_tasks = set() |
| task_candidates = [t for t in task_candidates if t and not (t in seen_tasks or seen_tasks.add(t))] |
| last_error = None |
|
|
| preferred_dtype, dtype_chain = _inference_torch_kwargs_for_model(strict_cuda=strict_cuda) |
| if preferred_dtype is not None: |
| base_kwargs["torch_dtype"] = preferred_dtype |
|
|
| for candidate_task in task_candidates: |
| for dtype_kwargs in dtype_chain: |
| cleaned_kwargs = {k: v for k, v in {**base_kwargs, **dtype_kwargs}.items() if v is not None} |
| try: |
| candidate_pipeline = hf_pipeline(candidate_task, **cleaned_kwargs) |
| if strict_cuda: |
| candidate_pipeline = _move_pipeline_to_cuda( |
| candidate_pipeline, |
| model_id, |
| f"pipeline load for {candidate_task}", |
| ) |
| candidate_model = getattr(candidate_pipeline, "model", None) |
| if candidate_model is not None and torch is not None and torch.cuda.is_available(): |
| try: |
| candidate_model = candidate_model.to("cuda") |
| if hasattr(candidate_pipeline, "model"): |
| candidate_pipeline.model = candidate_model |
| except Exception: |
| raise RuntimeError( |
| f"Failed to move pipeline model for {model_id} to CUDA. " |
| "This usually indicates ZeroGPU memory constraints." |
| ) |
| _assert_model_on_cuda(candidate_model, model_id, f"pipeline load for task {candidate_task}") |
| return candidate_pipeline |
| except Exception as exc: |
| last_error = exc |
|
|
| if task and task != "auto": |
| return _build_local_pipeline(model_id, hf_token, "auto", trust_remote_code=trust_remote_code) |
|
|
| raise RuntimeError(f"Failed to load local pipeline for {model_id}: {last_error}") |
|
|
|
|
| def _load_direct_components(model_id: str, hf_token: str, trust_remote_code: bool = False): |
| if AutoProcessor is None and AutoImageProcessor is None and AutoTokenizer is None: |
| return None, None |
| token = _sanitize_hf_token(hf_token) or None |
| component = (model_id, trust_remote_code) |
| with _LOCAL_DIRECT_LOCK: |
| cached = _LOCAL_DIRECT_CACHE.get(component) |
| if cached is not None: |
| _LOCAL_DIRECT_CACHE.move_to_end(component) |
| return cached |
|
|
| processor = None |
| processor_error: Optional[Exception] = None |
| for processor_ctor in [AutoProcessor, AutoImageProcessor]: |
| if processor_ctor is None: |
| continue |
| try: |
| processor = processor_ctor.from_pretrained( |
| model_id, |
| token=token, |
| trust_remote_code=trust_remote_code, |
| ) |
| break |
| except Exception as exc: |
| processor_error = exc |
|
|
| if processor is None: |
| if AutoTokenizer is not None: |
| try: |
| processor = AutoTokenizer.from_pretrained(model_id, token=token, trust_remote_code=trust_remote_code) |
| except Exception as exc: |
| if processor_error is None: |
| processor_error = exc |
| if processor is None: |
| error_message = ( |
| f"Failed to load processor for {model_id}: {processor_error}" |
| if processor_error is not None |
| else f"Failed to load processor for {model_id}: no compatible processor classes available." |
| ) |
| raise RuntimeError(error_message) |
|
|
| strict_cuda = _strict_cuda_required() and torch is not None and torch.cuda.is_available() |
| if strict_cuda: |
| _ensure_cuda_available(f"Direct model load for {model_id}") |
|
|
| if torch is None: |
| raise RuntimeError("PyTorch is required for direct model loading.") |
|
|
| model_errors = [] |
| model = None |
| preferred_dtype, model_kwargs_chain = _inference_torch_kwargs_for_model(strict_cuda=strict_cuda) |
| if preferred_dtype is not None: |
| base_kwargs = { |
| "token": token, |
| "trust_remote_code": trust_remote_code, |
| } |
| else: |
| base_kwargs = { |
| "token": token, |
| "trust_remote_code": trust_remote_code, |
| } |
| model_classes = [ |
| AutoModelForImageTextToText, |
| AutoModelForVision2Seq, |
| AutoModelForVisionEncoderDecoder, |
| AutoModelForSeq2SeqLM, |
| AutoModelForCausalLM, |
| AutoModelForConditionalGeneration, |
| AutoModelForDocumentQuestionAnswering, |
| AutoModel, |
| ] |
| for model_class in model_classes: |
| if model_class is None: |
| continue |
| try: |
| for model_kwargs in model_kwargs_chain: |
| candidate_kwargs = {k: v for k, v in {**base_kwargs, **model_kwargs}.items() if v is not None} |
| try: |
| model = model_class.from_pretrained( |
| model_id, |
| **candidate_kwargs, |
| ) |
| break |
| except TypeError as exc: |
| model_errors.append(f"{getattr(model_class, '__name__', str(model_class))}: {exc}") |
| continue |
| except Exception as exc: |
| model_errors.append(f"{getattr(model_class, '__name__', str(model_class))}: {exc}") |
| if model is not None: |
| break |
| except Exception as exc: |
| model_errors.append(f"{getattr(model_class, '__name__', str(model_class))}: {exc}") |
|
|
| if model is None: |
| raise RuntimeError(f"Direct loading failed for {model_id}. " + " | ".join(model_errors)) |
|
|
| if strict_cuda: |
| try: |
| model = model.to("cuda") |
| except Exception as exc: |
| raise RuntimeError(f"Failed to place {model_id} on CUDA: {exc}") |
| _assert_model_on_cuda(model, model_id, "direct model load") |
| elif torch.cuda.is_available(): |
| model = model.to("cuda") |
|
|
| with _LOCAL_DIRECT_LOCK: |
| _LOCAL_DIRECT_CACHE[component] = (model, processor) |
| if len(_LOCAL_DIRECT_CACHE) > DIRECT_MODEL_CACHE_MAX: |
| _, evicted = _LOCAL_DIRECT_CACHE.popitem(last=False) |
| try: |
| model_ref, _ = evicted |
| del model_ref |
| except Exception: |
| pass |
| if torch is not None and torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
| return model, processor |
|
|
|
|
| def _direct_infer(model_id: str, image_bytes: bytes, prompt: str, hf_token: str, params: dict, trust_remote_code: bool = False) -> str: |
| if torch is None: |
| raise RuntimeError("PyTorch is required for direct model inference.") |
| model, processor = _load_direct_components(model_id, hf_token, trust_remote_code=trust_remote_code) |
| image = _decode_image(image_bytes) |
|
|
| input_candidates = [] |
| if prompt: |
| input_candidates.extend( |
| [ |
| lambda: processor(text=prompt, images=image, return_tensors="pt"), |
| lambda: processor(images=image, text=prompt, return_tensors="pt"), |
| lambda: processor(prompt, image, return_tensors="pt"), |
| lambda: processor(prompt, return_tensors="pt"), |
| ] |
| ) |
| input_candidates.append(lambda: processor(images=image, return_tensors="pt")) |
|
|
| prepared_inputs = None |
| prep_error = None |
| for builder in input_candidates: |
| try: |
| candidate = builder() |
| if isinstance(candidate, Mapping) and candidate: |
| prepared_inputs = {} |
| for key, value in candidate.items(): |
| if hasattr(value, "to"): |
| prepared_inputs[key] = value.to(model.device) |
| elif isinstance(value, (list, tuple)): |
| prepared_inputs[key] = value |
| if "images" in prepared_inputs and "pixel_values" not in prepared_inputs: |
| prepared_inputs["pixel_values"] = prepared_inputs["images"] |
| prepared_inputs.pop("images", None) |
| if prepared_inputs: |
| break |
| except Exception as exc: |
| prep_error = exc |
|
|
| if prepared_inputs is None: |
| raise RuntimeError(f"Could not prepare inputs for {model_id}: {prep_error}") |
|
|
| gen_kwargs = {} |
| if "max_new_tokens" in params: |
| gen_kwargs["max_new_tokens"] = params["max_new_tokens"] |
| if "temperature" in params: |
| gen_kwargs["temperature"] = params["temperature"] |
| if "top_p" in params: |
| gen_kwargs["top_p"] = params["top_p"] |
| if "top_k" in params: |
| gen_kwargs["top_k"] = params["top_k"] |
| if "do_sample" in params: |
| gen_kwargs["do_sample"] = params["do_sample"] |
| if "num_beams" in params: |
| gen_kwargs["num_beams"] = params["num_beams"] |
| if not gen_kwargs and hasattr(model, "generation_config"): |
| try: |
| gen_kwargs = { |
| "max_new_tokens": getattr(model.generation_config, "max_new_tokens", None), |
| "temperature": getattr(model.generation_config, "temperature", None), |
| "top_p": getattr(model.generation_config, "top_p", None), |
| "top_k": getattr(model.generation_config, "top_k", None), |
| "num_beams": getattr(model.generation_config, "num_beams", None), |
| } |
| gen_kwargs = {k: v for k, v in gen_kwargs.items() if v is not None} |
| except Exception: |
| gen_kwargs = {} |
|
|
| with torch.no_grad(): |
| generated = model.generate(**prepared_inputs, **gen_kwargs) |
|
|
| if isinstance(generated, torch.Tensor): |
| decoded = processor.batch_decode(generated, skip_special_tokens=True) |
| return parse_output(decoded) |
| if isinstance(generated, (list, tuple)): |
| return parse_output(generated) |
| return parse_output(str(generated)) |
|
|
|
|
| def _get_local_pipeline(model_id: str, hf_token: str, task: str, trust_remote_code: bool = False): |
| device = "gpu" if (torch is not None and torch.cuda.is_available()) else "cpu" |
| dtype = _inference_dtype() |
| key = _pipeline_cache_key(model_id, task or "auto", device, dtype, trust_remote_code) |
| with _LOCAL_PIPELINE_LOCK: |
| cached = _LOCAL_PIPELINE_CACHE.get(key) |
| if cached is not None: |
| _LOCAL_PIPELINE_CACHE.move_to_end(key) |
| return cached |
|
|
| pipeline = _build_local_pipeline(model_id, hf_token, task, trust_remote_code=trust_remote_code) |
|
|
| with _LOCAL_PIPELINE_LOCK: |
| _LOCAL_PIPELINE_CACHE[key] = pipeline |
| if len(_LOCAL_PIPELINE_CACHE) > LOCAL_PIPELINE_CACHE_MAX: |
| _, evicted = _LOCAL_PIPELINE_CACHE.popitem(last=False) |
| try: |
| model = getattr(evicted, "model", None) |
| if model is not None: |
| del model |
| del evicted |
| except Exception: |
| pass |
| if torch is not None and torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
| gc.collect() |
| gc.collect() |
| return pipeline |
|
|
|
|
| def _clear_local_pipeline_cache() -> None: |
| global _LOCAL_PIPELINE_CACHE |
| with _LOCAL_PIPELINE_LOCK: |
| pipelines = list(_LOCAL_PIPELINE_CACHE.values()) |
| _LOCAL_PIPELINE_CACHE.clear() |
| for pipeline in pipelines: |
| try: |
| if hasattr(pipeline, "model"): |
| del pipeline.model |
| if hasattr(pipeline, "processor"): |
| del pipeline.processor |
| if hasattr(pipeline, "tokenizer"): |
| del pipeline.tokenizer |
| except Exception: |
| pass |
| gc.collect() |
| if torch is not None and torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
|
|
|
|
| def _clear_direct_cache() -> None: |
| global _LOCAL_DIRECT_CACHE |
| with _LOCAL_DIRECT_LOCK: |
| directs = list(_LOCAL_DIRECT_CACHE.values()) |
| _LOCAL_DIRECT_CACHE.clear() |
| for model, _ in directs: |
| try: |
| del model |
| except Exception: |
| pass |
| gc.collect() |
| if torch is not None and torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
|
|
|
|
| def run_local_model(entry: dict, image_bytes: bytes, prompt: str, hf_token: str) -> str: |
| if hf_pipeline is None: |
| raise RuntimeError(_transformers_runtime_message() or "transformers is not installed in this space.") |
| if torch is None: |
| raise RuntimeError("PyTorch is required for local model inference.") |
|
|
| model_id = entry["model_id"] |
| task = _model_pipeline_task(entry) |
| trust_remote_code = _model_requires_trust_remote_code(entry) |
| if _strict_cuda_required(): |
| _ensure_cuda_available(f"Local OCR execution for {model_id}") |
| try: |
| pipeline = _get_local_pipeline(model_id, hf_token, task, trust_remote_code=trust_remote_code) |
| except Exception as exc: |
| pipeline = None |
| pipeline_error = exc |
| else: |
| pipeline_error = None |
| params = sanitize_generation_params(entry.get("parameters", {})) |
| image = _decode_image(image_bytes) |
|
|
| if "extra_body" in params and isinstance(params["extra_body"], Mapping): |
| body = params.pop("extra_body") |
| if isinstance(body, Mapping): |
| supported_body_keys = { |
| "top_p", |
| "top_k", |
| "temperature", |
| "do_sample", |
| "num_beams", |
| "repetition_penalty", |
| "max_new_tokens", |
| "max_length", |
| "min_length", |
| "enable_thinking", |
| "seed", |
| "return_dict_in_generate", |
| "output_scores", |
| } |
| for key, value in body.items(): |
| if isinstance(key, str) and key in supported_body_keys: |
| params.setdefault(key, value) |
|
|
| if pipeline is not None: |
| try: |
| if torch is not None and torch.cuda.is_available(): |
| pipeline = _move_pipeline_to_cuda( |
| pipeline, |
| model_id, |
| "pipeline execution", |
| ) |
| if _strict_cuda_required(): |
| _assert_model_on_cuda(getattr(pipeline, "model", None), model_id, "pipeline execution") |
| pipeline_model = getattr(pipeline, "model", None) |
| if pipeline_model is not None and torch is not None and torch.cuda.is_available(): |
| pipeline_model = pipeline_model.to("cuda") |
| if hasattr(pipeline, "model"): |
| pipeline.model = pipeline_model |
| else: |
| pipeline_model = getattr(pipeline, "model", None) |
| if pipeline_model is not None and torch is not None and torch.cuda.is_available(): |
| pipeline_model = pipeline_model.to("cuda") |
| if hasattr(pipeline, "model"): |
| pipeline.model = pipeline_model |
| except Exception as exc: |
| if not _strict_cuda_required(): |
| pipeline_error = _format_dependency_error(exc) |
| else: |
| pipeline = None |
| pipeline_error = _format_dependency_error(exc) |
|
|
| calls = [] |
| if pipeline is not None: |
| if prompt: |
| calls.extend( |
| [ |
| lambda: pipeline({"image": image, "text": prompt}, **params), |
| lambda: pipeline({"image": image, "question": prompt}, **params), |
| lambda: pipeline({"text": prompt, "image": image}, **params), |
| lambda: pipeline({"images": image, "text": prompt}, **params), |
| lambda: pipeline(image, text=prompt, **params), |
| lambda: pipeline(image, question=prompt, **params), |
| lambda: pipeline(image, **params), |
| lambda: pipeline({"image": image}, **params), |
| ] |
| ) |
| else: |
| calls.extend([lambda: pipeline(image, **params), lambda: pipeline({"image": image}, **params)]) |
|
|
| for call in calls: |
| try: |
| output = call() |
| parsed = parse_output(output) |
| if parsed: |
| return parsed |
| except Exception as exc: |
| pipeline_error = _format_dependency_error(exc) |
| break |
|
|
| if pipeline is None: |
| pipeline_error = pipeline_error or "Pipeline execution failed." |
|
|
| try: |
| direct_output = _direct_infer( |
| model_id, |
| image_bytes, |
| prompt, |
| hf_token, |
| params, |
| trust_remote_code=trust_remote_code, |
| ) |
| parsed = parse_output(direct_output) |
| if parsed: |
| return parsed |
| except Exception as exc: |
| if pipeline_error is None: |
| pipeline_error = _format_dependency_error(exc) |
| else: |
| pipeline_error = RuntimeError(f"{pipeline_error}; direct fallback failed: {_format_dependency_error(exc)}") |
|
|
| raise RuntimeError(f"Local model inference failed for {model_id}: {pipeline_error}") |
|
|
|
|
| def run_tesseract(image_bytes: bytes, params: dict) -> str: |
| if pytesseract is None: |
| raise RuntimeError("pytesseract is not installed in this Space.") |
| if not _tesseract_binary_available(): |
| raise RuntimeError( |
| "pytesseract is installed but the tesseract executable is not available in PATH. " |
| "The Space should install tesseract via apt.txt, please confirm a clean rebuild." |
| ) |
| for candidate in TESSERACT_EXECUTABLE_PATHS: |
| if os.path.exists(candidate): |
| try: |
| pytesseract.pytesseract.tesseract_cmd = candidate |
| except Exception: |
| pass |
| image = Image.open(BytesIO(image_bytes)) |
| lang = params.get("lang", "heb+eng") |
| psm = params.get("psm", 6) |
| oem = params.get("oem", 1) |
| extra = params.get("extra_config", "").strip() |
| cfg_bits = [f"--psm {int(psm)}", f"--oem {int(oem)}"] |
| if extra: |
| cfg_bits.append(extra) |
| config = " ".join(cfg_bits).strip() |
| return pytesseract.image_to_string(image, lang=lang, config=config).strip() |
|
|
|
|
| @dataclass |
| class RunnerResult: |
| model_id: str |
| label: str |
| provider: str |
| status: str |
| output: str |
| latency_sec: Optional[float] |
| char_count: int |
| cer: Optional[float] |
| wer: Optional[float] |
| notes: str |
|
|
|
|
| def sanitize_generation_params(raw: dict) -> dict: |
| params = {} |
| if not raw: |
| return {"max_new_tokens": DEFAULT_MAX_TOKENS} |
| for k, v in raw.items(): |
| if v is None: |
| continue |
| if k == "max_new_tokens": |
| requested = int(v) |
| params["max_new_tokens"] = max(DEFAULT_MAX_TOKENS, requested) |
| elif k == "max_tokens": |
| requested = int(v) |
| params["max_new_tokens"] = max(DEFAULT_MAX_TOKENS, requested) |
| elif k == "temperature": |
| params["temperature"] = float(v) |
| elif k == "top_p": |
| params["top_p"] = float(v) |
| elif k == "top_k": |
| params["top_k"] = int(v) |
| elif k == "repetition_penalty": |
| params["repetition_penalty"] = float(v) |
| elif k == "frequency_penalty": |
| params["frequency_penalty"] = float(v) |
| elif k == "presence_penalty": |
| params["presence_penalty"] = float(v) |
| elif k == "seed": |
| params["seed"] = int(v) |
| elif k == "extra_body": |
| params["extra_body"] = v |
| else: |
| params[k] = v |
| return params |
|
|
|
|
| def run_single_model(entry: dict, image_bytes: bytes, data_uri: str, hf_token: str, timeout_sec: int = 120) -> RunnerResult: |
| provider = entry.get("provider", "local_transformer").lower() |
| if provider != "tesseract": |
| provider = "local_transformer" |
| model_id = entry["model_id"] |
| label = entry.get("name", model_id) |
| prompt = entry.get("prompt", DEFAULT_OCR_PROMPT) |
| params = sanitize_generation_params(entry.get("parameters", {})) |
| notes = entry.get("notes", "") |
| runtime = _get_runtime_profile() |
| runtime_tag = f"runtime: {_inference_device_label()} | provider: {runtime.get('provider')}" |
| notes = f"{notes} [{runtime_tag}]" if notes else runtime_tag |
| start = time.perf_counter() |
| output = "" |
|
|
| try: |
| token_message = _required_token_message(entry, hf_token) |
| if token_message: |
| raise RuntimeError(token_message) |
| if provider == "tesseract": |
| output = run_tesseract(image_bytes, params) |
| status = "ok" |
| elif provider in {"hf_chat", "hf_image_to_text", "local_transformer", "local"}: |
| output = run_local_model(entry, image_bytes, prompt, hf_token) |
| status = "ok" |
| else: |
| raise RuntimeError(f"Unsupported provider '{provider}'") |
| except Exception as exc: |
| status = "error" |
| output = f"{type(exc).__name__}: {exc}" |
|
|
| duration = round(time.perf_counter() - start, 3) |
| output = output or "" |
| return RunnerResult( |
| model_id=entry["id"], |
| label=label, |
| provider=provider, |
| status=status, |
| output=output, |
| latency_sec=duration, |
| char_count=len(output), |
| cer=None, |
| wer=None, |
| notes=notes, |
| ) |
|
|
|
|
| def _get_runtime_profile() -> Dict[str, object]: |
| profile = { |
| "has_cuda": False, |
| "torch_version": None, |
| "torch_cuda_version": None, |
| "torch_cuda_built": None, |
| "gpu_name": None, |
| "gpu_vram_gb": None, |
| "gpu_free_vram_gb": None, |
| "gpu_reserved_vram_gb": None, |
| "gpu_allocated_vram_gb": None, |
| "provider": "cpu", |
| } |
| env_hardware = os.getenv("SPACE_HARDWARE", "").lower() or os.getenv("HF_SPACE_HARDWARE", "").lower() |
| if env_hardware: |
| profile["provider"] = env_hardware |
| try: |
| import torch |
| profile["torch_version"] = getattr(torch, "__version__", None) |
| profile["torch_cuda_built"] = bool(getattr(getattr(torch, "version", None), "cuda", None)) |
| profile["torch_cuda_version"] = getattr(getattr(torch, "version", None), "cuda", None) |
|
|
| if torch.cuda.is_available(): |
| profile["has_cuda"] = True |
| if not profile["provider"] or profile["provider"] == "cpu": |
| profile["provider"] = "cuda" |
| props = torch.cuda.get_device_properties(0) |
| profile["gpu_name"] = props.name |
| profile["gpu_vram_gb"] = round(props.total_memory / (1024 ** 3), 1) |
| try: |
| free_mem, total_mem = torch.cuda.mem_get_info(0) |
| profile["gpu_free_vram_gb"] = round(free_mem / (1024 ** 3), 1) |
| profile["gpu_vram_gb"] = round(total_mem / (1024 ** 3), 1) |
| except Exception: |
| free_approx = max(0, props.total_memory - torch.cuda.memory_reserved(0)) |
| profile["gpu_free_vram_gb"] = round(free_approx / (1024 ** 3), 1) |
| profile["gpu_reserved_vram_gb"] = round(torch.cuda.memory_reserved(0) / (1024 ** 3), 1) |
| profile["gpu_allocated_vram_gb"] = round(torch.cuda.memory_allocated(0) / (1024 ** 3), 1) |
| provider = str(profile["provider"] or "").lower() |
| normalized_name = str(props.name or "").lower() |
| if "zero" in provider or "a10g" in normalized_name or "l4" in normalized_name: |
| profile["provider"] = "zero_gpu" |
| except Exception: |
| pass |
| return profile |
|
|
|
|
| def _runtime_capacity_note(runtime: Dict[str, object]) -> str: |
| if not runtime.get("has_cuda"): |
| return "Runtime compute mode: CPU-only." |
| if runtime.get("gpu_name"): |
| total = runtime.get("gpu_vram_gb") |
| free = runtime.get("gpu_free_vram_gb") |
| allocated = runtime.get("gpu_allocated_vram_gb") |
| reserved = runtime.get("gpu_reserved_vram_gb") |
| if free is None: |
| return f"Runtime compute mode: {runtime['gpu_name']} with {total}GB total VRAM." |
| return ( |
| f"Runtime compute mode: {runtime['gpu_name']} | total {total}GB | " |
| f"free {free}GB | allocated {allocated}GB | reserved {reserved}GB." |
| ) |
| return "Runtime compute mode: GPU detected but profile unavailable." |
|
|
|
|
| def _startup_cuda_audit() -> None: |
| runtime = _get_runtime_profile() |
| print( |
| "[startup] Runtime: " |
| f"torch={runtime.get('torch_version')} " |
| f"cuda={runtime.get('torch_cuda_version')} " |
| f"cuda_built={runtime.get('torch_cuda_built')}" |
| ) |
| if runtime.get("has_cuda"): |
| provider = runtime.get("provider") |
| if str(provider).lower() == "cpu": |
| provider = "cuda" |
| runtime["provider"] = provider |
| print( |
| "[startup] Compute backend: " |
| f"{runtime.get('provider')} 路 {runtime.get('gpu_name')} 路 total {runtime.get('gpu_vram_gb')}GB 路 " |
| f"free {runtime.get('gpu_free_vram_gb')}GB" |
| ) |
| else: |
| print("[startup] WARNING: CUDA is not available in this runtime.") |
|
|
|
|
| def _estimate_model_vram_gb(entry: dict, *, compute_mode: str) -> float: |
| if not isinstance(entry, dict): |
| return ZERO_GPU_MODEL_FALLBACK_GB_SAFE |
| provider = (entry.get("provider") or "").lower() |
| if provider == "tesseract": |
| return 0.2 |
| model_override = entry.get("estimated_vram_gb") |
| if isinstance(model_override, (int, float)) and model_override > 0: |
| return float(model_override) |
| model_id = (entry.get("model_id", "") or "").lower() |
| fallback = ZERO_GPU_MODEL_FALLBACK_GB_MAX if compute_mode == "max" else ZERO_GPU_MODEL_FALLBACK_GB_SAFE |
| if "31b" in model_id or "32b" in model_id: |
| return 22.0 |
| if "30b_a3b" in model_id: |
| return 20.0 |
| if "26b" in model_id: |
| return 16.0 |
| if "12b" in model_id: |
| return 8.0 |
| if "8b" in model_id: |
| return 6.0 |
| if "4b" in model_id or "e4b" in model_id: |
| return 4.0 |
| if "a3b" in model_id: |
| return 10.0 |
| if "3b" in model_id: |
| return 2.5 |
| return fallback |
|
|
|
|
| def _execution_plan(selected_entries: List[dict], compute_mode: str = "safe") -> Tuple[int, str]: |
| runtime = _get_runtime_profile() |
| selected_count = len(selected_entries) |
| capacity_note = _runtime_capacity_note(runtime) |
| if selected_count <= 1: |
| return 1, capacity_note |
|
|
| mode_is_max = compute_mode == "max" |
| estimates = [_estimate_model_vram_gb(entry, compute_mode=compute_mode) for entry in selected_entries] |
| estimated_gb = round(sum(estimates), 1) |
| provider = runtime.get("provider") |
| notes = [] |
| max_workers = min(ZERO_GPU_MAX_WORKERS, selected_count) |
|
|
| gpu_name = str(runtime.get("gpu_name", "")).lower() |
| is_zero_gpu = ( |
| provider == "zero_gpu" |
| or "zero-a10g" in str(provider) |
| or "a10g" in gpu_name |
| or "l4" in gpu_name |
| ) |
| if runtime.get("has_cuda") and not is_zero_gpu: |
| notes.append("GPU runtime detected; applying generic CUDA concurrency sizing.") |
| if is_zero_gpu: |
| if compute_mode in {"sequential", "safe"}: |
| max_workers = 1 |
| notes.append("Safe/Sequential mode runs one model at a time on ZeroGPU.") |
| elif runtime.get("gpu_free_vram_gb") and runtime.get("gpu_vram_gb") and selected_count: |
| |
| headroom = float(runtime["gpu_free_vram_gb"]) - ZERO_GPU_MIN_GUARDED_FREE_GB |
| headroom = max(0.0, headroom) |
| headroom *= ZERO_GPU_MAX_HEADROOM if mode_is_max else ZERO_GPU_SAFE_HEADROOM |
| sorted_estimates = sorted(estimates) |
| fit_workers = 0 |
| running = 0.0 |
| for est in sorted_estimates: |
| |
| needed = est + 0.8 |
| if fit_workers < ZERO_GPU_MAX_WORKERS and fit_workers + 1 <= selected_count and running + needed <= headroom: |
| running += needed |
| fit_workers += 1 |
| else: |
| break |
| if fit_workers <= 0 and sorted_estimates: |
| fit_workers = 1 |
| if fit_workers < selected_count: |
| notes.append( |
| f"ZeroGPU {'max' if mode_is_max else 'safe'} mode: {fit_workers}/{selected_count} models can be " |
| f"run in parallel with current free memory." |
| ) |
| max_workers = min(max_workers, fit_workers) |
| elif runtime.get("gpu_vram_gb") and estimated_gb >= runtime["gpu_vram_gb"] * 0.7: |
| notes.append( |
| f"Estimated total selected model VRAM ({estimated_gb:.1f}GB) is above 70% of available GPU ({runtime['gpu_vram_gb']}GB)." |
| ) |
| max_workers = 1 |
| if mode_is_max and max_workers > 1 and selected_count > 6: |
| |
| max_workers = min(max_workers, 2) |
| notes.append("Max-compute mode capped at 2 concurrent workers when many models are selected.") |
| if max_workers > ZERO_GPU_MAX_SAFE_SELECTED: |
| max_workers = ZERO_GPU_MAX_SAFE_SELECTED |
|
|
| if runtime.get("has_cuda") and estimated_gb and estimated_gb > 0: |
| if estimated_gb > 24: |
| notes.append( |
| f"Estimated total model VRAM {estimated_gb:.1f}GB is high. Running sequentially to be conservative." |
| ) |
| max_workers = 1 |
|
|
| if selected_count > ZERO_GPU_MAX_SAFE_SELECTED and is_zero_gpu: |
| notes.append("Running many models at once increases timeout risk on ZeroGPU.") |
|
|
| notes.append(capacity_note) |
| return max_workers, " | ".join(notes) |
|
|
|
|
| def run_comparison( |
| image_file, |
| selected_model_ids, |
| ground_truth_text, |
| hf_token, |
| compute_mode, |
| ) -> Tuple[str, str, str]: |
| try: |
| models = _safe_load_registry() |
| except Exception as exc: |
| return ( |
| "Configuration error: failed to load model registry.", |
| "[]", |
| json.dumps({"error": str(exc)}, ensure_ascii=False), |
| ) |
| if not image_file: |
| return "Upload an image first.", "[]", json.dumps({"error": "No image provided"}, ensure_ascii=False) |
| try: |
| image_bytes = _load_image_bytes(image_file) |
| except Exception as exc: |
| error_text = f"Failed to load uploaded image: {exc}" |
| return error_text, "[]", json.dumps({"error": error_text}, ensure_ascii=False) |
| selected_model_ids = _normalize_model_selection(selected_model_ids) |
| if not selected_model_ids: |
| selected_model_ids = [] |
|
|
| results: List[RunnerResult] = [] |
| selected = set(selected_model_ids) |
| selected_entries = [] |
| for entry in models: |
| if entry["id"] not in selected: |
| continue |
| if not entry.get("enabled", True): |
| continue |
| precheck_ok, precheck_message = _get_model_precheck(entry, hf_token) |
| if not precheck_ok: |
| entry_provider = entry.get("provider", "local_transformer") |
| if entry_provider != "tesseract": |
| entry_provider = "local_transformer" |
| results.append( |
| RunnerResult( |
| model_id=entry["id"], |
| label=entry.get("name", entry["id"]), |
| provider=entry_provider, |
| status="unsupported", |
| output=precheck_message, |
| latency_sec=None, |
| char_count=0, |
| cer=None, |
| wer=None, |
| notes=entry.get("notes", ""), |
| ) |
| ) |
| continue |
| selected_entries.append(entry) |
| if spaces is not None and selected_entries: |
| if _strict_cuda_required(): |
| _ensure_zero_gpu_lease() |
| else: |
| try: |
| _ensure_zero_gpu_lease() |
| except Exception: |
| pass |
|
|
| max_workers, execution_warning = _execution_plan(selected_entries, compute_mode=compute_mode) |
| hf_token = (hf_token or os.getenv("HF_TOKEN") or "").strip() |
| futures_map = {} |
| with ThreadPoolExecutor(max_workers=max_workers) as executor: |
| for entry in models: |
| if not entry.get("enabled", True): |
| entry_provider = entry.get("provider", "local_transformer") |
| if entry_provider != "tesseract": |
| entry_provider = "local_transformer" |
| results.append( |
| RunnerResult( |
| model_id=entry["id"], |
| label=entry.get("name", entry["id"]), |
| provider=entry_provider, |
| status="disabled", |
| output="", |
| latency_sec=None, |
| char_count=0, |
| cer=None, |
| wer=None, |
| notes=entry.get("notes", ""), |
| ) |
| ) |
| continue |
| if entry["id"] not in selected: |
| entry_provider = entry.get("provider", "local_transformer") |
| if entry_provider != "tesseract": |
| entry_provider = "local_transformer" |
| results.append( |
| RunnerResult( |
| model_id=entry["id"], |
| label=entry.get("name", entry["id"]), |
| provider=entry_provider, |
| status="skipped", |
| output="", |
| latency_sec=None, |
| char_count=0, |
| cer=None, |
| wer=None, |
| notes=entry.get("notes", ""), |
| ) |
| ) |
| continue |
| if entry not in selected_entries: |
| continue |
| futures_map[executor.submit(run_single_model, entry, image_bytes, "", hf_token)] = entry |
|
|
| for future in as_completed(futures_map): |
| try: |
| result = future.result() |
| if ground_truth_text: |
| cer, wer = compute_cer_wer(ground_truth_text, result.output) |
| result.cer = cer |
| result.wer = wer |
| results.append(result) |
| except Exception as exc: |
| results.append( |
| RunnerResult( |
| model_id="__worker_error__", |
| label="Worker failure", |
| provider="local_transformer", |
| status="error", |
| output=f"Unexpected worker failure: {exc}", |
| latency_sec=None, |
| char_count=0, |
| cer=None, |
| wer=None, |
| notes="", |
| ) |
| ) |
|
|
| order_map = {model["id"]: idx for idx, model in enumerate(models)} |
| results.sort(key=lambda r: order_map.get(r.model_id, 1_000_000)) |
|
|
| summary = [] |
| if execution_warning: |
| summary.append(f"**Execution mode:** {execution_warning}") |
| for result in results: |
| if result.status == "ok": |
| metrics = [] |
| if result.cer is not None: |
| metrics.append(f"CER {result.cer:.4f}") |
| if result.wer is not None: |
| metrics.append(f"WER {result.wer:.4f}") |
| metric_str = " | ".join(metrics) if metrics else "N/A" |
| summary.append( |
| f"- **{result.label}**: {result.status} 路 {result.latency_sec:.2f}s 路 {result.char_count} chars 路 {metric_str}" |
| ) |
| else: |
| summary.append(f"- **{result.label}**: {result.status} 路 {result.notes or result.output[:120]}") |
|
|
| table_rows = [] |
| for result in results: |
| row_output = result.output |
| if len(row_output) > 400: |
| row_output = row_output[:397] + "..." |
| table_rows.append( |
| { |
| "Model": result.label, |
| "Provider": result.provider, |
| "Status": result.status, |
| "Time (s)": result.latency_sec, |
| "Chars": result.char_count, |
| "CER": result.cer, |
| "WER": result.wer, |
| "Output preview": row_output, |
| "Notes": result.notes, |
| } |
| ) |
|
|
| json_payload = [r.__dict__ for r in results] |
| markdown = "# OCR comparison results\n\n" + ("\n".join(summary) if summary else "No models selected.") |
| return ( |
| markdown, |
| json.dumps(table_rows, ensure_ascii=False, indent=2), |
| json.dumps(json_payload, ensure_ascii=False, indent=2), |
| ) |
|
|
|
|
| def refresh_model_choices(): |
| try: |
| models = _safe_load_registry() |
| except Exception: |
| return [("Model registry is unavailable", "__registry_error__")], [] |
| options = [(m["name"], m["id"]) for m in models if m.get("enabled", True)] |
| |
| |
| default_checked = [ |
| m["id"] |
| for m in models |
| if m.get("enabled", True) and str(m.get("provider", "")).lower() != "tesseract" |
| ] |
| return options, default_checked |
|
|
|
|
| def build_ui(): |
| options, default_checked = refresh_model_choices() |
| runtime_warning = _transformers_runtime_message() |
| dependency_warning = _runtime_dependency_warning() |
| if runtime_warning is None: |
| runtime_warning = dependency_warning |
| elif dependency_warning: |
| runtime_warning = f"{runtime_warning}\n\n{dependency_warning}" |
| with gr.Blocks(title="Hebrew/English OCR Model Comparison (Zero GPU)") as demo: |
| gr.Markdown( |
| """ |
| # Private Zero-GPU HF Space: Hebrew OCR Comparator |
| |
| Upload one document image and run only the models you check. |
| The benchmark is built for **Hebrew documents containing printed + handwritten text**. |
| Unchecked models will be skipped and **not executed**. |
| All OCR inference is executed locally in this Space on ZeroGPU (no external inference API calls). |
| By default, CPU fallback is disabled so models that cannot stay on CUDA fail with clear errors. |
| To allow CPU fallback (slower, for compatibility only), set `ALLOW_CPU_FALLBACK=1`. |
| Tesseract is CPU-only and is disabled by default. Enable it only when you need the baseline check. |
| """ |
| ) |
| if runtime_warning: |
| gr.Markdown(f"### Runtime warning\n{runtime_warning}") |
| with gr.Row(): |
| with gr.Column(scale=2): |
| image_input = gr.Image(type="filepath", label="Document image (printed and handwritten Hebrew, English optional)") |
| hf_token = gr.Textbox( |
| label="HF_TOKEN (optional)", |
| type="password", |
| value=os.getenv("HF_TOKEN", ""), |
| info="Needed only for private/gated model downloads into this Space.", |
| ) |
| ground_truth = gr.Textbox( |
| label="Ground truth (optional)", |
| lines=5, |
| placeholder="Paste exact expected text for CER/WER", |
| ) |
| with gr.Column(scale=3): |
| compute_mode = gr.Radio( |
| label="Compute mode", |
| choices=[ |
| ("Safe (recommended, avoid overrun)", "safe"), |
| ("Max compute (faster)", "max"), |
| ("Sequential only", "sequential"), |
| ], |
| value="safe", |
| ) |
| model_selector = gr.CheckboxGroup( |
| label="Models to run", |
| choices=options, |
| value=default_checked, |
| interactive=True, |
| ) |
| run_btn = gr.Button("Run selected models", variant="primary") |
| with gr.Row(): |
| status_md = gr.Markdown("## Results") |
| results_md = gr.Markdown("") |
| results_df = gr.JSON( |
| label="Result rows (JSON)", |
| ) |
| results_rows_json = gr.Textbox( |
| label="Result rows (JSON)", |
| lines=12, |
| interactive=False, |
| ) |
| raw_json = gr.Textbox( |
| label="Full outputs (JSON)", |
| lines=12, |
| interactive=False, |
| ) |
| run_btn.click( |
| fn=run_comparison, |
| inputs=[image_input, model_selector, ground_truth, hf_token, compute_mode], |
| outputs=[results_md, results_rows_json, raw_json], |
| api_name="run_comparison", |
| ) |
| return demo |
|
|
|
|
| if __name__ == "__main__": |
| if not os.path.exists(REGISTRY_PATH): |
| raise RuntimeError("Missing model_registry.json. Create it before running the app.") |
| _startup_cuda_audit() |
| if _strict_cuda_required(): |
| _ensure_cuda_available("Application startup") |
| demo = build_ui() |
| port = int(os.getenv("PORT", "7860")) |
| demo.launch(server_name="0.0.0.0", server_port=port) |
|
|