Spaces:
Runtime error
Runtime error
| """Optional ONLINE OCR via Google Gemini (handwriting + printed transcription). | |
| This is an OPT-IN, accuracy-first fallback for pages the local engines | |
| (PaddleOCR / TrOCR) read poorly. It sends a rendered PAGE IMAGE to Google's | |
| Gemini API and returns a verbatim transcription. | |
| PRIVACY: enabling this uploads page images to Google's servers — the bytes | |
| leave this machine. It is OFF by default and only runs when the caller passes | |
| a Gemini API key. Do not enable it for confidential documents you cannot send | |
| to a third party. | |
| SETUP: get a free API key from https://aistudio.google.com/ (Google AI Studio) | |
| and pass it as ``api_key`` (or wire it to a ``GEMINI_API_KEY`` env var in the | |
| caller). No extra pip dependency is needed: this module talks to the REST API | |
| with the Python standard library only (``urllib.request`` + ``json`` + | |
| ``base64``). ``cv2`` is used to PNG-encode the OpenCV image. | |
| Importing this module performs NO network call and has no import-time side | |
| effects. | |
| """ | |
| import base64 | |
| import json | |
| import urllib.error | |
| import urllib.request | |
| import cv2 | |
| import numpy as np | |
| # --------------------------------------------------------------------------- | |
| # Verified Gemini REST contract. | |
| # --------------------------------------------------------------------------- | |
| # Generation endpoint (non-streaming). The canonical reference path is | |
| # /v1beta/{model=models/*}:generateContent; the model name on the wire is | |
| # "models/<id>". {model} below is the BARE model id (e.g. "gemini-2.5-pro"). | |
| _API_BASE = "https://generativelanguage.googleapis.com/v1beta/models" | |
| _GENERATE_URL = _API_BASE + "/{model}:generateContent" | |
| # Auth header preferred over the legacy ?key= query param (keeps the key out of | |
| # URLs/logs). Header name is literally "x-goog-api-key". | |
| _API_KEY_HEADER = "x-goog-api-key" | |
| # Strong handwriting-capable models, FREE-TIER-FRIENDLY first. As of 2026 the | |
| # Pro models are NOT available on the API free tier (free-tier limit 0), so the | |
| # default auto-pick must be a Flash model — Flash is already excellent at | |
| # handwriting (benchmarked ~95% char-acc on a real cursive page). Users with | |
| # paid billing can still select a Pro model from the dropdown. | |
| SUPPORTED_MODELS = ( | |
| "gemini-2.5-flash", # free-tier workhorse; strong handwriting | |
| "gemini-3.5-flash", # newer flash if the key exposes it | |
| "gemini-2.5-flash-lite", # free-tier, lighter/faster, slightly weaker | |
| "gemini-3.1-flash-lite", | |
| "gemini-2.5-pro", # better accuracy but needs PAID billing (not free tier) | |
| "gemini-3.1-pro-preview", | |
| ) | |
| DEFAULT_MODEL = SUPPORTED_MODELS[0] | |
| # Verbatim-transcription instruction. The online path runs for ANY page that | |
| # needs OCR (handwritten OR printed), so this must NOT exclude printed text — | |
| # it covers both. Still ignores pre-printed page furniture (Date/Page stamps, | |
| # margin rules) which would otherwise leak into the output. | |
| DEFAULT_PROMPT = ( | |
| "Transcribe all text in this image verbatim — both printed and handwritten. " | |
| "Preserve line breaks and spelling exactly; do not correct, summarize, or " | |
| "add commentary. Ignore pre-printed page furniture such as 'Date'/'Page' " | |
| "header labels, margin rules and ruled lines. Output only the transcription." | |
| ) | |
| # Inline image payload cap. The TOTAL request (image + prompt, base64-expanded) | |
| # must stay under 20 MB; above that the Files API is required. We guard on the | |
| # base64 string length and surface an actionable error well before the wire. | |
| _MAX_INLINE_B64_BYTES = 20 * 1024 * 1024 | |
| # Cap the longest image side sent to Gemini. Gemini internally tiles images | |
| # (~768 px tiles), so a huge 300-DPI render only inflates the upload with no | |
| # accuracy gain — and a multi-MB PNG over a slow connection causes "write | |
| # operation timed out" errors. Downscaling + JPEG keeps requests small and fast. | |
| _MAX_IMAGE_SIDE = 2048 | |
| _JPEG_QUALITY = 90 | |
| def is_configured(api_key) -> bool: | |
| """True if a usable (non-empty) Gemini API key has been supplied. | |
| Does NOT validate the key against the network — it only checks presence, | |
| so calling it is free and side-effect-free. | |
| """ | |
| return bool(api_key and str(api_key).strip()) | |
| def _clean_key(api_key) -> str: | |
| """Return the trimmed key, or raise a clean, KEY-FREE RuntimeError. | |
| A key with an interior newline (pasted wrapped across two lines) or a | |
| non-ASCII/control character would otherwise make http.client raise a raw | |
| ValueError — whose message INCLUDES the full key value — or a | |
| UnicodeEncodeError, neither of which subclasses HTTPError/URLError, so they | |
| escape the documented "only ever raise a single-line RuntimeError" contract | |
| and (for the ValueError case) echo the key into the per-page error text that | |
| jobs.py broadcasts/stores. Validate up front and never put the key in the | |
| message. | |
| """ | |
| key = str(api_key).strip() | |
| if not key.isascii() or any(ord(c) < 0x20 or ord(c) == 0x7F for c in key): | |
| raise RuntimeError( | |
| "The Gemini API key contains invalid characters (control or " | |
| "non-ASCII). Re-copy it from https://aistudio.google.com/." | |
| ) | |
| return key | |
| def _resolve_model(model) -> str: | |
| """Return a clean bare model id, defaulting when unset. | |
| Accepts either a bare id ("gemini-2.5-pro") or the "models/<id>" form and | |
| normalizes to the bare id used in the URL placeholder. | |
| """ | |
| name = (model or DEFAULT_MODEL).strip() | |
| if name.startswith("models/"): | |
| name = name[len("models/"):] | |
| return name or DEFAULT_MODEL | |
| def _encode_image_b64(img_bgr: np.ndarray): | |
| """Downscale a huge image, JPEG-encode it, return ``(base64_str, mime_type)``. | |
| Caps the longest side at ``_MAX_IMAGE_SIDE`` and uses JPEG (much smaller than | |
| PNG for scans/photos) so the request uploads quickly even on slow links. | |
| """ | |
| if img_bgr is None or getattr(img_bgr, "size", 0) == 0: | |
| raise RuntimeError( | |
| "Online OCR received an empty image; nothing to transcribe." | |
| ) | |
| h, w = img_bgr.shape[:2] | |
| longest = max(h, w) | |
| if longest > _MAX_IMAGE_SIDE: | |
| scale = _MAX_IMAGE_SIDE / float(longest) | |
| img_bgr = cv2.resize( | |
| img_bgr, | |
| (max(1, int(round(w * scale))), max(1, int(round(h * scale)))), | |
| interpolation=cv2.INTER_AREA, | |
| ) | |
| ok, buf = cv2.imencode( | |
| ".jpg", img_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), _JPEG_QUALITY] | |
| ) | |
| if not ok: | |
| raise RuntimeError( | |
| "Online OCR could not encode the page image (cv2.imencode failed). " | |
| "Check that the image is a valid HxWx3 uint8 array." | |
| ) | |
| # Standard base64 of the raw JPEG file bytes — NOT a data: URI. | |
| return base64.standard_b64encode(buf.tobytes()).decode("ascii"), "image/jpeg" | |
| def _build_request_body(image_b64: str, mime_type: str, prompt: str) -> dict: | |
| """Assemble the generateContent request body per the verified contract.""" | |
| return { | |
| "contents": [ | |
| { | |
| "parts": [ | |
| { | |
| "inline_data": { | |
| "mime_type": mime_type, | |
| "data": image_b64, | |
| } | |
| }, | |
| {"text": prompt}, | |
| ] | |
| } | |
| ], | |
| "generationConfig": { | |
| "temperature": 0, | |
| "responseMimeType": "text/plain", | |
| }, | |
| } | |
| def _extract_text(payload: dict) -> str: | |
| """Pull the transcription out of a generateContent response. | |
| Concatenates the ``text`` of every part in ``candidates[0].content.parts`` | |
| (text output can be split across parts). Raises an actionable RuntimeError | |
| when the response carries no usable text — e.g. a prompt-level block | |
| (``promptFeedback.blockReason``) or a non-STOP ``finishReason`` such as | |
| SAFETY / MAX_TOKENS / RECITATION. | |
| """ | |
| # Prompt blocked before any candidate was produced. | |
| feedback = payload.get("promptFeedback") if isinstance(payload.get("promptFeedback"), dict) else {} | |
| block_reason = feedback.get("blockReason") | |
| candidates = payload.get("candidates") or [] | |
| if not isinstance(candidates, list): | |
| # A non-list "candidates" (proxy corruption / future API change) would | |
| # otherwise index/attribute-error below and escape the module's | |
| # single-line-RuntimeError contract. Degrade to a clean message. | |
| raise RuntimeError( | |
| "Gemini returned an unexpected response shape (candidates was not a " | |
| "list); retry shortly." | |
| ) | |
| if not candidates: | |
| if block_reason: | |
| raise RuntimeError( | |
| "Gemini blocked the request (promptFeedback.blockReason=" | |
| "{}). The page image or prompt tripped a safety filter; " | |
| "online OCR is unavailable for this page.".format(block_reason) | |
| ) | |
| raise RuntimeError( | |
| "Gemini returned no candidates and no text. The response was " | |
| "empty; try again or switch models." | |
| ) | |
| candidate = candidates[0] if isinstance(candidates[0], dict) else {} | |
| finish_reason = candidate.get("finishReason") | |
| content = candidate.get("content") | |
| if not isinstance(content, dict): | |
| content = {} | |
| parts = content.get("parts") | |
| if not isinstance(parts, list): | |
| parts = [] | |
| # ``part.get("text", "")`` only defaults when the key is ABSENT; an explicit | |
| # ``{"text": null}`` would yield None and break the join. Coerce defensively. | |
| text = "".join( | |
| str(part.get("text") or "") for part in parts if isinstance(part, dict) | |
| ) | |
| if not text.strip(): | |
| if finish_reason and finish_reason != "STOP": | |
| raise RuntimeError( | |
| "Gemini produced no text (finishReason={}). This usually " | |
| "means the output was cut off (MAX_TOKENS) or filtered " | |
| "(SAFETY/RECITATION); try a different page or model.".format( | |
| finish_reason | |
| ) | |
| ) | |
| raise RuntimeError( | |
| "Gemini returned an empty transcription for this page." | |
| ) | |
| # On a clean STOP we return the text as-is; if truncated but non-empty we | |
| # still return what we have (a partial transcription beats failing hard). | |
| return text | |
| def _friendly_http_error(err: urllib.error.HTTPError) -> RuntimeError: | |
| """Translate an HTTPError into an actionable RuntimeError. | |
| Reads the JSON ``error`` body (code/message/status) to surface the API's | |
| own message and maps the common status codes to plain guidance. | |
| """ | |
| code = err.code | |
| api_message = "" | |
| api_status = "" | |
| try: | |
| raw = err.read() | |
| if raw: | |
| body = json.loads(raw.decode("utf-8", "replace")) | |
| error_obj = body.get("error") or {} | |
| api_message = str(error_obj.get("message") or "").strip() | |
| api_status = str(error_obj.get("status") or "").strip() | |
| except Exception: | |
| # Body wasn't JSON or couldn't be read; fall back to the status code. | |
| pass | |
| detail = api_message or err.reason or "no detail provided" | |
| if code in (401, 403): | |
| return RuntimeError( | |
| "Gemini rejected the API key (HTTP {} {}): {}. Check that " | |
| "GEMINI_API_KEY is a valid, enabled key from " | |
| "https://aistudio.google.com/ and has access to this model." | |
| .format(code, api_status or "PERMISSION_DENIED", detail) | |
| ) | |
| if code == 429: | |
| return RuntimeError( | |
| "Gemini rate limit / quota exceeded (HTTP 429 {}): {}. Slow down " | |
| "requests, wait and retry, switch to a lighter model, or request " | |
| "a quota increase in Google AI Studio." | |
| .format(api_status or "RESOURCE_EXHAUSTED", detail) | |
| ) | |
| if code == 400: | |
| return RuntimeError( | |
| "Gemini rejected the request (HTTP 400 {}): {}. This is usually a " | |
| "malformed body, an unsupported model id, or (FAILED_PRECONDITION) " | |
| "billing not enabled for your region." | |
| .format(api_status or "INVALID_ARGUMENT", detail) | |
| ) | |
| if code == 404: | |
| return RuntimeError( | |
| "Gemini model or resource not found (HTTP 404 {}): {}. Check the " | |
| "model id ({}).".format( | |
| api_status or "NOT_FOUND", detail, ", ".join(SUPPORTED_MODELS) | |
| ) | |
| ) | |
| if code in (500, 503, 504): | |
| return RuntimeError( | |
| "Gemini server error (HTTP {} {}): {}. The service is overloaded " | |
| "or timed out; retry in a moment or switch to another model." | |
| .format(code, api_status or "UNAVAILABLE", detail) | |
| ) | |
| return RuntimeError( | |
| "Gemini request failed (HTTP {} {}): {}.".format( | |
| code, api_status or "ERROR", detail | |
| ) | |
| ) | |
| def list_models(api_key, *, timeout=30) -> list: | |
| """Validate the key and return available bare model ids (no network on import). | |
| Calls ``GET /v1beta/models`` and returns the bare ids (``models/`` prefix | |
| stripped) of models that support ``generateContent``. Doubles as a cheap | |
| key-validation call for the UI. Raises an actionable RuntimeError on a bad | |
| key / network failure, mirroring ``transcribe_image_bgr``. | |
| """ | |
| if not is_configured(api_key): | |
| raise RuntimeError( | |
| "Online OCR needs a Gemini API key but none was provided. Get a " | |
| "free key from https://aistudio.google.com/." | |
| ) | |
| api_key = _clean_key(api_key) | |
| request = urllib.request.Request( | |
| _API_BASE, # GET https://generativelanguage.googleapis.com/v1beta/models | |
| method="GET", | |
| headers={_API_KEY_HEADER: api_key}, | |
| ) | |
| try: | |
| with urllib.request.urlopen(request, timeout=timeout) as response: | |
| raw = response.read() | |
| except urllib.error.HTTPError as err: | |
| raise _friendly_http_error(err) from None | |
| except urllib.error.URLError as err: | |
| raise RuntimeError( | |
| "Could not reach the Gemini API ({}). Check your internet " | |
| "connection.".format(err.reason) | |
| ) from None | |
| except TimeoutError: | |
| raise RuntimeError( | |
| "The Gemini request timed out after {}s.".format(timeout) | |
| ) from None | |
| try: | |
| payload = json.loads(raw.decode("utf-8", "replace")) | |
| except (ValueError, AttributeError) as err: | |
| raise RuntimeError( | |
| "Gemini returned a non-JSON model list ({}).".format(err) | |
| ) from None | |
| if not isinstance(payload, dict): | |
| raise RuntimeError( | |
| "Gemini returned an unexpected model-list response (not a JSON object)." | |
| ) | |
| out = [] | |
| for entry in payload.get("models") or []: | |
| if not isinstance(entry, dict): | |
| continue | |
| methods = entry.get("supportedGenerationMethods") or [] | |
| if methods and "generateContent" not in methods: | |
| continue | |
| name = str(entry.get("name") or "") | |
| if name.startswith("models/"): | |
| name = name[len("models/"):] | |
| if name: | |
| out.append(name) | |
| return out | |
| def best_available_model(available) -> str: | |
| """Pick the strongest handwriting model we support that the key can access. | |
| Falls back to :data:`DEFAULT_MODEL` if none of ``available`` is recognized. | |
| """ | |
| avail = set(available or ()) | |
| for m in SUPPORTED_MODELS: | |
| if m in avail: | |
| return m | |
| return DEFAULT_MODEL | |
| def transcribe_image_bgr( | |
| img_bgr: np.ndarray, | |
| *, | |
| api_key, | |
| model=None, | |
| prompt=None, | |
| timeout=120, | |
| ) -> str: | |
| """Transcribe an OpenCV BGR page image with Gemini and return the text. | |
| Parameters | |
| ---------- | |
| img_bgr : np.ndarray | |
| An OpenCV ``HxWx3`` BGR ``uint8`` image (a rendered PDF page). | |
| api_key : str | |
| A Gemini API key from https://aistudio.google.com/. Required. | |
| model : str, optional | |
| A model id from :data:`SUPPORTED_MODELS` (bare id or "models/<id>"). | |
| Defaults to :data:`DEFAULT_MODEL`. | |
| prompt : str, optional | |
| Override the verbatim-transcription instruction | |
| (:data:`DEFAULT_PROMPT`). | |
| timeout : float, optional | |
| Per-request socket timeout in seconds (default 120). | |
| Returns | |
| ------- | |
| str | |
| The verbatim transcription (line breaks preserved). | |
| Raises | |
| ------ | |
| RuntimeError | |
| With an actionable, single-line message (never a raw stack trace) for | |
| a missing/empty key, an invalid key, a rate limit, a network/timeout | |
| failure, or an empty/safety-blocked response. | |
| """ | |
| if not is_configured(api_key): | |
| raise RuntimeError( | |
| "Online OCR needs a Gemini API key but none was provided. Get a " | |
| "free key from https://aistudio.google.com/ and pass it as the " | |
| "api_key argument (or set GEMINI_API_KEY)." | |
| ) | |
| api_key = _clean_key(api_key) | |
| model_id = _resolve_model(model) | |
| prompt_text = prompt if (prompt and prompt.strip()) else DEFAULT_PROMPT | |
| image_b64, mime_type = _encode_image_b64(img_bgr) | |
| if len(image_b64) > _MAX_INLINE_B64_BYTES: | |
| raise RuntimeError( | |
| "Page image is too large for an inline Gemini request " | |
| "(~{:.1f} MB base64; the 20 MB inline limit applies to the whole " | |
| "request). Render the page at a lower DPI, or use Gemini's Files " | |
| "API for payloads this big.".format( | |
| len(image_b64) / (1024 * 1024) | |
| ) | |
| ) | |
| body = _build_request_body(image_b64, mime_type, prompt_text) | |
| data = json.dumps(body).encode("utf-8") | |
| url = _GENERATE_URL.format(model=model_id) | |
| request = urllib.request.Request( | |
| url, | |
| data=data, | |
| method="POST", | |
| headers={ | |
| "Content-Type": "application/json", | |
| _API_KEY_HEADER: api_key, # already cleaned/validated above | |
| }, | |
| ) | |
| try: | |
| with urllib.request.urlopen(request, timeout=timeout) as response: | |
| raw = response.read() | |
| except urllib.error.HTTPError as err: | |
| # Non-2xx: surface the API's own error message via a friendly mapping. | |
| raise _friendly_http_error(err) from None | |
| except urllib.error.URLError as err: | |
| # DNS failure, connection refused, TLS error, or a socket timeout | |
| # (which arrives wrapped as URLError(reason=timeout)). | |
| raise RuntimeError( | |
| "Could not reach the Gemini API ({}). Check your internet " | |
| "connection and that generativelanguage.googleapis.com is " | |
| "reachable; the request may also have timed out (timeout={}s)." | |
| .format(err.reason, timeout) | |
| ) from None | |
| except TimeoutError as err: # bare socket timeout on some Python versions | |
| raise RuntimeError( | |
| "The Gemini request timed out after {}s. Try a smaller image, a " | |
| "lighter model, or a longer timeout.".format(timeout) | |
| ) from None | |
| try: | |
| payload = json.loads(raw.decode("utf-8", "replace")) | |
| except (ValueError, AttributeError) as err: | |
| raise RuntimeError( | |
| "Gemini returned a response that was not valid JSON ({}). The " | |
| "service may be having problems; retry shortly.".format(err) | |
| ) from None | |
| if not isinstance(payload, dict): | |
| raise RuntimeError( | |
| "Gemini returned an unexpected response (not a JSON object); " | |
| "retry shortly." | |
| ) | |
| return _extract_text(payload) | |