openworld-sam / modeling_openworld_sam.py
neerajaabhyankar's picture
feat: configurable mask_decoder_chunk_size (#2)
d065dbb
Raw
History Blame Contribute Delete
19.5 kB
"""OpenWorldSAM: self-contained HuggingFace model (no detectron2).
Original paper: "Extending SAM2 for Universal Image Segmentation with Language Prompts"
(Xiao et al., NeurIPS 2025 Spotlight). Original code: GinnyXiao/OpenWorldSAM (Apache-2.0).
Architecture:
evf_sam2 — EvfSam2Model (SAM2 Hiera-Large + BEiT-3 multimodal encoder)
text_hidden_fcs — 3-layer projection MLP: BEiT-3 hidden_dim → query_dim
positional_tokens — learnable positional embeddings [num_tokens, query_dim]
cross_attention_transformer — 3-layer cross-attention stack
"""
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision
from transformers import PreTrainedModel, AutoTokenizer
# Absolute imports: the repo root must be on sys.path (handled by the FiftyOne
# loader or by HF trust_remote_code loading from a local snapshot directory).
from configuration_openworld_sam import OpenWorldSAMConfig # noqa: E402
# Trigger Hydra config registration before any SAM2 imports
from model import evf_sam2 as _evf_module # noqa: F401, E402
from model.evf_sam2 import EvfSam2Model # noqa: E402
# ---------------------------------------------------------------------------
# Cross-attention transformer (inlined from model/open_world_sam2.py)
# ---------------------------------------------------------------------------
class _CrossAttentionLayer(nn.Module):
def __init__(self, embedding_dim, num_heads, mlp_dim, dropout=0.1):
super().__init__()
self.self_attn_norm = nn.LayerNorm(embedding_dim)
self.self_attn = nn.MultiheadAttention(embedding_dim, num_heads, dropout=dropout, batch_first=True)
self.self_attn_dropout = nn.Dropout(dropout)
self.cross_attn_norm = nn.LayerNorm(embedding_dim)
self.cross_attn = nn.MultiheadAttention(embedding_dim, num_heads, dropout=dropout, batch_first=True)
self.cross_attn_dropout = nn.Dropout(dropout)
self.mlp_norm = nn.LayerNorm(embedding_dim)
self.mlp = nn.Sequential(
nn.Linear(embedding_dim, mlp_dim),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(mlp_dim, embedding_dim),
nn.Dropout(dropout),
)
def forward(self, vlm_features, image_embeddings):
# Self-attention
r = vlm_features
x = self.self_attn_norm(vlm_features)
x, _ = self.self_attn(x, x, x)
x = r + self.self_attn_dropout(x)
# Cross-attention
r = x
x = self.cross_attn_norm(x)
x, _ = self.cross_attn(query=x, key=image_embeddings, value=image_embeddings)
x = r + self.cross_attn_dropout(x)
# MLP
r = x
x = self.mlp_norm(x)
x = self.mlp(x)
return r + x
class _CrossAttentionTransformer(nn.Module):
def __init__(self, embedding_dim, num_heads, mlp_dim, num_layers=3, dropout=0.1):
super().__init__()
self.layers = nn.ModuleList([
_CrossAttentionLayer(embedding_dim, num_heads, mlp_dim, dropout)
for _ in range(num_layers)
])
def forward(self, vlm_features, image_embeddings):
x = vlm_features
for layer in self.layers:
x = layer(x, image_embeddings)
return x
# ---------------------------------------------------------------------------
# Pure-torch helpers (replacing detectron2 structures)
# ---------------------------------------------------------------------------
def _masks_to_boxes(masks):
"""bool tensor [N, H, W] → float tensor [N, 4] xyxy bounding boxes."""
n = masks.shape[0]
boxes = torch.zeros((n, 4), dtype=torch.float32, device=masks.device)
for i in range(n):
m = masks[i]
rows = m.any(dim=1).nonzero(as_tuple=False)
cols = m.any(dim=0).nonzero(as_tuple=False)
if rows.numel() > 0 and cols.numel() > 0:
boxes[i] = torch.tensor(
[cols[0].item(), rows[0].item(), cols[-1].item() + 1, rows[-1].item() + 1],
dtype=torch.float32,
device=masks.device,
)
return boxes
# ---------------------------------------------------------------------------
# Main PreTrainedModel
# ---------------------------------------------------------------------------
class OpenWorldSAMModel(PreTrainedModel):
"""OpenWorldSAM zero-shot segmentation model (HuggingFace trust_remote_code).
Usage::
from transformers import AutoModel
model = AutoModel.from_pretrained(
"neerajaabhyankar/openworld-sam", trust_remote_code=True
)
# batched_inputs: list of dicts with keys:
# "image" — float32 SAM-normalised tensor [3, 1024, 1024]
# "evf_image" — float32 BEiT-3 tensor [3, 224, 224]
# "height", "width" — original image dimensions (int)
# "prompt" — list[str] of text prompts
# "unique_categories" — list[int] of category ids (one per prompt)
outputs = model(batched_inputs)
# outputs: list of dicts, one per image; key "instances" holds masks/scores/class_ids
"""
config_class = OpenWorldSAMConfig
def __init__(self, config: OpenWorldSAMConfig):
super().__init__(config)
# EVF-SAM2 backbone (SAM2 Hiera-L + BEiT-3)
from model.configuration_evf import EvfConfig
evf_cfg = EvfConfig(
hidden_size=1024,
sam_scale=config.sam_scale,
mm_extractor_scale=config.mm_extractor_scale,
)
self.evf_sam2 = EvfSam2Model(evf_cfg)
# Projection MLP: BEiT-3 hidden (1024) → query_dim (256)
in_dim = 1024 # BEiT-3 large hidden size
qd = config.query_dim
self.text_hidden_fcs = nn.ModuleList([
nn.Sequential(nn.Linear(in_dim, in_dim), nn.ReLU(), nn.Linear(in_dim, qd))
])
# Learnable positional tokens [num_tokens, query_dim]
self.positional_tokens = nn.Parameter(torch.randn(config.num_tokens, qd))
# Cross-attention transformer
self.cross_attention_transformer = _CrossAttentionTransformer(
embedding_dim=qd,
num_heads=8,
mlp_dim=qd * 4,
num_layers=config.cross_attention_layers,
)
# SAM2 feature size schedule (matches Hiera-L)
self._bb_feat_sizes = [(256, 256), (128, 128), (64, 64)]
# Preprocessing buffers
self.register_buffer(
"pixel_mean",
torch.tensor(config.pixel_mean).view(-1, 1, 1),
persistent=False,
)
self.register_buffer(
"pixel_std",
torch.tensor(config.pixel_std).view(-1, 1, 1),
persistent=False,
)
# Tokenizer loaded lazily on first forward
self._tokenizer = None
# Required by transformers>=5's from_pretrained (sets
# self.all_tied_weights_keys and other bookkeeping consumed by
# _finalize_model_loading); harmless no-op pre-checkpoint-load init.
self.post_init()
# ------------------------------------------------------------------
# Tokenizer
# ------------------------------------------------------------------
@property
def tokenizer(self):
if self._tokenizer is None:
self._tokenizer = AutoTokenizer.from_pretrained(
self.config.tokenizer_name_or_path,
padding_side="right",
use_fast=False,
)
return self._tokenizer
def _tokenize_prompts(self, prompts):
tok = self.tokenizer
ids = [tok(p, return_tensors="pt").input_ids[0] for p in prompts]
ids = torch.nn.utils.rnn.pad_sequence(ids, batch_first=True, padding_value=tok.pad_token_id)
masks = ids.ne(tok.pad_token_id)
trunc = tok.model_max_length
return ids[:, :trunc].to(self.device), masks[:, :trunc].to(self.device)
# ------------------------------------------------------------------
# Forward
# ------------------------------------------------------------------
def forward(self, batched_inputs):
"""
Args:
batched_inputs: list of dicts, one per image:
"image" float32 [3, 1024, 1024], SAM normalised
"evf_image" float32 [3, 224, 224], BEiT-3 normalised
"height", "width" int, original image size
"prompt" list[str]
"unique_categories" list[int]
Returns:
list of dicts, one per image. Key "instances" is a dict:
"masks" bool tensor [N, H, W]
"scores" float tensor [N]
"class_ids" long tensor [N]
"""
dtype = torch.float32
images = torch.stack([x["image"].to(dtype=dtype, device=self.device) for x in batched_inputs])
images_evf = torch.stack([x["evf_image"].to(dtype=dtype, device=self.device) for x in batched_inputs])
original_size_list = [(x["height"], x["width"]) for x in batched_inputs]
# Build flattened prompt list with per-image offsets
offset = [0]
all_prompts = []
for x in batched_inputs:
all_prompts.extend(x["prompt"])
offset.append(offset[-1] + len(x["prompt"]))
input_ids, attention_masks = self._tokenize_prompts(all_prompts)
batch_size = len(batched_inputs)
# SAM2 visual encoder
with torch.no_grad():
backbone_out = self.evf_sam2.visual_model.forward_image(images)
_, image_embeddings, _, _ = self.evf_sam2.visual_model._prepare_backbone_features(backbone_out)
image_embeddings = [e.to(dtype) for e in image_embeddings]
if self.evf_sam2.visual_model.directly_add_no_mem_embed:
image_embeddings[-1] = image_embeddings[-1] + self.evf_sam2.visual_model.no_mem_embed
# Expand images_evf per prompt count if using visual tokens
if self.config.use_visual_tokens:
imgs_list = []
for i in range(batch_size):
n = offset[i + 1] - offset[i]
imgs_list.append(images_evf[i].unsqueeze(0).expand(n, -1, -1, -1).contiguous())
images_evf_expanded = torch.cat(imgs_list, dim=0)
else:
images_evf_expanded = None
# BEiT-3 multimodal encoding
with torch.no_grad():
if images_evf_expanded is not None:
out = self.evf_sam2.mm_extractor.beit3(
visual_tokens=images_evf_expanded,
textual_tokens=input_ids,
text_padding_position=~attention_masks,
)
else:
out = self.evf_sam2.mm_extractor.beit3(
visual_tokens=None,
textual_tokens=input_ids,
text_padding_position=~attention_masks,
)
feat = out["encoder_out"][:, :1, ...] # [total_prompts, 1, hidden]
feat = self.text_hidden_fcs[0](feat) # [total_prompts, 1, query_dim]
# Split back per image
feat = torch.split(feat, [offset[i + 1] - offset[i] for i in range(batch_size)])
# Multi-scale image feature tensor
feats = [
e.permute(1, 2, 0).view(batch_size, -1, *sz)
for e, sz in zip(image_embeddings[::-1], self._bb_feat_sizes[::-1])
][::-1]
_features = {"image_embed": feats[-1], "high_res_feats": feats[:-1]}
processed_results = []
for img_idx in range(batch_size):
img_feat = feat[img_idx] # [num_prompts, 1, query_dim]
# Build batch_feat_with_tokens
tokens_list = []
for pf in img_feat:
repeated = pf.expand(self.config.num_tokens, -1, -1) # [num_tokens, 1, query_dim]
tokens_list.append(repeated + self.positional_tokens.unsqueeze(1))
batch_feat_with_tokens = torch.cat(tokens_list, dim=0) # [total_tokens, 1, query_dim]
# Cross-attention with skip connection
if self.config.use_cross_attention:
img_embed = _features["image_embed"][img_idx].flatten(1).transpose(0, 1).unsqueeze(0)
# batch_feat_with_tokens: [total_tokens, 1, qd] → squeeze middle → [1, total_tokens, qd]
bft = batch_feat_with_tokens.squeeze(1).unsqueeze(0)
enhanced = self.cross_attention_transformer(bft, img_embed) # [1, total_tokens, qd]
# Reshape back to [total_tokens, 1, qd] and add skip connection
enhanced = enhanced.squeeze(0).unsqueeze(1)
batch_feat_with_tokens = batch_feat_with_tokens + enhanced
# SAM2 prompt encoder + mask decoder
sparse_embeddings, dense_embeddings = self.evf_sam2.visual_model.sam_prompt_encoder(
points=None, boxes=None, masks=None, text_embeds=batch_feat_with_tokens,
)
sparse_embeddings = sparse_embeddings.to(batch_feat_with_tokens.dtype)
high_res_features = [
f[img_idx].unsqueeze(0) for f in _features["high_res_feats"]
]
# The decoder's repeat_image path duplicates image_embeddings and
# image_pe once per candidate query, so scoring all num_classes *
# num_tokens candidates in one call can reach double-digit GiB
# for large vocabularies. `chunk_size` bounds peak memory
# rather than total candidate count.
image_embed_img = _features["image_embed"][img_idx].unsqueeze(0)
image_pe = self.evf_sam2.visual_model.sam_prompt_encoder.get_dense_pe()
chunk_size = self.config.mask_decoder_chunk_size
num_total_tokens = sparse_embeddings.shape[0]
low_res_masks_chunks = []
iou_pred_chunks = []
with torch.no_grad():
for start in range(0, num_total_tokens, chunk_size):
end = min(start + chunk_size, num_total_tokens)
chunk_low_res_masks, chunk_iou_pred, _, _ = self.evf_sam2.visual_model.sam_mask_decoder(
image_embeddings=image_embed_img,
image_pe=image_pe,
sparse_prompt_embeddings=sparse_embeddings[start:end],
dense_prompt_embeddings=dense_embeddings[start:end],
multimask_output=False,
repeat_image=True,
high_res_features=high_res_features,
)
low_res_masks_chunks.append(chunk_low_res_masks)
iou_pred_chunks.append(chunk_iou_pred)
low_res_masks = torch.cat(low_res_masks_chunks, dim=0)
iou_pred = torch.cat(iou_pred_chunks, dim=0)
pred_masks = low_res_masks.squeeze(1) # [total_tokens, H_low, W_low]
pred_logits = iou_pred.squeeze(1) # [total_tokens]
# Assign class labels: each prompt gets num_tokens predictions
unique_categories = batched_inputs[img_idx]["unique_categories"]
num_total = pred_masks.shape[0]
class_indices = torch.div(
torch.arange(num_total, device=self.device),
self.config.num_tokens, rounding_mode="floor"
)
class_labels = torch.tensor(
[unique_categories[int(i)] for i in class_indices],
dtype=torch.long, device=self.device,
)
# Filter on low-res masks first; only the survivors get upsampled
# to the original image size (upsampling the full query set before
# filtering allocates one [num_queries, H, W] float32 tensor that
# can reach double-digit GiB for large vocabularies).
instances = self._instance_inference(
pred_masks, pred_logits, class_labels, original_size_list[img_idx]
)
processed_results.append({"instances": instances})
return processed_results
def _postprocess_masks(self, masks, orig_hw):
return F.interpolate(
masks.float().unsqueeze(0), orig_hw, mode="bilinear", align_corners=False
).squeeze(0)
def _instance_inference(self, pred_masks, iou_scores, class_labels, orig_hw):
"""Returns dict with keys: masks (bool), scores (float), class_ids (long)."""
pred_masks = pred_masks.squeeze(1) if pred_masks.ndim == 4 else pred_masks
# Top-K filter
if self.config.top_k_on:
k = min(self.config.detections_per_image, pred_masks.shape[0])
idx = torch.argsort(iou_scores, descending=True)[:k]
pred_masks, iou_scores, class_labels = pred_masks[idx], iou_scores[idx], class_labels[idx]
# IoU threshold filter
keep = iou_scores >= self.config.iou_thresh
pred_masks, iou_scores, class_labels = pred_masks[keep], iou_scores[keep], class_labels[keep]
if pred_masks.shape[0] == 0:
empty = torch.empty(0, device=self.device)
return {
"masks": torch.empty((0, *orig_hw), dtype=torch.bool, device=self.device),
"scores": empty,
"class_ids": empty.long(),
}
# NMS on low-res masks — box IoU is scale-invariant, so this doesn't
# need the full-res masks either.
low_res_binary_masks = pred_masks > 0
if self.config.nms_on:
boxes = _masks_to_boxes(low_res_binary_masks)
nms_keep = torchvision.ops.nms(boxes, iou_scores, self.config.nms_thresh)
pred_masks = pred_masks[nms_keep]
iou_scores = iou_scores[nms_keep]
class_labels = class_labels[nms_keep]
# Upsample only the surviving masks to the original image size
pred_masks = self._postprocess_masks(pred_masks, orig_hw)
binary_masks = pred_masks > 0
return {
"masks": binary_masks,
"scores": iou_scores,
"class_ids": class_labels,
}
# ------------------------------------------------------------------
# Convenience preprocessing (mirrors demo/inference_utils.py)
# ------------------------------------------------------------------
def preprocess_image(self, image):
"""Normalise a uint8 HWC numpy array (RGB) → float32 [3, 1024, 1024] tensor on model device."""
if not isinstance(image, np.ndarray):
image = np.array(image)
tensor = torch.as_tensor(np.ascontiguousarray(image.transpose(2, 0, 1))).float()
tensor = F.interpolate(
tensor.unsqueeze(0), (1024, 1024), mode="bilinear", align_corners=False
).squeeze(0)
return (tensor - self.pixel_mean.cpu()) / self.pixel_std.cpu()
def preprocess_image_beit3(self, image):
"""Normalise a uint8 HWC numpy array (RGB) → float32 [3, 224, 224] tensor."""
from torchvision import transforms
from PIL import Image as PILImage
if isinstance(image, np.ndarray):
pil = PILImage.fromarray(image.astype(np.uint8))
else:
pil = image
tf = transforms.Compose([
transforms.ToTensor(),
transforms.Resize((224, 224), interpolation=3, antialias=None),
transforms.Normalize(mean=(0.5, 0.5, 0.5), std=(0.5, 0.5, 0.5)),
])
return tf(pil)