| |
| |
| |
|
|
| """IndicBlockOCR: crops in, transcriptions out. |
| |
| Heavy imports live inside methods, so importing this module stays free -- asserted by |
| tests/ocr/test_ocr_lazy_import.py. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from typing import TYPE_CHECKING, NamedTuple, Protocol, runtime_checkable |
|
|
| from idp_types import CropConfig, RecognizerConfig |
|
|
| if TYPE_CHECKING: |
| from PIL.Image import Image |
|
|
|
|
| class CropRequest(NamedTuple): |
| image: Image |
| prompt: str |
|
|
|
|
| @runtime_checkable |
| class RecognizerBackend(Protocol): |
| """``transcribe`` returns one string per request, in the same order.""" |
|
|
| def transcribe(self, requests: list[CropRequest]) -> list[str]: ... |
|
|
| def close(self) -> None: ... |
|
|
|
|
| def build_requests(blocks, page, crop_cfg: CropConfig, table_format) -> tuple[list, list]: |
| """Crop each block and pair it with its prompt. |
| |
| Returns ``(requests, orders)`` -- the reading-order rank of each request, so transcriptions |
| can be matched back. Blocks that yield no crop are simply absent from both. |
| """ |
| from idp_crops import area_clamp, crop_for |
| from idp_contract import prompt_for |
|
|
| requests, orders = [], [] |
| for block in blocks: |
| crop = crop_for(block, page, crop_cfg) |
| if crop is None: |
| continue |
| requests.append( |
| CropRequest(area_clamp(crop, crop_cfg), prompt_for(block.type, table_format)) |
| ) |
| orders.append(block.order) |
| return requests, orders |
|
|
|
|
| class HfRecognizer: |
| """Reference recognizer on plain ``transformers`` -- no vLLM. |
| |
| Exists so IndicDocParser can run anywhere ``transformers`` runs, including straight from the |
| Hub with ``trust_remote_code=True``. It is the *quickstart* path, not the working one: |
| without continuous batching it is orders of magnitude slower per block than |
| :class:`VllmRecognizer`, so use it to try a page, not to parse a corpus. |
| |
| Output also diverges slightly from the vLLM path. Both decode greedily, but different kernels |
| give different logits, and a near-tie flips the argmax -- so do not expect byte-identical |
| transcriptions between the two backends. |
| """ |
|
|
| def __init__( |
| self, |
| ckpt: str | None = None, |
| config: RecognizerConfig | None = None, |
| device: str = "auto", |
| attn_implementation: str = "sdpa", |
| batch_size: int = 8, |
| ) -> None: |
| import torch |
| from transformers import AutoModelForImageTextToText, AutoProcessor |
|
|
|
|
| self._torch = torch |
| self.config = config or RecognizerConfig() |
| |
| |
| self.batch_size = batch_size |
| if ckpt is None: |
| raise ValueError( |
| "no recognizer weights given -- " |
| "IndicDocParser.from_pretrained(snapshot_download(REPO))" |
| ) |
| self.ckpt = ckpt |
|
|
| self.processor = AutoProcessor.from_pretrained(self.ckpt) |
| tokenizer = self.processor.tokenizer |
| |
| tokenizer.padding_side = "left" |
|
|
| self.model = AutoModelForImageTextToText.from_pretrained( |
| self.ckpt, |
| dtype=getattr(torch, self.config.dtype), |
| device_map=device, |
| attn_implementation=attn_implementation, |
| ) |
| self.model.eval() |
|
|
| |
| |
| |
| |
| self.eos_token_id = tokenizer.eos_token_id |
| self.pad_token_id = tokenizer.pad_token_id or tokenizer.eos_token_id |
|
|
| def _prompt(self, text: str) -> str: |
| return self.processor.apply_chat_template( |
| [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": text}]}], |
| add_generation_prompt=True, |
| tokenize=False, |
| ) |
|
|
| def transcribe(self, requests: list[CropRequest]) -> list[str]: |
| texts: list[str] = [] |
| for i in range(0, len(requests), self.batch_size): |
| chunk = requests[i : i + self.batch_size] |
| inputs = self.processor( |
| text=[self._prompt(r.prompt) for r in chunk], |
| images=[r.image for r in chunk], |
| padding=True, |
| return_tensors="pt", |
| ).to(self.model.device) |
| prompt_len = inputs["input_ids"].shape[-1] |
|
|
| with self._torch.inference_mode(): |
| out = self.model.generate( |
| **inputs, |
| max_new_tokens=self.config.max_tokens, |
| do_sample=False, |
| use_cache=True, |
| eos_token_id=self.eos_token_id, |
| pad_token_id=self.pad_token_id, |
| ) |
| texts.extend(self.processor.batch_decode(out[:, prompt_len:], skip_special_tokens=True)) |
| return [t.strip() for t in texts] |
|
|
| def close(self) -> None: |
| self.model = None |
| self.processor = None |
|
|