CoolFace
Apppublic

algoryn/dots-ocr-idcard

sourceHugging Faceotherupdated 1y agoView on Hugging Face
0likes
model_loader.py397 linesDownload Raw Back to kybtech_dots_ocr
1"""Dots.OCR Model Loader2 3This module handles downloading and loading the Dots.OCR model using4Hugging Face's `snapshot_download`. It centralizes device selection,5dtype configuration, model initialization, and safe fallbacks.6 7Why this exists:8- Keep model lifecycle and I/O concerns isolated from API/business logic.9- Provide safe CPU defaults, optional CUDA acceleration, and optional10  FlashAttention2 when compatible and explicitly enabled.11 12Key environment variables:13- DOTS_OCR_REPO_ID: HF repo to download (default: "rednote-hilab/dots.ocr").14- DOTS_OCR_LOCAL_DIR: Local cache directory for `snapshot_download`.15- DOTS_OCR_DEVICE: One of {"cpu", "cuda", "auto"}. "auto" prefers CUDA.16- DOTS_OCR_MAX_NEW_TOKENS: Max generated tokens per request.17- DOTS_OCR_FLASH_ATTENTION: "1" to attempt FlashAttention2 when compatible.18- DOTS_OCR_MIN_PIXELS / DOTS_OCR_MAX_PIXELS: Image size bounds pre-inference.19- DOTS_OCR_PROMPT: Optional default transcription prompt.20 21Usage: call `load_model()` once, then `extract_text(image)` per request.22"""23 24import os25import logging26import torch27from typing import Optional, Tuple, Dict, Any28from pathlib import Path29 30from huggingface_hub import snapshot_download31from transformers import AutoModelForCausalLM, AutoProcessor32from PIL import Image33 34# Configure logging35logger = logging.getLogger(__name__)36 37# Environment variable configuration38#39# These env vars make runtime behavior tunable without code changes. Defaults are40# conservative to favor stability on CPU-only platforms; performance features41# are opt-in and gated by compatibility checks.42REPO_ID = os.getenv("DOTS_OCR_REPO_ID", "rednote-hilab/dots.ocr")43LOCAL_DIR = os.getenv("DOTS_OCR_LOCAL_DIR", "/data/models/dots-ocr")44DEVICE_CONFIG = os.getenv("DOTS_OCR_DEVICE", "auto")  # "auto" prefers CUDA if available45MAX_NEW_TOKENS = int(os.getenv("DOTS_OCR_MAX_NEW_TOKENS", "2048"))46USE_FLASH_ATTENTION = os.getenv("DOTS_OCR_FLASH_ATTENTION", "0") == "1"  # opt-in47MIN_PIXELS = int(os.getenv("DOTS_OCR_MIN_PIXELS", "3136"))  # 56x56 lower bound48MAX_PIXELS = int(os.getenv("DOTS_OCR_MAX_PIXELS", "11289600"))  # 3360x3360 upper bound49CUSTOM_PROMPT = os.getenv("DOTS_OCR_PROMPT")50 51# Default transcription prompt for faithful text extraction.52# Keep terse to reduce bias; we want faithful extraction, not translation or formatting.53DEFAULT_PROMPT = (54    "Transcribe all visible text in the image in the original language. "55    "Do not translate. Preserve natural reading order. Output plain text only."56)57 58 59class DotsOCRModelLoader:60    """Handles Dots.OCR model downloading, loading, and inference.61 62    Encapsulates model lifecycle (download, init, device placement), preprocessing,63    and a narrow inference surface for OCR. Exposes a minimal API and maintains a64    single global instance via helpers below.65    """66 67    def __init__(self):68        """Initialize the model loader.69 70        Heavyweight work is deferred until `load_model()` so that constructing this71        class is cheap. The default prompt is captured from env, if provided.72        """73        self.model = None74        self.processor = None75        self.device = None76        self.dtype = None77        self.local_dir = None78        self.prompt = CUSTOM_PROMPT or DEFAULT_PROMPT79 80    def _determine_device_and_dtype(self) -> Tuple[str, torch.dtype]:81        """Pick device and dtype based on availability and configuration.82 83        Rules:84        - Respect explicit "cpu" or "cuda" when valid.85        - "auto" selects CUDA when available, else CPU.86        - Use bfloat16 on CUDA for throughput; float32 on CPU for correctness.87        """88        if DEVICE_CONFIG == "cpu":89            device = "cpu"90            dtype = torch.float3291        elif DEVICE_CONFIG == "cuda" and torch.cuda.is_available():92            device = "cuda"93            dtype = torch.bfloat1694        elif DEVICE_CONFIG == "auto":95            if torch.cuda.is_available():96                device = "cuda"97                dtype = torch.bfloat1698            else:99                device = "cpu"100                dtype = torch.float32101        else:102            # Fallback to CPU if CUDA requested but not available103            logger.warning(f"CUDA requested but not available, falling back to CPU")104            device = "cpu"105            dtype = torch.float32106 107        logger.info(f"Selected device: {device}, dtype: {dtype}")108        return device, dtype109 110    def _download_model(self) -> str:111        """Download the model using `snapshot_download` and ensure cache dir exists.112 113        Returns the resolved local path for deterministic, offline-friendly loading.114        Raises `RuntimeError` on failure.115        """116        logger.info(f"Downloading model from {REPO_ID} to {LOCAL_DIR}")117 118        try:119            # Ensure local directory exists120            Path(LOCAL_DIR).mkdir(parents=True, exist_ok=True)121 122            # Download model snapshot123            local_path = snapshot_download(124                repo_id=REPO_ID,125                local_dir=LOCAL_DIR,126            )127 128            logger.info(f"Model downloaded successfully to {local_path}")129            return local_path130 131        except Exception as e:132            logger.error(f"Failed to download model: {e}")133            raise RuntimeError(f"Model download failed: {e}")134 135    def _can_use_flash_attn(self) -> bool:136        """Check whether FlashAttention2 can be enabled safely.137 138        Requires all of:139        - DOTS_OCR_FLASH_ATTENTION toggle is set.140        - `flash_attn` is importable.141        - dtype is fp16/bf16 per library support.142        """143        if not USE_FLASH_ATTENTION:144            return False145        try:146            # Import check avoids runtime error from Transformers if not installed147            import flash_attn  # type: ignore  # noqa: F401148        except Exception:149            logger.warning(150                "flash_attn package not installed; disabling FlashAttention2"151            )152            return False153        # FlashAttention2 supports fp16/bf16 only (see HF docs)154        return self.dtype in (torch.float16, torch.bfloat16)155 156    def load_model(self) -> None:157        """Load the Dots.OCR model and processor.158 159        Steps:160        1) Determine device/dtype161        2) Download snapshot if missing162        3) Load `AutoProcessor`163        4) Configure attention/device mapping164        5) Instantiate model and place on target device165        """166        try:167            # Determine device and dtype168            self.device, self.dtype = self._determine_device_and_dtype()169 170            # Download model if not already present171            self.local_dir = self._download_model()172 173            # Load processor174            logger.info("Loading processor...")175            self.processor = AutoProcessor.from_pretrained(176                self.local_dir, trust_remote_code=True177            )178 179            # Load model with appropriate configuration180            model_kwargs = {181                "dtype": self.dtype,  # NOTE: `torch_dtype` is deprecated upstream182                "trust_remote_code": True,183            }184 185            # Add device-specific configurations186            if self.device == "cuda":187                # Prefer FlashAttention2 when truly available; otherwise SDPA188                if self._can_use_flash_attn():189                    model_kwargs["attn_implementation"] = "flash_attention_2"190                    logger.info("Using flash attention 2")191                else:192                    model_kwargs["attn_implementation"] = "sdpa"193                    logger.info(194                        "Using SDPA attention (flash-attn unavailable or disabled)"195                    )196 197                # Use device_map for automatic GPU memory management198                model_kwargs["device_map"] = "auto"199            else:200                # For CPU, don't use device_map201                model_kwargs["device_map"] = None202 203            logger.info("Loading model...")204            self.model = AutoModelForCausalLM.from_pretrained(205                self.local_dir, **model_kwargs206            )207 208            # Move model to device if not using device_map209            if self.device == "cpu" or model_kwargs.get("device_map") is None:210                self.model = self.model.to(self.device)211 212            logger.info(f"Model loaded successfully on {self.device}")213 214        except Exception as e:215            logger.error(f"Failed to load model: {e}")216            raise RuntimeError(f"Model loading failed: {e}")217 218    def _preprocess_image(self, image: Image.Image) -> Image.Image:219        """Preprocess image to meet model requirements.220 221        - Normalize to RGB222        - Constrain pixel count within [MIN_PIXELS, MAX_PIXELS]223        - Snap dimensions to multiples of 28 to satisfy backbone constraints224        """225        # Convert to RGB if necessary226        if image.mode != "RGB":227            image = image.convert("RGB")228 229        # Calculate current pixel count230        width, height = image.size231        current_pixels = width * height232 233        # Resize if necessary to meet pixel requirements234        if current_pixels < MIN_PIXELS:235            # Scale up to meet minimum pixel requirement236            scale_factor = (MIN_PIXELS / current_pixels) ** 0.5237            new_width = int(width * scale_factor)238            new_height = int(height * scale_factor)239            image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)240            logger.info(241                f"Scaled up image from {width}x{height} to {new_width}x{new_height}"242            )243 244        elif current_pixels > MAX_PIXELS:245            # Scale down to meet maximum pixel requirement246            scale_factor = (MAX_PIXELS / current_pixels) ** 0.5247            new_width = int(width * scale_factor)248            new_height = int(height * scale_factor)249            image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)250            logger.info(251                f"Scaled down image from {width}x{height} to {new_width}x{new_height}"252            )253 254        # Ensure dimensions are divisible by 28 (common requirement for vision models)255        width, height = image.size256        new_width = ((width + 27) // 28) * 28257        new_height = ((height + 27) // 28) * 28258 259        if new_width != width or new_height != height:260            image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)261            logger.info(262                f"Adjusted image dimensions to be divisible by 28: {new_width}x{new_height}"263            )264 265        return image266 267    @torch.inference_mode()268    def extract_text(self, image: Image.Image, prompt: Optional[str] = None) -> str:269        """Extract text from an image using the loaded model.270 271        Builds a single-turn chat message with the image and a transcription prompt,272        applies the model's chat template, and decodes deterministically (no sampling).273        """274        if self.model is None or self.processor is None:275            raise RuntimeError("Model not loaded. Call load_model() first.")276 277        try:278            # Preprocess image279            processed_image = self._preprocess_image(image)280 281            # Use provided prompt or default282            text_prompt = prompt or self.prompt283 284            # Prepare messages for the model285            messages = [286                {287                    "role": "user",288                    "content": [289                        {"type": "image", "image": processed_image},290                        {"type": "text", "text": text_prompt},291                    ],292                }293            ]294 295            # Apply chat template (preserves special tokens/formatting expected by model)296            text = self.processor.apply_chat_template(297                messages, tokenize=False, add_generation_prompt=True298            )299 300            # Process vision information (required for some models)301            try:302                from qwen_vl_utils import process_vision_info303 304                image_inputs, video_inputs = process_vision_info(messages)305            except ImportError:306                # Fallback if qwen_vl_utils not available307                logger.warning("qwen_vl_utils not available, using basic processing")308                image_inputs = [processed_image]309                video_inputs = []310 311            # Prepare inputs312            inputs = self.processor(313                text=[text],314                images=image_inputs,315                videos=video_inputs,316                padding=True,317                return_tensors="pt",318            ).to(self.device)319 320            # Generate text deterministically (temperature=0, do_sample=False)321            output_ids = self.model.generate(322                **inputs,323                max_new_tokens=MAX_NEW_TOKENS,324                do_sample=False,325                temperature=0.0,326                pad_token_id=self.processor.tokenizer.eos_token_id,327            )328 329            # Decode only newly generated tokens (strip prompt tokens)330            trimmed = [331                out[len(inp) :] for inp, out in zip(inputs.input_ids, output_ids)332            ]333            decoded = self.processor.batch_decode(334                trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False335            )336 337            return decoded[0] if decoded else ""338 339        except Exception as e:340            logger.error(f"Text extraction failed: {e}")341            raise RuntimeError(f"Text extraction failed: {e}")342 343    def is_loaded(self) -> bool:344        """Return True when both model and processor are initialized."""345        return self.model is not None and self.processor is not None346 347    def get_model_info(self) -> Dict[str, Any]:348        """Get diagnostic information about the loaded model and configuration."""349        return {350            "device": self.device,351            "dtype": str(self.dtype),352            "local_dir": self.local_dir,353            "repo_id": REPO_ID,354            "max_new_tokens": MAX_NEW_TOKENS,355            "use_flash_attention": USE_FLASH_ATTENTION,356            "prompt": self.prompt,357            "is_loaded": self.is_loaded(),358        }359 360 361# Global model instance362_model_loader: Optional[DotsOCRModelLoader] = None363 364 365def get_model_loader() -> DotsOCRModelLoader:366    """Get the global model loader instance."""367    global _model_loader368    if _model_loader is None:369        _model_loader = DotsOCRModelLoader()370    return _model_loader371 372 373def load_model() -> None:374    """Load the Dots.OCR model."""375    loader = get_model_loader()376    loader.load_model()377 378 379def extract_text(image: Image.Image, prompt: Optional[str] = None) -> str:380    """Extract text from an image using the loaded model."""381    loader = get_model_loader()382    if not loader.is_loaded():383        raise RuntimeError("Model not loaded. Call load_model() first.")384    return loader.extract_text(image, prompt)385 386 387def is_model_loaded() -> bool:388    """Check if the model is loaded and ready."""389    loader = get_model_loader()390    return loader.is_loaded()391 392 393def get_model_info() -> Dict[str, Any]:394    """Get information about the loaded model."""395    loader = get_model_loader()396    return loader.get_model_info()397