Source code for rapidtools.models.gemma4

# 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-22-2026

"""
Google Gemma 4 vision-language models via Transformers.

Example:
    >>> from rapidtools.models import Gemma4Inference
    >>> model = Gemma4Inference('google/gemma-4-E2B-it')
    >>> 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 (
    GEMMA4_DEFAULT_MODEL,
    GEMMA4_MODEL_CATALOG,
    PROVIDERS,
)
from .hf_vision import _load_multimodal_model, transformers_at_least
from .local_base import BaseLocalInferenceModel

logger = logging.getLogger(__name__)


[docs] class Gemma4Inference(BaseLocalInferenceModel): """ Local inference model for Google's Gemma 4 vision-language models. Supports every instruction-tuned Gemma 4 size (31B, 26B-A4B, 12B, E4B, E2B). Images are loaded with the robust PIL loader of :class:`~rapidtools.models.local_base.BaseLocalInferenceModel` and generation runs sequentially (or in explicit batches through :meth:`run_inference_batch`) to protect GPU memory. Example: >>> from rapidtools.models import Gemma4Inference >>> Gemma4Inference.list_available_models()[-1] 'google/gemma-4-E2B-it' >>> model = Gemma4Inference('google/gemma-4-E4B-it', device='cuda') >>> out = model.run_inference( ... 'house.jpg', 'Rate the damage from 0 (none) to 5 (destroyed).' ... ) >>> out.text '4' """ INFO = PROVIDERS['gemma4'] MODEL_CATALOG: list[dict[str, str]] = GEMMA4_MODEL_CATALOG VALID_MODELS = [entry['model_id'] for entry in GEMMA4_MODEL_CATALOG] def __init__( self, model_id: str = GEMMA4_DEFAULT_MODEL, device: str = 'auto', temperature: float = 0.4, max_tokens: int = 2048, ) -> None: """ Initialize the processor and multimodal model. Args: model_id: Hugging Face model identifier. Defaults to ``'google/gemma-4-E2B-it'``; a warning is logged for IDs outside :attr:`VALID_MODELS`. device: ``'auto'``, ``'cuda'`` or ``'cpu'``. temperature: Sampling temperature. Defaults to 0.4. max_tokens: Maximum number of new tokens. Defaults to 2048. Raises: ImportError: If the checkpoint needs a newer Transformers release than the one installed. """ # Suppress HuggingFace HTTP and warning messages: 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 if self.model_id not in self.VALID_MODELS: logger.warning( f'Model ID {self.model_id} not in officially supported Gemma-4 list.' ) for entry in self.MODEL_CATALOG: required = entry.get('min_transformers') if entry['model_id'] == self.model_id and required: if not transformers_at_least(required): raise ImportError( f'{self.model_id} requires transformers>={required}. ' f'Run `pip install "transformers>={required}"`.' ) logger.info(f'Loading processor for {self.model_id}...') self.processor = AutoProcessor.from_pretrained(self.model_id) logger.info(f'Loading model {self.model_id} (this may take a while)...') if self.device == 'auto': self.model = _load_multimodal_model( self.model_id, dtype='auto', device_map='auto' ) else: self.model = _load_multimodal_model(self.model_id, dtype='auto').to( self.device ) self.model.eval()
[docs] @classmethod def list_available_models(cls, auth_key: str | None = None) -> list[str]: """ Return the curated instruction-tuned Gemma 4 checkpoints. Args: auth_key: Unused; present for signature parity with API wrappers. Returns: list[str]: Repository IDs, largest first. Example: >>> from rapidtools.models import Gemma4Inference >>> Gemma4Inference.list_available_models()[0] 'google/gemma-4-31B-it' """ return [entry['model_id'] for entry in cls.MODEL_CATALOG]
def _generation_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: """Translate rapidtools options into ``generate()`` keyword arguments.""" gen = self._resolve_generation( kwargs.get('config'), temperature=kwargs.get('temperature'), max_tokens=kwargs.get('max_tokens'), ) return { 'max_new_tokens': gen.max_tokens, 'temperature': gen.temperature, 'do_sample': gen.temperature is not None and gen.temperature > 0.0, } def _prompt_text(self, prompt: str | Path, kwargs: dict[str, Any]) -> str: """Resolve the prompt and append a JSON instruction in ``json_mode``.""" text = self._resolve_prompt(prompt) config = kwargs.get('config') if kwargs.get('json_mode') or (config is not None and config.json_mode): text += ( '\n\nYou must respond with only valid JSON and no other ' 'conversational text.' ) return text def _parse(self, response_text: str) -> Any: """Run the processor's ``parse_response`` hook when available.""" parse = getattr(self.processor, 'parse_response', None) if parse is None: return response_text try: return parse(response_text, prefix='') except TypeError: return parse(response_text)
[docs] def run_inference( self, image_inputs: str | Path | list[str | Path] | None, prompt: str | Path, **kwargs: Any, ) -> ModelOutput | None: """ Run multimodal inference on a single prompt and 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 and raw response, or ``None`` when every requested image failed to load or generation raised. Example: >>> from rapidtools.models import Gemma4Inference >>> model = Gemma4Inference() >>> model.run_inference([], 'Reply with OK.').text 'OK' """ if image_inputs is None: image_inputs = [] elif not isinstance(image_inputs, list): image_inputs = [image_inputs] content_list: list[dict[str, Any]] = [] for img_input in image_inputs: pil_img = self._load_image_as_pil(img_input) if pil_img: content_list.append({'type': 'image', 'image': pil_img}) else: logger.warning(f'Skipping failed image: {img_input}') # Abort only if images were requested but none loaded. if len(image_inputs) > 0 and not content_list: logger.error('No valid images loaded. Cannot proceed with inference.') return None content_list.append({'type': 'text', 'text': self._prompt_text(prompt, kwargs)}) messages = [{'role': 'user', 'content': content_list}] try: inputs = self.processor.apply_chat_template( messages, tokenize=True, add_generation_prompt=True, return_dict=True, return_tensors='pt', ).to(self.model.device) input_len = inputs['input_ids'].shape[-1] with torch.no_grad(): outputs = self.model.generate( **inputs, **self._generation_kwargs(kwargs) ) response_text = self.processor.decode( outputs[0][input_len:], skip_special_tokens=True ) return ModelOutput( text=response_text.strip(), raw_response={ 'generated_text': response_text, 'parsed_response': self._parse(response_text), }, ) except Exception as e: # noqa: BLE001 - surfaced as a failed asset logger.error(f'Inference failed for {self.model_id}: {e}') return None
[docs] def run_inference_batch( self, batch_image_inputs: list[list[str | Path]], batch_prompts: list[str], **kwargs: Any, ) -> list[ModelOutput | None]: """ Run batched multimodal inference on several prompt/image sets. Assets whose images all fail to load receive ``None`` at their index so the output aligns with the input. If the batched call itself raises, every index is ``None``. Args: batch_image_inputs: One list of image paths/URLs 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 Gemma4Inference >>> model = Gemma4Inference() >>> outs = model.run_inference_batch( ... [['a.jpg'], ['b.jpg']], ['Rate damage 0-5.'] * 2 ... ) >>> [o.text for o in outs] ['1', '4'] """ batch_messages: list[list[dict[str, Any]] | None] = [] for img_inputs, prompt in zip(batch_image_inputs, batch_prompts, strict=True): content_list: list[dict[str, Any]] = [] for img_input in img_inputs: pil_img = self._load_image_as_pil(img_input) if pil_img: content_list.append({'type': 'image', 'image': pil_img}) if not content_list: batch_messages.append(None) continue content_list.append( {'type': 'text', 'text': self._prompt_text(prompt, kwargs)} ) batch_messages.append([{'role': 'user', 'content': content_list}]) valid_indices = [i for i, msg in enumerate(batch_messages) if msg is not None] valid_messages = [batch_messages[i] for i in valid_indices] results: list[ModelOutput | None] = [None] * len(batch_messages) if not valid_messages: return results try: inputs = self.processor.apply_chat_template( valid_messages, tokenize=True, return_dict=True, return_tensors='pt', padding=True, add_generation_prompt=True, ).to(self.model.device) input_len = inputs['input_ids'].shape[-1] with torch.no_grad(): outputs = self.model.generate( **inputs, **self._generation_kwargs(kwargs) ) for valid_idx, single_output in zip(valid_indices, outputs, strict=True): response_text = self.processor.decode( single_output[input_len:], skip_special_tokens=True ) results[valid_idx] = ModelOutput( text=response_text.strip(), raw_response={ 'generated_text': response_text, 'parsed_response': self._parse(response_text), }, ) return results except Exception as e: # noqa: BLE001 - surfaced as failed assets logger.error(f'Batched inference failed: {e}') return [None] * len(batch_messages)