# 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
"""
Pipeline components that describe assets with vision-language models.
:class:`AssetAnalyzer` is the single analysis step: give it any model from
:mod:`rapidtools.models` (hosted API or local checkpoint) and a prompt, and it
sends each asset's images to the model, parses the reply into key/value
attributes (``<prefix>_<key>``) and returns the mutated collection. Hosted
models run in a thread pool with a shared rate-limit cooldown; local models
run sequentially or in batches to protect GPU memory.
The provider-specific classes (``GeminiAssetAnalyzer``, ``Gemma4AssetAnalyzer``,
...) remain as thin, deprecated wrappers that build the model for you.
Example:
>>> from rapidtools import Pipeline
>>> from rapidtools.models import load
>>> from rapidtools.processing import AssetAnalyzer
>>> analyzer = AssetAnalyzer(
... load('gemini', api_key='AIza...'), prompt='Rate the damage 0-5 as JSON.'
... )
>>> collection = Pipeline([analyzer]).run(collection)
>>> collection.get('bldg_01').attributes['gemini_damage']
'3'
"""
from __future__ import annotations
import json
import logging
import math
import re
import threading
import time
import warnings
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from tqdm import tqdm
from rapidtools.core import (
ImageAsset,
PhysicalAsset,
PhysicalAssetCollection,
is_cancelled,
raise_if_cancelled,
)
from rapidtools.models import (
ClaudeInference,
GeminiInference,
Gemma4Inference,
GenerationConfig,
HFVisionInference,
LlamaVisionInference,
MuseGlimmerInference,
MuseSparkInference,
OpenAIInference,
QwenInference,
QwenVisionInference,
)
from rapidtools.models.base import BaseInferenceModel, resolve_prompt_text
from rapidtools.models.catalogs import (
ANTHROPIC_DEFAULT_MODEL,
DEFAULT_OPEN_VLM,
GEMINI_DEFAULT_LITE_MODEL,
GEMMA4_DEFAULT_MODEL,
LLAMA_DEFAULT_MODEL,
MUSE_GLIMMER_DEFAULT_MODEL,
MUSE_SPARK_DEFAULT_MODEL,
OPENAI_DEFAULT_MODEL,
QWEN_DEFAULT_MODEL,
QWEN_VL_DEFAULT_MODEL,
)
from rapidtools.models.local_base import BaseLocalInferenceModel
from .step import Stage
logger = logging.getLogger(__name__)
# Analyzers default to the cheapest current Gemini tier because they may run
# over thousands of assets; pass ``model_id`` for a stronger model.
GEMINI_ANALYZER_DEFAULT_MODEL = GEMINI_DEFAULT_LITE_MODEL
def parse_structured_results(text: str) -> dict[str, Any]:
"""
Extract key-value pairs from a vision-language model response.
Attempts to parse a JSON block (wrapped in ```json ... ``` or bare). If no
JSON object is found, falls back to line-by-line ``Key: Value`` parsing,
normalising keys to ``snake_case``.
Args:
text: The raw text generated by the model.
Returns:
dict[str, Any]: The parsed attributes (empty if nothing matched).
Example:
>>> parse_structured_results('```json\\n{"level": 3}\\n```')
{'level': 3}
>>> parse_structured_results('CHS Level: 3\\nNote - fine')
{'chs_level': '3', 'note': 'fine'}
"""
results: dict[str, Any] = {}
json_match = re.search(r'```json\s*(.*?)\s*```', text, re.DOTALL)
json_text = json_match.group(1) if json_match else text
try:
parsed = json.loads(json_text)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
for line in text.split('\n'):
match = re.match(r'^([\w\s]+)[:\-]\s*(.*)$', line.strip())
if match:
key = match.group(1).strip().lower().replace(' ', '_')
results[key] = match.group(2).strip()
return results
[docs]
@dataclass
class RateLimitPolicy:
"""
Shared back-off applied by :class:`AssetAnalyzer` to hosted APIs.
When a request fails (rate limit, transient error, safety block), every
worker pauses for ``cooldown_seconds`` multiplied by the number of
consecutive failures; the first success resets the multiplier.
Assets whose request failed are retried in additional passes once the
first pass is complete (``max_asset_retries``), so a burst of 429s or
timeouts does not leave holes in the results.
Attributes:
cooldown_seconds: Base pause after a failure. ``0`` disables pausing.
max_asset_retries: Extra passes over the assets that failed.
``0`` disables retries.
Example:
>>> RateLimitPolicy(cooldown_seconds=10).wait_for(3)
30
>>> RateLimitPolicy(max_asset_retries=1).max_asset_retries
1
"""
cooldown_seconds: float = 30.0
max_asset_retries: int = 2
[docs]
def wait_for(self, consecutive_errors: int) -> float:
"""Return the pause (seconds) after ``consecutive_errors`` failures."""
return self.cooldown_seconds * consecutive_errors
[docs]
class AssetAnalyzer:
"""
Describe every asset in a collection with a vision-language model.
Args:
model (BaseInferenceModel):
Any rapidtools model wrapper (see :func:`rapidtools.models.load`).
prompt (str | Path):
Prompt text or path to a prompt file applied to every asset.
attribute_prefix (str | None):
Prefix of the attributes written to each asset. Defaults to the
provider's prefix (``'gemini'``, ``'qwen_vl'``, ...).
max_workers (int | None):
Concurrent requests for hosted models. Defaults to 5 for APIs and
1 for local models (local inference is never threaded).
batch_size (int):
Assets per batch when the model offers ``run_inference_batch``.
Defaults to 4.
max_images_per_asset (int | None):
Cap on the images sent per asset; ``None`` sends them all.
image_filter (Callable[[ImageAsset], bool] | None):
Optional predicate selecting which images to analyze.
cancel_event (threading.Event | None):
Cooperative cancellation flag checked between assets/batches.
rate_limit (RateLimitPolicy | None):
Back-off and retry policy for hosted models; ``None`` uses a
30-second cooldown with two retry passes for APIs and no cooldown
for local models.
generation (GenerationConfig | None):
Per-call generation options (``json_mode``, ``temperature``, ...)
forwarded to the model.
mode (str | None):
Force ``'api'`` (threaded) or ``'local'`` (sequential/batched)
execution; inferred from the model when ``None``.
Example:
>>> from rapidtools.models import GenerationConfig, load
>>> from rapidtools.processing import AssetAnalyzer
>>> analyzer = AssetAnalyzer(
... load('qwen_vl', model_id='Qwen/Qwen3.5-9B', load_in_4bit=True),
... prompt='Return JSON with a damage_level key.',
... batch_size=4,
... generation=GenerationConfig(json_mode=True, temperature=0.0),
... )
>>> collection = analyzer(collection)
>>> collection.get('bldg_01').attributes['qwen_vl_damage_level']
'moderate'
"""
stage = Stage.ANALYZE
#: Default attribute prefix when neither the model nor the caller gives one.
ATTRIBUTE_PREFIX: str | None = None
PROVIDER_NAME: str | None = None
def __init__(
self,
model: BaseInferenceModel,
prompt: str | Path = '',
attribute_prefix: str | None = None,
max_workers: int | None = None,
batch_size: int = 4,
max_images_per_asset: int | None = None,
image_filter: Callable[[ImageAsset], bool] | None = None,
cancel_event: threading.Event | None = None,
rate_limit: RateLimitPolicy | None = None,
generation: GenerationConfig | None = None,
mode: str | None = None,
) -> None:
"""Wrap ``model`` for use as the analysis step of a pipeline."""
self.model = model
self.prompt = resolve_prompt_text(prompt)
self.image_filter = image_filter
self.cancel_event = cancel_event
self.generation = generation
self.max_images_per_asset = max_images_per_asset
self.batch_size = max(1, batch_size)
info = getattr(model, 'INFO', None)
if mode is None:
if info is not None and info.kind in ('api', 'local'):
mode = info.kind
elif isinstance(model, BaseLocalInferenceModel):
mode = 'local'
else:
mode = 'api'
if mode not in ('api', 'local'):
raise ValueError(f"mode must be 'api' or 'local', got {mode!r}.")
self.mode = mode
prefix = attribute_prefix or type(self).ATTRIBUTE_PREFIX
if not prefix:
prefix = info.attribute_prefix if info is not None else 'vlm'
self.ATTRIBUTE_PREFIX = prefix or 'vlm'
self.PROVIDER_NAME = type(self).PROVIDER_NAME or (
info.label if info is not None else 'VLM'
)
if max_workers is None:
max_workers = 5 if self.mode == 'api' else 1
self.max_workers = max(1, max_workers)
if rate_limit is None:
rate_limit = RateLimitPolicy() if self.mode == 'api' else RateLimitPolicy(0)
self.rate_limit = rate_limit
self._lock = threading.Lock()
self._global_cooldown_until = 0.0
self._consecutive_error_count = 0
# IDs of assets the provider rejected outright (refusal, 4xx, safety
# block): they are reported as failed but neither pause the workers
# nor get another pass.
self._permanent_failures: set[str] = set()
self.model_id = getattr(model, 'model_id', None)
# ------------------------------------------------------------ properties
@property
def attribute_prefix(self) -> str:
"""Prefix of the attributes written to each asset."""
# ``__init__`` always stores a non-empty prefix; the class attribute
# is Optional only so subclasses may leave it unset.
return self.ATTRIBUTE_PREFIX or 'vlm'
@property
def cooldown_duration(self) -> float:
"""Base cooldown in seconds (from :attr:`rate_limit`)."""
return self.rate_limit.cooldown_seconds
# --------------------------------------------------------------- helpers
def _parse_structured_results(self, text: str) -> dict[str, Any]:
"""Extract key-value pairs from the model response."""
return parse_structured_results(text)
def _image_paths(self, asset: PhysicalAsset) -> list[Path]:
"""Return the downloaded image paths of an asset after filtering/capping."""
target_images = asset.image_assets
if self.image_filter is not None:
target_images = target_images.filter(self.image_filter)
paths = [img.path for img in target_images if img.is_downloaded]
if self.max_images_per_asset is not None:
paths = paths[: self.max_images_per_asset]
return paths
def _infer(self, image_paths: list[Path], prompt: str) -> Any:
"""Call the model, forwarding the generation config when one is set."""
if self.generation is not None:
return self.model.run_inference(
image_inputs=image_paths, prompt=prompt, config=self.generation
)
return self.model.run_inference(image_inputs=image_paths, prompt=prompt)
def _apply_result(self, asset: PhysicalAsset, img_paths: list, result: Any) -> bool:
"""Write a model response onto the asset; return ``True`` on success."""
if result is None or not result.text:
return False
structured_data = self._parse_structured_results(result.text)
prefix = self.ATTRIBUTE_PREFIX
if structured_data:
for key, val in structured_data.items():
asset.attributes[f'{prefix}_{key}'] = val
else:
asset.attributes[f'{prefix}_analysis_raw'] = result.text
asset.attributes['ai_model_used'] = getattr(self.model, 'model_id', None)
asset.attributes['images_analyzed_count'] = len(img_paths)
return True
# ------------------------------------------------------------- API mode
def _process_single_asset(self, asset: PhysicalAsset) -> bool:
"""
Analyze one asset, honouring the shared rate-limit cooldown.
Returns:
bool: ``True`` when attributes were written.
"""
if is_cancelled(self.cancel_event):
return False
with self._lock:
wait_time = self._global_cooldown_until - time.time()
if wait_time > 0:
logger.debug(
f'Asset {asset.id} delayed: global cooldown active for '
f'{int(wait_time)}s...'
)
time.sleep(wait_time)
image_paths = self._image_paths(asset)
if not image_paths:
return False
result = self._infer(image_paths, self.prompt)
if (
result is not None
and getattr(result, 'failed', False)
and not getattr(result, 'retryable', True)
):
# A rejection is not a rate limit: do not stall the other workers
# or re-send the same request later.
logger.error(
f'Asset {asset.id} was rejected by the provider ({result.error}); '
'it will not be retried.'
)
with self._lock:
self._permanent_failures.add(asset.id)
return False
if result is None or not result.text:
with self._lock:
self._consecutive_error_count += 1
dynamic_wait = self.rate_limit.wait_for(self._consecutive_error_count)
if dynamic_wait > 0:
self._global_cooldown_until = time.time() + dynamic_wait
logger.warning(
f'Request failed for asset {asset.id} (consecutive failures: '
f'{self._consecutive_error_count}); pausing all workers for '
f'{dynamic_wait:g}s. The asset will be retried later.'
)
return False
with self._lock:
if self._consecutive_error_count > 0:
logger.info('Successful response received. Resetting error multiplier.')
self._consecutive_error_count = 0
return self._apply_result(asset, image_paths, result)
def _run_pass(
self, assets: list[PhysicalAsset], description: str
) -> list[PhysicalAsset]:
"""Analyze ``assets`` concurrently and return the ones that failed."""
workers = min(self.max_workers, max(1, len(assets)))
failed: list[PhysicalAsset] = []
with ThreadPoolExecutor(max_workers=workers) as executor:
future_to_asset = {
executor.submit(self._process_single_asset, asset): asset
for asset in assets
}
for future in tqdm(
as_completed(future_to_asset), total=len(assets), desc=description
):
asset = future_to_asset[future]
if is_cancelled(self.cancel_event):
executor.shutdown(wait=False, cancel_futures=True)
break
try:
if not future.result() and asset.id not in self._permanent_failures:
failed.append(asset)
except Exception as e: # noqa: BLE001 - reported per asset
logger.error(f'Error on asset {asset.id}: {e}')
failed.append(asset)
return failed
def _run_api(self, assets: list[PhysicalAsset]) -> int:
"""
Analyze assets concurrently with retry passes; return the failures.
Assets without downloaded images are skipped up front. Assets whose
request failed transiently (rate limit, timeout, network error) are
retried in up to ``rate_limit.max_asset_retries`` further passes;
assets the provider rejected (refusal, safety block, 4xx) are counted
as failed without a retry.
"""
self._permanent_failures = set()
pending = [asset for asset in assets if self._image_paths(asset)]
skipped = len(assets) - len(pending)
if skipped:
logger.info(f'{skipped} assets have no downloaded images and are skipped.')
workers = min(self.max_workers, max(1, len(pending)))
logger.info(
f'{self.PROVIDER_NAME}: analyzing {len(pending)} assets with {workers} '
f'workers (base cooldown: {self.cooldown_duration:g}s, up to '
f'{self.rate_limit.max_asset_retries} retry passes).'
)
failed: list[PhysicalAsset] = []
for attempt in range(1 + max(0, self.rate_limit.max_asset_retries)):
if not pending or is_cancelled(self.cancel_event):
break
description = (
'Analyzing Assets' if attempt == 0 else f'Retry pass {attempt}'
)
if attempt > 0:
logger.info(
f'Retrying {len(pending)} assets that failed (pass {attempt} of '
f'{self.rate_limit.max_asset_retries})...'
)
failed = self._run_pass(pending, description)
pending = failed
if failed:
logger.error(
f'{len(failed)} assets still failed after all retries: '
f'{", ".join(a.id for a in failed[:10])}'
f'{"..." if len(failed) > 10 else ""}'
)
if self._permanent_failures:
logger.error(
f'{len(self._permanent_failures)} assets were rejected by the '
'provider and not retried.'
)
return len(failed) + len(self._permanent_failures) + skipped
# ----------------------------------------------------------- local mode
def _run_local(self, assets: list[PhysicalAsset]) -> int:
"""Analyze assets sequentially or in batches; return the failures."""
run_inference_batch = getattr(self.model, 'run_inference_batch', None)
batched = run_inference_batch is not None
batch_size = self.batch_size if batched else 1
total_batches = math.ceil(len(assets) / batch_size)
logger.info(
f'{self.PROVIDER_NAME}: analyzing {len(assets)} assets in batches of '
f'{batch_size}...'
)
failed = 0
for i in tqdm(
range(0, len(assets), batch_size),
total=total_batches,
desc='Batch Analysis',
):
raise_if_cancelled(self.cancel_event, f'{self.PROVIDER_NAME} analysis')
batch_assets = assets[i : i + batch_size]
batch_images = [self._image_paths(a) for a in batch_assets]
if run_inference_batch is not None:
kwargs = {'config': self.generation} if self.generation else {}
results = run_inference_batch(
batch_images, [self.prompt] * len(batch_assets), **kwargs
)
else:
results = [
self._infer(paths, self.prompt) if paths else None
for paths in batch_images
]
for asset, img_paths, result in zip(
batch_assets, batch_images, results, strict=True
):
if not self._apply_result(asset, img_paths, result):
failed += 1
self._release_gpu_memory()
return failed
@staticmethod
def _release_gpu_memory() -> None:
"""Clear the CUDA cache between batches when PyTorch is available."""
try:
import torch
except ImportError: # pragma: no cover - torch is a dependency
return
if torch.cuda.is_available():
torch.cuda.empty_cache()
# -------------------------------------------------------------- __call__
def __call__(
self, asset_collection: PhysicalAssetCollection
) -> PhysicalAssetCollection:
"""
Analyze every asset that has downloaded images.
Args:
asset_collection: The assets to describe.
Returns:
PhysicalAssetCollection: The same collection with new attributes.
Raises:
OperationCancelled: If ``cancel_event`` is set during the run.
"""
assets_with_images = [a for a in asset_collection if len(a.image_assets) > 0]
if not assets_with_images:
logger.warning('No assets with images found to analyze. Skipping.')
return asset_collection
if self.mode == 'api':
failed = self._run_api(assets_with_images)
else:
failed = self._run_local(assets_with_images)
raise_if_cancelled(self.cancel_event, f'{self.PROVIDER_NAME} analysis')
if failed > 0:
logger.error(f'{self.PROVIDER_NAME}: {failed} assets failed to process.')
else:
logger.info(f'{self.PROVIDER_NAME}: All assets processed successfully.')
return asset_collection
# ======================================================================
# Deprecated provider-specific wrappers (kept for backwards compatibility)
# ======================================================================
def _warn_deprecated(old: str, provider: str) -> None:
"""Emit the standard deprecation warning for a provider-specific analyzer."""
warnings.warn(
f'{old} is deprecated and will be removed in rapidtools 0.2; use '
f"AssetAnalyzer(rapidtools.models.load('{provider}', ...), prompt=...) "
'instead.',
DeprecationWarning,
stacklevel=3,
)
class BaseAPIAssetAnalyzer(AssetAnalyzer):
"""
Deprecated base for hosted-API analyzers; builds the model from ``MODEL_CLASS``.
Prefer :class:`AssetAnalyzer` with :func:`rapidtools.models.load`.
Example:
>>> analyzer = GeminiAssetAnalyzer(api_key='...', prompt='Rate it 0-5.')
>>> collection = analyzer(collection)
"""
MODEL_CLASS: type = GeminiInference
ATTRIBUTE_PREFIX = 'gemini'
PROVIDER_NAME = 'Gemini API'
DEFAULT_MODEL_ID: str | None = None
def __init__(
self,
api_key: str | Path | None = None,
prompt: str | Path = '',
model_id: str | None = None,
max_workers: int | None = 5,
max_retries: int | None = 3,
cooldown_duration: int | None = 30,
system_instruction: str | None = None,
temperature: float = 0.4,
max_tokens: int = 2048,
image_filter: Callable[[ImageAsset], bool] | None = None,
cancel_event: threading.Event | None = None,
):
"""
Build the provider model and wrap it in an :class:`AssetAnalyzer`.
Args:
api_key: Provider API key (or path to a file containing it).
prompt: Text prompt or path to a prompt file.
model_id: Provider model ID. Defaults to the provider's default.
max_workers: Concurrent threads. Defaults to 5.
max_retries: Retries per individual request. Defaults to 3.
cooldown_duration: Base seconds to wait on rate limit. Defaults to 30.
system_instruction: Optional model persona/constraints.
temperature: Randomness (0.0 to 2.0).
max_tokens: Maximum output length.
image_filter: Optional function to filter ImageAssets.
cancel_event: Optional ``threading.Event``; when set, the analyzer
stops submitting requests and raises ``OperationCancelled``.
"""
_warn_deprecated(type(self).__name__, type(self).ATTRIBUTE_PREFIX)
self.max_retries = max_retries
model_kwargs: dict[str, Any] = dict(
api_key=api_key,
max_workers=max_workers,
max_retries=max_retries,
system_instruction=system_instruction,
temperature=temperature,
max_tokens=max_tokens,
)
chosen = model_id or self.DEFAULT_MODEL_ID
if chosen:
model_kwargs['model_id'] = chosen
model = self.MODEL_CLASS(**model_kwargs)
super().__init__(
model,
prompt=prompt,
attribute_prefix=type(self).ATTRIBUTE_PREFIX,
max_workers=max_workers,
image_filter=image_filter,
cancel_event=cancel_event,
rate_limit=RateLimitPolicy(cooldown_duration or 0),
mode='api',
)
[docs]
class GeminiAssetAnalyzer(BaseAPIAssetAnalyzer):
"""Deprecated: use ``AssetAnalyzer(load('gemini', ...))``. Prefix ``gemini_``."""
MODEL_CLASS = GeminiInference
ATTRIBUTE_PREFIX = 'gemini'
PROVIDER_NAME = 'Gemini API'
DEFAULT_MODEL_ID = GEMINI_ANALYZER_DEFAULT_MODEL
[docs]
class ClaudeAssetAnalyzer(BaseAPIAssetAnalyzer):
"""Deprecated: use ``AssetAnalyzer(load('claude', ...))``. Prefix ``claude_``."""
MODEL_CLASS = ClaudeInference
ATTRIBUTE_PREFIX = 'claude'
PROVIDER_NAME = 'Claude API'
DEFAULT_MODEL_ID = ANTHROPIC_DEFAULT_MODEL
[docs]
class OpenAIAssetAnalyzer(BaseAPIAssetAnalyzer):
"""Deprecated: use ``AssetAnalyzer(load('openai', ...))``. Prefix ``openai_``."""
MODEL_CLASS = OpenAIInference
ATTRIBUTE_PREFIX = 'openai'
PROVIDER_NAME = 'OpenAI API'
DEFAULT_MODEL_ID = OPENAI_DEFAULT_MODEL
[docs]
class MuseSparkAssetAnalyzer(BaseAPIAssetAnalyzer):
"""Deprecated: use ``AssetAnalyzer(load('muse_spark', ...))``."""
MODEL_CLASS = MuseSparkInference
ATTRIBUTE_PREFIX = 'muse_spark'
PROVIDER_NAME = 'Meta Model API'
DEFAULT_MODEL_ID = MUSE_SPARK_DEFAULT_MODEL
[docs]
class QwenAssetAnalyzer(BaseAPIAssetAnalyzer):
"""Deprecated: use ``AssetAnalyzer(load('qwen', ...))``. Prefix ``qwen_``."""
MODEL_CLASS = QwenInference
ATTRIBUTE_PREFIX = 'qwen'
PROVIDER_NAME = 'Qwen API'
DEFAULT_MODEL_ID = QWEN_DEFAULT_MODEL
class BaseLocalAssetAnalyzer(AssetAnalyzer):
"""
Compatibility alias for :class:`AssetAnalyzer` in local (sequential) mode.
Example:
>>> analyzer = BaseLocalAssetAnalyzer(Gemma4Inference(), prompt='Rate it.')
>>> collection = analyzer(collection)
"""
ATTRIBUTE_PREFIX = 'vlm'
PROVIDER_NAME = 'Local VLM'
def __init__(
self,
model: Any,
prompt: str | Path = '',
max_images_per_asset: int = 2,
batch_size: int = 4,
image_filter: Callable[[ImageAsset], bool] | None = None,
cancel_event: threading.Event | None = None,
attribute_prefix: str | None = None,
) -> None:
"""
Wrap an already-loaded local model.
Args:
model: A local inference model exposing ``run_inference`` (and
optionally ``run_inference_batch``) plus a ``model_id``.
prompt: Text instruction or path to a prompt file.
max_images_per_asset: Safeguard limit on images passed per asset.
batch_size: Assets processed simultaneously when the model
supports batching.
image_filter: Optional function to filter ``ImageAsset`` objects.
cancel_event: Optional ``threading.Event`` checked between batches.
attribute_prefix: Overrides the class-level ``ATTRIBUTE_PREFIX``.
"""
super().__init__(
model,
prompt=prompt,
attribute_prefix=attribute_prefix or type(self).ATTRIBUTE_PREFIX,
batch_size=batch_size,
max_images_per_asset=max_images_per_asset,
image_filter=image_filter,
cancel_event=cancel_event,
mode='local',
)
[docs]
class Gemma4AssetAnalyzer(BaseLocalAssetAnalyzer):
"""Deprecated: use ``AssetAnalyzer(load('gemma4', ...))``. Prefix ``gemma4_``."""
ATTRIBUTE_PREFIX = 'gemma4'
PROVIDER_NAME = 'Gemma-4'
def __init__(
self,
prompt: str | Path = '',
model_id: str = GEMMA4_DEFAULT_MODEL,
device: str = 'auto',
temperature: float = 0.4,
max_tokens: int = 512,
max_images_per_asset: int = 2,
batch_size: int = 4,
image_filter: Callable[[ImageAsset], bool] | None = None,
cancel_event: threading.Event | None = None,
) -> None:
"""Build a :class:`~rapidtools.models.Gemma4Inference` and wrap it."""
_warn_deprecated('Gemma4AssetAnalyzer', 'gemma4')
model = Gemma4Inference(
model_id=model_id,
device=device,
temperature=temperature,
max_tokens=max_tokens,
)
super().__init__(
model,
prompt=prompt,
max_images_per_asset=max_images_per_asset,
batch_size=batch_size,
image_filter=image_filter,
cancel_event=cancel_event,
)
[docs]
class LlamaVisionAssetAnalyzer(BaseLocalAssetAnalyzer):
"""Deprecated: use ``AssetAnalyzer(load('llama', ...))``. Prefix ``llama_``."""
ATTRIBUTE_PREFIX = 'llama'
PROVIDER_NAME = 'Llama Vision'
def __init__(
self,
prompt: str | Path = '',
model_id: str = LLAMA_DEFAULT_MODEL,
device: str = 'auto',
load_in_4bit: bool = True,
temperature: float = 0.4,
max_tokens: int = 512,
max_images_per_asset: int = 2,
image_filter: Callable[[ImageAsset], bool] | None = None,
cancel_event: threading.Event | None = None,
) -> None:
"""Build a :class:`~rapidtools.models.LlamaVisionInference` and wrap it."""
_warn_deprecated('LlamaVisionAssetAnalyzer', 'llama')
model = LlamaVisionInference(
model_id=model_id,
device=device,
load_in_4bit=load_in_4bit,
temperature=temperature,
max_tokens=max_tokens,
)
super().__init__(
model,
prompt=prompt,
max_images_per_asset=max_images_per_asset,
batch_size=1,
image_filter=image_filter,
cancel_event=cancel_event,
)
[docs]
class HFVisionAssetAnalyzer(BaseLocalAssetAnalyzer):
"""
Deprecated: use ``AssetAnalyzer(load('hf', ...))``. Prefix ``vlm_``.
Subclasses swap ``MODEL_CLASS`` for a family-specific wrapper.
"""
MODEL_CLASS: type[HFVisionInference] = HFVisionInference
ATTRIBUTE_PREFIX = 'vlm'
PROVIDER_NAME = 'Hugging Face VLM'
def __init__(
self,
prompt: str | Path = '',
model_id: str | None = DEFAULT_OPEN_VLM,
device: str = 'auto',
load_in_4bit: bool = False,
temperature: float = 0.4,
max_tokens: int = 512,
max_images_per_asset: int = 2,
batch_size: int = 1,
image_filter: Callable[[ImageAsset], bool] | None = None,
cancel_event: threading.Event | None = None,
attribute_prefix: str | None = None,
trust_remote_code: bool = False,
) -> None:
"""Build ``MODEL_CLASS`` and wrap it."""
info = getattr(self.MODEL_CLASS, 'INFO', None)
_warn_deprecated(type(self).__name__, info.key if info else 'hf')
model = self.MODEL_CLASS(
model_id=model_id or self.MODEL_CLASS.DEFAULT_MODEL,
device=device,
load_in_4bit=load_in_4bit,
temperature=temperature,
max_tokens=max_tokens,
trust_remote_code=trust_remote_code,
)
super().__init__(
model,
prompt=prompt,
max_images_per_asset=max_images_per_asset,
batch_size=batch_size,
image_filter=image_filter,
cancel_event=cancel_event,
attribute_prefix=attribute_prefix,
)
[docs]
class MuseGlimmerAssetAnalyzer(HFVisionAssetAnalyzer):
"""Deprecated: use ``AssetAnalyzer(load('muse_glimmer', ...))``."""
MODEL_CLASS = MuseGlimmerInference
ATTRIBUTE_PREFIX = 'muse_glimmer'
PROVIDER_NAME = 'Muse Glimmer'
def __init__(
self,
prompt: str | Path = '',
model_id: str = MUSE_GLIMMER_DEFAULT_MODEL,
device: str = 'auto',
load_in_4bit: bool = True,
temperature: float = 0.4,
max_tokens: int = 512,
max_images_per_asset: int = 2,
batch_size: int = 1,
image_filter: Callable[[ImageAsset], bool] | None = None,
cancel_event: threading.Event | None = None,
attribute_prefix: str | None = None,
) -> None:
"""Build a :class:`~rapidtools.models.MuseGlimmerInference` and wrap it."""
super().__init__(
prompt=prompt,
model_id=model_id,
device=device,
load_in_4bit=load_in_4bit,
temperature=temperature,
max_tokens=max_tokens,
max_images_per_asset=max_images_per_asset,
batch_size=batch_size,
image_filter=image_filter,
cancel_event=cancel_event,
attribute_prefix=attribute_prefix,
)
[docs]
class QwenVisionAssetAnalyzer(HFVisionAssetAnalyzer):
"""Deprecated: use ``AssetAnalyzer(load('qwen_vl', ...))``. Prefix ``qwen_vl_``."""
MODEL_CLASS = QwenVisionInference
ATTRIBUTE_PREFIX = 'qwen_vl'
PROVIDER_NAME = 'Qwen (local)'
def __init__(
self,
prompt: str | Path = '',
model_id: str = QWEN_VL_DEFAULT_MODEL,
device: str = 'auto',
load_in_4bit: bool = False,
temperature: float = 0.4,
max_tokens: int = 512,
max_images_per_asset: int = 2,
batch_size: int = 1,
image_filter: Callable[[ImageAsset], bool] | None = None,
cancel_event: threading.Event | None = None,
attribute_prefix: str | None = None,
) -> None:
"""Build a :class:`~rapidtools.models.QwenVisionInference` and wrap it."""
super().__init__(
prompt=prompt,
model_id=model_id,
device=device,
load_in_4bit=load_in_4bit,
temperature=temperature,
max_tokens=max_tokens,
max_images_per_asset=max_images_per_asset,
batch_size=batch_size,
image_filter=image_filter,
cancel_event=cancel_event,
attribute_prefix=attribute_prefix,
)