Spaces:
Running on Zero
Running on Zero
| """Image -> text extraction with a local vision model (MiniCPM-V-4.5). | |
| Runs in-Space on ZeroGPU via the @spaces.GPU decorator. The model reads a | |
| document/ID image and returns the fields as plain text, which then flows | |
| through the same PII detection + redaction core as typed text. | |
| MiniCPM-V-4.5 (model_type "minicpmv") uses trust_remote_code + model.chat() | |
| and runs on transformers 4.x. The weights are downloaded/loaded on CPU | |
| *before* entering the GPU window so the 16GB download can't blow the | |
| ZeroGPU time budget; the GPU call only moves the model to CUDA and generates. | |
| Local dev: set REDAC_MOCK=1 to skip the model entirely and return a canned | |
| extraction, so the Gradio UI runs on a laptop with no GPU. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from functools import lru_cache | |
| MODEL_ID = "openbmb/MiniCPM-V-4_5" | |
| EXTRACTION_PROMPT = ( | |
| "You are a document data extractor. Read this image and transcribe ALL " | |
| "personal and sensitive information you can find. Output one field per " | |
| "line as 'field: value'. Include, when present: full name, date of birth, " | |
| "address, passport/ID/driver-license number, national ID or social " | |
| "security number, phone, email, and any account or card numbers. " | |
| "Transcribe values exactly as written. Do not invent fields." | |
| ) | |
| _MOCK_EXTRACTION = ( | |
| "full name: John A. Doe\n" | |
| "date of birth: 1985-04-12\n" | |
| "address: 221B Baker Street, London\n" | |
| "passport number: X1234567\n" | |
| "national id number: 123-45-6789\n" | |
| "email: john.doe@example.com\n" | |
| "phone: +49 151 23456789" | |
| ) | |
| def _is_mock() -> bool: | |
| return os.environ.get("REDAC_MOCK", "").strip() in {"1", "true", "True"} | |
| # ZeroGPU decorator; degrade to a no-op decorator when `spaces` is absent | |
| # (local dev) so the module imports cleanly off-Space. | |
| try: | |
| import spaces # type: ignore | |
| _gpu = spaces.GPU(duration=120) | |
| except Exception: # pragma: no cover - local fallback | |
| def _gpu(fn): | |
| return fn | |
| def _load_model(): | |
| """Download + load weights on CPU. Cached. Called outside the GPU window.""" | |
| import torch | |
| from transformers import AutoModel, AutoTokenizer | |
| model = AutoModel.from_pretrained( | |
| MODEL_ID, | |
| trust_remote_code=True, | |
| attn_implementation="sdpa", | |
| torch_dtype=torch.bfloat16, | |
| ).eval() | |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True) | |
| return model, tokenizer | |
| def _chat(image, prompt: str) -> str: | |
| model, tokenizer = _load_model() # cached -> instant inside GPU window | |
| model = model.to("cuda") | |
| msgs = [{"role": "user", "content": [image, prompt]}] | |
| return model.chat(image=None, msgs=msgs, tokenizer=tokenizer, sampling=False) | |
| def extract_text_from_image(image, prompt: str | None = None) -> str: | |
| """Return extracted field text from a PIL image. Honors REDAC_MOCK.""" | |
| if image is None: | |
| return "" | |
| if _is_mock(): | |
| return _MOCK_EXTRACTION | |
| _load_model() # warm the cache on CPU before claiming the GPU | |
| return _chat(image.convert("RGB"), prompt or EXTRACTION_PROMPT).strip() | |