indic-doc-parser / idp_recognizer.py
spark-ux's picture
Copy from bodhan-ai/indic-doc-parser
7b2177e verified
Raw
History Blame Contribute Delete
5.62 kB
# GENERATED by hub/build_hub_package.py from src/bodhan_genai/ocr/engine/recognizer.py -- do not edit.
# Vendored so this repo is self-contained: `pip install transformers torch pillow` is the
# whole install. See indic_doc_parser.py for usage.
"""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: # pragma: no cover
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()
# RecognizerConfig.batch_size sizes a vLLM chunk (~2048). Generating that many at once
# here would simply OOM; HF batches are bounded by memory, not by scheduler behaviour.
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
# Left padding so every sequence in a batch ends flush against the generation boundary.
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()
# The checkpoint's generation_config carries eos_token_id 248044, which is an ordinary
# word piece, not a turn terminator. Left alone, generate() never stops and every block
# runs to max_new_tokens, repeating itself. vLLM does not hit this because it takes the
# tokenizer's EOS. Trust the tokenizer here too.
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