Source code for rapidtools.models.hf_vision

# Copyright (c) 2025 The University of Washington
#
# This file is part of rapidtools.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# You should have received a copy of the BSD 3-Clause License along with
# rapidtools. If not, see <http://www.opensource.org/licenses/>.
#
# Contributors:
# Barbaros Cetiner
#
# Last updated:
# 09-28-2026

"""
Generic local vision-language model backend for Hugging Face checkpoints.

Any model that Transformers exposes through its multimodal auto class
(``AutoModelForMultimodalLM`` / ``AutoModelForImageTextToText``) and that
ships a chat template can be used: Qwen3.8 / Qwen3.6 / Qwen3.5 / Qwen3-VL /
Qwen2.5-VL, Meta Muse Glimmer, Llama 4 and Llama 3.2 Vision, Gemma 3 and
Gemma 3n, InternVL3.5 / InternVL3, LLaVA-NeXT / LLaVA-OneVision, Pixtral,
Granite Vision and many more.

:class:`HFVisionInference` is also the base class of the family-specific
wrappers :class:`~rapidtools.models.QwenVisionInference` and
:class:`~rapidtools.models.MuseGlimmerInference`, which only swap the
default checkpoint and catalogue.

Example:
    >>> from rapidtools.models import HFVisionInference
    >>> HFVisionInference.list_available_models()[:2]
    ['Qwen/Qwen3.8-27B', 'Qwen/Qwen3.6-27B']
    >>> model = HFVisionInference('Qwen/Qwen2.5-VL-3B-Instruct', load_in_4bit=True)
    >>> out = model.run_inference('roof.jpg', 'Describe the roof damage.')
    >>> print(out.text)
"""

from __future__ import annotations

import logging
from pathlib import Path
from typing import Any

import torch
from transformers import AutoProcessor
from transformers import logging as hf_logging

from .base import ModelOutput
from .catalogs import (
    DEFAULT_OPEN_VLM,
    OPEN_VLM_CATALOG,
    PROVIDERS,
)
from .local_base import BaseLocalInferenceModel

logger = logging.getLogger(__name__)


def transformers_version() -> str:
    """
    Return the installed Transformers version string.

    Example:
        >>> from rapidtools.models.hf_vision import transformers_version
        >>> transformers_version().count('.') >= 1
        True
    """
    import transformers

    return transformers.__version__


def transformers_at_least(minimum: str) -> bool:
    """
    Check whether the installed Transformers release is at least ``minimum``.

    Args:
        minimum: Version string such as ``'5.15.0'``.

    Returns:
        bool: ``True`` when the installed version satisfies the requirement.

    Example:
        >>> from rapidtools.models.hf_vision import transformers_at_least
        >>> transformers_at_least('4.0.0')
        True
    """
    from packaging.version import Version

    return Version(transformers_version()) >= Version(minimum)


def _load_multimodal_model(model_id: str, **kwargs: Any):
    """
    Load a checkpoint with whichever multimodal auto class Transformers has.

    Args:
        model_id: Hugging Face repository ID.
        **kwargs: Passed to ``from_pretrained`` (dtype, device_map, ...).

    Returns:
        The loaded ``PreTrainedModel``.

    Raises:
        ImportError: If the installed Transformers has no multimodal auto
            class.
    """
    import transformers

    for name in ('AutoModelForMultimodalLM', 'AutoModelForImageTextToText'):
        auto_class = getattr(transformers, name, None)
        if auto_class is not None:
            return auto_class.from_pretrained(model_id, **kwargs)
    raise ImportError(
        'This version of transformers has no multimodal auto class; '
        'please upgrade transformers.'
    )


[docs] class HFVisionInference(BaseLocalInferenceModel): """ Run any Hugging Face vision-language checkpoint that ships a chat template. Family-specific subclasses override the class attributes below. Attributes: MODEL_CATALOG: Curated checkpoint records for :meth:`list_available_models`. DEFAULT_MODEL: Checkpoint used when ``model_id`` is omitted. MIN_TRANSFORMERS_VERSION: Minimum Transformers release required by the family; a clear ``ImportError`` is raised when it is not met. FAMILY_NAME: Human-readable family label used in logs. Example: >>> from rapidtools.models import HFVisionInference >>> model = HFVisionInference('Qwen/Qwen2.5-VL-3B-Instruct', load_in_4bit=True) >>> out = model.run_inference('roof.jpg', 'Describe the roof damage.') >>> print(out.text) >>> batch = model.run_inference_batch( ... [['a.jpg'], ['b.jpg']], ['Rate damage 0-5.', 'Rate damage 0-5.'] ... ) >>> [o.text for o in batch] ['2', '4'] """ INFO = PROVIDERS['hf'] MODEL_CATALOG: list[dict[str, str]] = OPEN_VLM_CATALOG DEFAULT_MODEL: str = DEFAULT_OPEN_VLM MIN_TRANSFORMERS_VERSION: str | None = None FAMILY_NAME: str = 'Hugging Face VLM' def __init__( self, model_id: str | None = None, device: str = 'auto', load_in_4bit: bool = False, temperature: float = 0.4, max_tokens: int = 512, trust_remote_code: bool = False, ) -> None: """ Load the processor and weights. Args: model_id: Hugging Face repository ID of the checkpoint. Defaults to :attr:`DEFAULT_MODEL`. device: ``'auto'`` (spread across available devices), ``'cuda'``, or ``'cpu'``. load_in_4bit: Quantize with bitsandbytes so large models fit consumer GPUs. temperature: Sampling temperature (0 disables sampling). max_tokens: Maximum new tokens generated per call. trust_remote_code: Allow repositories that ship custom modelling code. Only enable for sources you trust. Raises: ImportError: If the installed Transformers is older than :attr:`MIN_TRANSFORMERS_VERSION` (or the catalogue entry's ``min_transformers``). """ logging.getLogger('huggingface_hub').setLevel(logging.WARNING) logging.getLogger('httpx').setLevel(logging.WARNING) hf_logging.set_verbosity_error() super().__init__(device=device, temperature=temperature, max_tokens=max_tokens) self.model_id = model_id or self.DEFAULT_MODEL self.load_in_4bit = load_in_4bit self._check_transformers_version() logger.info(f'Loading processor for {self.model_id}...') self.processor = AutoProcessor.from_pretrained( self.model_id, trust_remote_code=trust_remote_code ) tokenizer = getattr(self.processor, 'tokenizer', None) if tokenizer is not None: # Left padding keeps generated tokens aligned in batched decoding. tokenizer.padding_side = 'left' if tokenizer.pad_token is None and tokenizer.eos_token is not None: tokenizer.pad_token = tokenizer.eos_token logger.info(f'Loading model {self.model_id} (this may take a while)...') model_kwargs: dict[str, Any] = { 'dtype': 'auto', 'trust_remote_code': trust_remote_code, } if load_in_4bit: from transformers import BitsAndBytesConfig model_kwargs['quantization_config'] = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_compute_dtype=torch.float16 ) if self.device == 'auto' or load_in_4bit: model_kwargs['device_map'] = ( 'auto' if self.device == 'auto' else self.device ) self.model = _load_multimodal_model(self.model_id, **model_kwargs) else: self.model = _load_multimodal_model(self.model_id, **model_kwargs).to( self.device ) self.model.eval() logger.info(f'Model {self.model_id} loaded successfully.') # ------------------------------------------------------------ catalogue def _check_transformers_version(self) -> None: """Raise ``ImportError`` when Transformers is too old for the model.""" required = self.MIN_TRANSFORMERS_VERSION for entry in self.MODEL_CATALOG: if entry['model_id'] == self.model_id and entry.get('min_transformers'): required = entry['min_transformers'] break if required and not transformers_at_least(required): raise ImportError( f'{self.model_id} requires transformers>={required} but ' f'{transformers_version()} is installed. Run ' f'`pip install "transformers>={required}"`.' )
[docs] @classmethod def list_available_models(cls, auth_key: str | None = None) -> list[str]: """ Return the curated catalogue of checkpoints for this family. Args: auth_key: Unused; present for signature parity with API wrappers. Returns: list[str]: Hugging Face repository IDs, newest first. Example: >>> from rapidtools.models import HFVisionInference >>> 'google/gemma-3-4b-it' in HFVisionInference.list_available_models() True """ return [entry['model_id'] for entry in cls.MODEL_CATALOG]
[docs] @classmethod def search_hub_models(cls, limit: int = 40) -> list[str]: """ List the most downloaded image-text-to-text checkpoints on the Hub. The curated catalogue is returned first; Hub results that are not already listed are appended. Falls back to the catalogue alone when the Hub cannot be reached. Args: limit: Maximum number of Hub results to fetch. Returns: list[str]: Repository IDs. Example: >>> from rapidtools.models import HFVisionInference >>> ids = HFVisionInference.search_hub_models(limit=10) >>> ids[0] 'Qwen/Qwen3.8-27B' """ try: from huggingface_hub import list_models # ``sort='downloads'`` already returns the most downloaded models # first; the ``direction`` argument no longer exists in # huggingface_hub 1.x. found = list_models( pipeline_tag='image-text-to-text', sort='downloads', limit=limit, ) ids = [m.id for m in found if getattr(m, 'id', None)] except Exception as exc: # noqa: BLE001 - network failures degrade gracefully logger.warning(f'Could not query the Hugging Face Hub: {exc}') ids = [] curated = cls.list_available_models() return curated + [i for i in ids if i not in curated]
# ------------------------------------------------------------ helpers def _build_messages( self, image_inputs: list[str | Path], prompt: str ) -> list[dict[str, Any]] | None: """ Build a single-turn chat message with PIL images and the prompt. Returns ``None`` when images were requested but none could be loaded. """ content: list[dict[str, Any]] = [] for img_input in image_inputs: pil_img = self._load_image_as_pil(img_input) if pil_img is not None: content.append({'type': 'image', 'image': pil_img}) else: logger.warning(f'Skipping failed image: {img_input}') if image_inputs and not content: return None content.append({'type': 'text', 'text': prompt}) return [{'role': 'user', 'content': content}] def _apply_json_mode(self, prompt_str: str, kwargs: dict[str, Any]) -> str: """Append a JSON-only instruction when ``json_mode`` was requested.""" config = kwargs.get('config') if kwargs.get('json_mode') or (config is not None and config.json_mode): return ( f'{prompt_str}\n\nYou must respond with only valid JSON and no ' 'other conversational text.' ) return prompt_str def _generation_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: """Translate rapidtools options into ``generate()`` keyword arguments.""" resolved = self._resolve_generation( kwargs.get('config'), temperature=kwargs.get('temperature'), max_tokens=kwargs.get('max_tokens'), ) temperature = resolved.temperature do_sample = temperature is not None and temperature > 0.0 gen: dict[str, Any] = { 'max_new_tokens': resolved.max_tokens, 'do_sample': do_sample, } if do_sample: gen['temperature'] = temperature return gen def _generate( self, messages_batch: list[list[dict[str, Any]]], kwargs: dict[str, Any] ) -> list[str]: """Tokenize one or more conversations, generate, and decode new tokens.""" inputs = self.processor.apply_chat_template( messages_batch if len(messages_batch) > 1 else messages_batch[0], tokenize=True, add_generation_prompt=True, return_dict=True, return_tensors='pt', padding=len(messages_batch) > 1, ).to(self.model.device) input_len = inputs['input_ids'].shape[-1] with torch.no_grad(): outputs = self.model.generate(**inputs, **self._generation_kwargs(kwargs)) return [ self.processor.decode(row[input_len:], skip_special_tokens=True).strip() for row in outputs ] # ---------------------------------------------------------- inference
[docs] def run_inference( self, image_inputs: str | Path | list[str | Path], prompt: str | Path, **kwargs: Any, ) -> ModelOutput | None: """ Run the model on one prompt and its image(s). Args: image_inputs: A path/URL, a list of them, or ``None``/``[]`` for a text-only prompt. prompt: Prompt text or path to a prompt file. **kwargs: ``temperature``, ``max_tokens``, ``json_mode`` overrides or a ``config=GenerationConfig(...)``. Returns: ModelOutput | None: Generated text, or ``None`` when every image failed to load or generation raised. Example: >>> from rapidtools.models import HFVisionInference >>> model = HFVisionInference('Qwen/Qwen2.5-VL-3B-Instruct') >>> model.run_inference([], 'Reply with the word ready.').text 'ready' """ if image_inputs is None: image_inputs = [] elif not isinstance(image_inputs, list): image_inputs = [image_inputs] prompt_str = self._apply_json_mode(self._resolve_prompt(prompt), kwargs) messages = self._build_messages(image_inputs, prompt_str) if messages is None: logger.error('No valid images loaded. Cannot proceed with inference.') return None try: text = self._generate([messages], kwargs)[0] except Exception as exc: # noqa: BLE001 - surfaced as a failed asset logger.error(f'Inference failed for {self.model_id}: {exc}') return None return ModelOutput(text=text, raw_response={'generated_text': text})
[docs] def run_inference_batch( self, batch_image_inputs: list[list[str | Path]], batch_prompts: list[str], **kwargs: Any, ) -> list[ModelOutput | None]: """ Run the model on several prompt/image sets at once (left-padded). Entries whose images all failed to load yield ``None`` at their index. If the batched call raises (typically out-of-memory), the batch is retried one asset at a time. Args: batch_image_inputs: One list of image paths per asset. batch_prompts: One prompt per asset (same length). **kwargs: ``temperature`` and ``max_tokens`` overrides. Returns: list[ModelOutput | None]: Results aligned with the inputs. Example: >>> from rapidtools.models import HFVisionInference >>> model = HFVisionInference('Qwen/Qwen2.5-VL-3B-Instruct') >>> outs = model.run_inference_batch( ... [['a.jpg'], ['missing.jpg']], ['Describe.', 'Describe.'] ... ) >>> outs[1] is None True """ messages_batch: list[list[dict[str, Any]] | None] = [ self._build_messages( list(imgs), self._apply_json_mode(self._resolve_prompt(prompt), kwargs) ) for imgs, prompt in zip(batch_image_inputs, batch_prompts, strict=True) ] valid = [i for i, m in enumerate(messages_batch) if m is not None] valid_messages = [m for m in messages_batch if m is not None] results: list[ModelOutput | None] = [None] * len(messages_batch) if not valid: return results try: texts = self._generate(valid_messages, kwargs) except Exception as exc: # noqa: BLE001 logger.error(f'Batch inference failed for {self.model_id}: {exc}') if len(valid) > 1: logger.info('Retrying the batch one asset at a time...') for i in valid: results[i] = self.run_inference( list(batch_image_inputs[i]), batch_prompts[i], **kwargs ) return results for i, text in zip(valid, texts, strict=True): results[i] = ModelOutput(text=text, raw_response={'generated_text': text}) return results