"""
MLRS: InternVL3.5 (LoRA) reasons about the image and emits a segmentation tool call
...{"noun phrase": "...", "objects": [{"bbox": [x1, y1, x2, y2]}]}
(boxes on a 0-999 grid). For every parsed noun phrase, SAM3 (finetuned mask decoder)
is prompted with the phrase and its boxes and returns one semantic mask, restricted
to the predicted boxes. Non-segmentation tasks are answered with `...`
and produce no masks.
"""
from __future__ import annotations
import json
import logging
import os
import re
from dataclasses import dataclass, field
from math import isfinite
from typing import Any, Dict, List, Optional, Sequence, Tuple
import torch
import torch.nn.functional as F
from huggingface_hub import snapshot_download
from peft import PeftModel
from safetensors.torch import load_file
from transformers import (
AutoModelForImageTextToText,
AutoTokenizer,
PreTrainedModel,
Sam3Model,
Sam3Processor,
)
from .configuration_mlrs import MLRSConfig
logger = logging.getLogger(__name__)
_HUB_KWARGS = ("cache_dir", "force_download", "local_files_only", "proxies", "revision", "token", "subfolder")
_BASE_HUB_KWARGS = ("cache_dir", "force_download", "local_files_only", "proxies", "token")
_NON_MODEL_INPUT_KEYS = ("sam_inputs", "sam_original_sizes", "image_original_sizes")
# ---------------------------------------------------------------------------
# Tool-call parsing
# ---------------------------------------------------------------------------
def _is_number_like(v) -> bool:
try:
return isfinite(float(v))
except Exception:
return False
def _is_valid_box(b) -> bool:
return isinstance(b, (list, tuple)) and len(b) == 4 and all(_is_number_like(x) for x in b)
def _infer_grid_from_values(values) -> Optional[int]:
if not values:
return 999
vmax = 0.0
for v in values:
try:
fv = float(v)
if isfinite(fv):
vmax = max(vmax, abs(fv))
except Exception:
continue
if vmax <= 1.2:
return 1
if vmax <= 1000.5:
return 999
return None
def _scale_coord_to_pixel(val, grid, size) -> int:
if grid is None:
vv = round(float(val))
elif grid == 1:
vv = round(float(val) * (size - 1))
else:
vv = round(float(val) * (size - 1) / 999.0)
return max(0, min(size - 1, int(vv)))
def _boxes_to_pixels(raw_bboxes, image_size_wh, grid) -> List[List[int]]:
w, h = image_size_wh
out = []
for b in raw_bboxes:
if not _is_valid_box(b):
continue
x1 = _scale_coord_to_pixel(b[0], grid, w)
y1 = _scale_coord_to_pixel(b[1], grid, h)
x2 = _scale_coord_to_pixel(b[2], grid, w)
y2 = _scale_coord_to_pixel(b[3], grid, h)
out.append([min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)])
return out
def _normalize_label(label) -> str:
if label is None:
return ""
if isinstance(label, str):
return label.strip()
if isinstance(label, (list, tuple)):
return ", ".join(s for s in (str(x).strip() for x in label if x is not None) if s)
return str(label).strip()
def _segmentation_to_answer_tags(output_text: str) -> str:
if output_text is None:
return ""
m = re.fullmatch(
r"^\s*(?:\s*(.*?)\s*\s*)?\s*(.*?)\s*\s*$",
output_text,
flags=re.DOTALL,
)
if not m:
return output_text
think = m.group(1) or ""
body = (m.group(2) or "").strip()
if body == "":
return output_text
if think.strip():
return f"{think}{body}"
return f"{body}"
def _segmentation_body_to_dicts(raw: Optional[str]) -> List[Dict[str, Any]]:
"""Parse one dict, a JSON array of dicts, or comma-joined dicts."""
if raw is None:
return []
raw = raw.strip()
if raw == "":
return []
def _strip_quotes_in_box_array(match):
return match.group(1) + match.group(2).replace('"', "") + match.group(3)
def _try_load(s):
try:
return json.loads(s)
except json.JSONDecodeError:
fixed = re.sub(r'("objects"\s*:\s*\[)(.*?)(\])', _strip_quotes_in_box_array, s, flags=re.DOTALL)
try:
return json.loads(fixed)
except json.JSONDecodeError:
return None
data = _try_load(raw)
if data is None:
data = _try_load("[" + raw + "]")
if isinstance(data, dict):
return [data]
if isinstance(data, list):
return [d for d in data if isinstance(d, dict)]
return []
def _dict_to_raw_bboxes(d: Dict[str, Any]) -> List[List[float]]:
objects = d.get("objects", [])
if not isinstance(objects, list):
return []
raw_bboxes = []
for obj in objects:
if _is_valid_box(obj):
raw_bboxes.append(list(obj))
elif isinstance(obj, dict) and _is_valid_box(obj.get("bbox")):
raw_bboxes.append(list(obj["bbox"]))
return raw_bboxes
def parse_segmentation_call(output_text: str, image_size_wh: Tuple[int, int]) -> List[Tuple[str, List[List[int]]]]:
"""
Parse `[..]{...}` into
`[(noun_phrase, [[x1, y1, x2, y2], ...]), ...]` with pixel boxes.
Returns [] when the output is not a (valid) segmentation call.
"""
converted = _segmentation_to_answer_tags(output_text)
m = re.fullmatch(
r"^\s*(?:\s*(.*?)\s*\s*)?\s*(.*?)\s*\s*$",
converted,
re.DOTALL,
)
if not m:
return []
dicts = _segmentation_body_to_dicts((m.group(2) or "").strip())
if not dicts:
return []
# One coordinate grid for all phrases so scaling is consistent.
per_dict_boxes = [_dict_to_raw_bboxes(d) for d in dicts]
all_vals = [v for boxes in per_dict_boxes for b in boxes for v in b[:4]]
grid = _infer_grid_from_values(all_vals)
phrases = []
for d, raw_bboxes in zip(dicts, per_dict_boxes):
label = _normalize_label(d.get("noun phrase", d.get("noun_phrase", "")))
if not label.strip() or not raw_bboxes:
continue
phrase_grid = grid if grid is not None else _infer_grid_from_values([v for b in raw_bboxes for v in b[:4]])
boxes = _boxes_to_pixels(raw_bboxes, image_size_wh, phrase_grid)
if boxes:
phrases.append((label, boxes))
return phrases
# ---------------------------------------------------------------------------
# Model
# ---------------------------------------------------------------------------
@dataclass
class MLRSOutput:
"""
text: generated text per sample.
masks: per sample, bool tensor [K, H, W] (one mask per noun phrase) at
`sam_original_sizes` resolution. K == 0 for non-segmentation answers.
phrases: per sample, the K noun phrases sent to SAM3.
boxes: per sample, per phrase, the pixel boxes [x1, y1, x2, y2] sent to SAM3.
"""
text: List[str]
masks: List[torch.Tensor]
phrases: List[List[str]] = field(default_factory=list)
boxes: List[List[List[List[int]]]] = field(default_factory=list)
def _param_device_dtype(module: torch.nn.Module) -> Tuple[torch.device, torch.dtype]:
device = torch.device("cpu")
dtype = torch.float32
for i, p in enumerate(module.parameters()):
if i == 0:
device = p.device
if p.is_floating_point():
return device, p.dtype
return device, dtype
def _to_device(batch: Dict[str, Any], device: torch.device, dtype: torch.dtype) -> Dict[str, Any]:
out = {}
for k, v in batch.items():
if torch.is_tensor(v):
out[k] = v.to(device=device, dtype=dtype) if v.is_floating_point() else v.to(device)
else:
out[k] = v
return out
class MLRS(PreTrainedModel):
config_class = MLRSConfig
base_model_prefix = "mlrs"
def __init__(
self,
config: MLRSConfig,
language_model: Optional[torch.nn.Module] = None,
sam_model: Optional[torch.nn.Module] = None,
tokenizer=None,
sam_processor=None,
):
super().__init__(config)
if language_model is None or sam_model is None or tokenizer is None or sam_processor is None:
raise ValueError("MLRS must be created with MLRS.from_pretrained(...)")
self.language_model = language_model
self.sam_model = sam_model
self.tokenizer = tokenizer
self.sam_processor = sam_processor
# -- loading ------------------------------------------------------------
@classmethod
def from_pretrained(
cls,
pretrained_model_name_or_path,
*model_args,
config: Optional[MLRSConfig] = None,
dtype=None,
torch_dtype=None,
device_map=None,
merge_lora: bool = False,
**kwargs,
) -> "MLRS":
"""
Loads the InternVL3.5 and SAM3 base models at the revisions pinned in the
config, attaches the LoRA adapter and overwrites the SAM3 mask decoder.
merge_lora: merge the adapter into the base weights (faster generation,
tiny numerical differences vs. the reported results).
"""
hub_kwargs = {k: kwargs.pop(k) for k in _HUB_KWARGS if k in kwargs}
kwargs.pop("trust_remote_code", None)
unused = [k for k in kwargs if not k.startswith("_")]
if unused:
logger.warning("MLRS.from_pretrained ignores arguments: %s", unused)
base_hub_kwargs = {k: hub_kwargs[k] for k in _BASE_HUB_KWARGS if k in hub_kwargs}
dtype = dtype if dtype is not None else torch_dtype
if dtype is None or dtype == "auto":
dtype = torch.bfloat16
elif isinstance(dtype, str):
dtype = getattr(torch, dtype)
if config is None:
config = MLRSConfig.from_pretrained(pretrained_model_name_or_path, **hub_kwargs)
path = str(pretrained_model_name_or_path)
if os.path.isdir(path):
local_dir = path
else:
local_dir = snapshot_download(
repo_id=path,
allow_patterns=[f"{config.language_adapter_subdir}/*", config.sam_delta_file, "*.json"],
**{k: v for k, v in hub_kwargs.items() if k != "subfolder"},
)
# Language side: base InternVL3.5 + LoRA adapter.
# The adapter is kept in its saved precision (autocast_adapter_dtype=True,
# PEFT's default), matching the setup used for the reported results.
language_base = AutoModelForImageTextToText.from_pretrained(
config.language_base_model,
revision=config.language_base_revision,
dtype=dtype,
device_map=device_map,
**base_hub_kwargs,
)
language_model = PeftModel.from_pretrained(
language_base,
os.path.join(local_dir, config.language_adapter_subdir),
is_trainable=False,
)
if merge_lora:
language_model = language_model.merge_and_unload()
# SAM side: base SAM3 + finetuned weights (mask decoder).
sam_model = Sam3Model.from_pretrained(
config.sam_base_model,
revision=config.sam_base_revision,
dtype=dtype,
device_map=device_map,
**base_hub_kwargs,
)
sam_delta = load_file(os.path.join(local_dir, config.sam_delta_file))
incompatible = sam_model.load_state_dict(sam_delta, strict=False)
if incompatible.unexpected_keys:
raise RuntimeError(f"SAM3 delta has keys not present in {config.sam_base_model}: {incompatible.unexpected_keys}")
tokenizer = AutoTokenizer.from_pretrained(
config.language_base_model,
revision=config.language_base_revision,
**base_hub_kwargs,
)
sam_processor = Sam3Processor.from_pretrained(
config.sam_base_model,
revision=config.sam_base_revision,
**base_hub_kwargs,
)
model = cls(
config,
language_model=language_model,
sam_model=sam_model,
tokenizer=tokenizer,
sam_processor=sam_processor,
)
return model.eval()
def get_input_embeddings(self):
return self.language_model.get_input_embeddings()
def forward(self, *args, **kwargs):
"""Language-model forward pass (logits). Use `generate` for text + masks."""
return self.language_model(*args, **kwargs)
# -- inference ------------------------------------------------------------
def _special_token_ids(self, generation_kwargs: Dict[str, Any]) -> Tuple[Optional[int], Optional[int]]:
eos = generation_kwargs.get("eos_token_id")
pad = generation_kwargs.get("pad_token_id")
for cfg in (getattr(self.language_model, "generation_config", None), getattr(self.language_model, "config", None)):
if cfg is None:
continue
if eos is None:
eos = getattr(cfg, "eos_token_id", None)
if pad is None:
pad = getattr(cfg, "pad_token_id", None)
if isinstance(eos, (list, tuple)):
eos = eos[0] if eos else None
return eos, pad
@staticmethod
def _generated_mask(tokens: torch.Tensor, eos: Optional[int], pad: Optional[int]) -> torch.Tensor:
"""Valid generated tokens: up to and including the first EOS."""
mask = torch.zeros_like(tokens, dtype=torch.bool)
for i, row in enumerate(tokens):
if eos is not None:
eos_pos = (row == eos).nonzero(as_tuple=False)
if eos_pos.numel() > 0:
mask[i, : int(eos_pos[0].item()) + 1] = True
continue
mask[i] = row != pad if pad is not None else True
return mask
def _is_valid_sam_text(self, text: str) -> bool:
if not text.strip():
return False
max_len = self.sam_model.config.text_config.max_position_embeddings
n_tokens = self.sam_processor.tokenizer(
text, truncation=False, add_special_tokens=True, return_tensors="pt"
)["input_ids"].shape[1]
return int(n_tokens) <= int(max_len)
def _segment(
self,
sam_inputs: Dict[str, Any],
row: int,
size_hw: Tuple[int, int],
phrases: List[Tuple[str, List[List[int]]]],
) -> torch.Tensor:
"""Run SAM3 once per noun phrase of sample `row`. Returns bool [K, H, W]."""
device, dtype = _param_device_dtype(self.sam_model)
h, w = size_hw
k = len(phrases)
labels = [p[0] for p in phrases]
boxes = [p[1] for p in phrases]
base = {}
for key, v in sam_inputs.items():
if torch.is_tensor(v):
v = v.index_select(0, torch.tensor([row], device=v.device))
v = v.expand(k, *v.shape[1:]).contiguous()
base[key] = v
prompt_inputs = self.sam_processor(
original_sizes=torch.tensor([[h, w]] * k, dtype=torch.long),
text=labels,
input_boxes=boxes,
input_boxes_labels=[[1] * len(bb) for bb in boxes],
return_tensors="pt",
)
base.update(_to_device(dict(prompt_inputs), device, dtype))
logits = self.sam_model(**base).semantic_seg
if logits is None or logits.ndim != 4 or logits.shape[1] != 1:
raise RuntimeError("SAM3 did not return semantic_seg logits of shape [K, 1, Hm, Wm]")
logits = F.interpolate(logits.float(), size=(h, w), mode="bilinear", align_corners=False)
if self.config.bbox_filtering:
inside = torch.zeros_like(logits)
for i, phrase_boxes in enumerate(boxes):
for x1, y1, x2, y2 in phrase_boxes:
inside[i, :, y1:y2, x1:x2] = 1
logits = logits * inside - 1000 * (1 - inside)
return (torch.sigmoid(logits) > self.config.mask_threshold).reshape(k, h, w)
@torch.no_grad()
def generate(
self,
input_ids: torch.Tensor,
sam_inputs: Dict[str, Any],
sam_original_sizes: torch.Tensor,
attention_mask: Optional[torch.Tensor] = None,
image_original_sizes: Optional[torch.Tensor] = None,
**kwargs,
) -> MLRSOutput:
"""
Args:
input_ids, attention_mask, pixel_values, sam_inputs, sam_original_sizes:
the output of `MLRSProcessor`.
**kwargs: other tensors are forwarded to the language model; everything
else is treated as a generation argument (max_new_tokens, do_sample, ...).
"""
lm_inputs: Dict[str, Any] = {"input_ids": input_ids}
if attention_mask is not None:
lm_inputs["attention_mask"] = attention_mask
generation_kwargs: Dict[str, Any] = {}
for key, value in kwargs.items():
if key in _NON_MODEL_INPUT_KEYS:
continue
(lm_inputs if torch.is_tensor(value) else generation_kwargs)[key] = value
generation_kwargs.setdefault("max_new_tokens", self.config.max_new_tokens)
generation_kwargs.setdefault("do_sample", False)
generation_kwargs.setdefault("repetition_penalty", 1.0)
generation_kwargs["num_return_sequences"] = 1
# 1) Language model.
lm_device, lm_dtype = _param_device_dtype(self.language_model)
lm_inputs = _to_device(lm_inputs, lm_device, lm_dtype)
sequences = self.language_model.generate(**lm_inputs, **generation_kwargs)
if not torch.is_tensor(sequences):
sequences = sequences.sequences
generated = sequences[:, lm_inputs["input_ids"].shape[1]:]
eos, pad = self._special_token_ids(generation_kwargs)
valid = self._generated_mask(generated, eos, pad)
texts = [
self.tokenizer.decode(ids[m], skip_special_tokens=True)
for ids, m in zip(generated, valid)
]
# 2) Segmentation tool.
sam_device, sam_dtype = _param_device_dtype(self.sam_model)
sam_inputs = _to_device(dict(sam_inputs), sam_device, sam_dtype)
sizes = sam_original_sizes.to(torch.long).tolist()
all_masks, all_phrases, all_boxes = [], [], []
for row, text in enumerate(texts):
h, w = sizes[row]
phrases = [
(label.lstrip(), boxes)
for label, boxes in parse_segmentation_call(text, image_size_wh=(w, h))
if self._is_valid_sam_text(label.lstrip())
]
if phrases:
masks = self._segment(sam_inputs, row, (h, w), phrases)
else:
masks = torch.zeros((0, h, w), dtype=torch.bool, device=sam_device)
all_masks.append(masks)
all_phrases.append([p[0] for p in phrases])
all_boxes.append([p[1] for p in phrases])
return MLRSOutput(text=texts, masks=all_masks, phrases=all_phrases, boxes=all_boxes)