Add CLIP-based semantic re-ranking for instance segmentation

#3
configuration_openworld_sam.py CHANGED
@@ -53,6 +53,21 @@ class OpenWorldSAMConfig(PretrainedConfig):
53
  iou_thresh=0.7,
54
  top_k_on=False,
55
  detections_per_image=100,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  # Class vocabulary
57
  stuff_classes=None,
58
  **kwargs,
@@ -74,4 +89,8 @@ class OpenWorldSAMConfig(PretrainedConfig):
74
  self.iou_thresh = iou_thresh
75
  self.top_k_on = top_k_on
76
  self.detections_per_image = detections_per_image
 
 
 
 
77
  self.stuff_classes = stuff_classes if stuff_classes is not None else ADE20K_150_CLASSES
 
53
  iou_thresh=0.7,
54
  top_k_on=False,
55
  detections_per_image=100,
56
+ # Mask decoder batching: the decoder's repeat_image path duplicates
57
+ # the image embeddings/positional encoding once per candidate query,
58
+ # so running all num_classes * num_tokens candidates through it in
59
+ # one call can reach double-digit GiB for large vocabularies. This
60
+ # bounds peak memory by running the decoder in chunks instead.
61
+ mask_decoder_chunk_size=500,
62
+ # CLIP-based re-ranking: the SAM mask-quality (IoU) score measures
63
+ # only whether a mask is well-formed, not whether it matches the
64
+ # queried class, so class-agnostic mask proposals conditioned on
65
+ # different prompts routinely tie on score for the same object. When
66
+ # enabled, surviving candidates (post iou_thresh) are re-scored and
67
+ # re-labeled using actual CLIP image/text similarity instead.
68
+ use_clip_rerank=True,
69
+ clip_model_name_or_path="openai/clip-vit-base-patch32",
70
+ clip_crop_padding_frac=0.1,
71
  # Class vocabulary
72
  stuff_classes=None,
73
  **kwargs,
 
89
  self.iou_thresh = iou_thresh
90
  self.top_k_on = top_k_on
91
  self.detections_per_image = detections_per_image
92
+ self.mask_decoder_chunk_size = mask_decoder_chunk_size
93
+ self.use_clip_rerank = use_clip_rerank
94
+ self.clip_model_name_or_path = clip_model_name_or_path
95
+ self.clip_crop_padding_frac = clip_crop_padding_frac
96
  self.stuff_classes = stuff_classes if stuff_classes is not None else ADE20K_150_CLASSES
modeling_openworld_sam.py CHANGED
@@ -180,6 +180,10 @@ class OpenWorldSAMModel(PreTrainedModel):
180
  # Tokenizer loaded lazily on first forward
181
  self._tokenizer = None
182
 
 
 
 
 
183
  # Required by transformers>=5's from_pretrained (sets
184
  # self.all_tied_weights_keys and other bookkeeping consumed by
185
  # _finalize_model_loading); harmless no-op pre-checkpoint-load init.
@@ -199,6 +203,31 @@ class OpenWorldSAMModel(PreTrainedModel):
199
  )
200
  return self._tokenizer
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  def _tokenize_prompts(self, prompts):
203
  tok = self.tokenizer
204
  ids = [tok(p, return_tensors="pt").input_ids[0] for p in prompts]
@@ -388,11 +417,11 @@ class OpenWorldSAMModel(PreTrainedModel):
388
  iou_pred_all = torch.cat(iou_pred_chunks, dim=0)
389
 
390
  pred_masks_all = low_res_masks_all.squeeze(1) # [total_tokens_all, H_low, W_low]
391
- pred_logits_all = iou_pred_all.squeeze(1) # [total_tokens_all]
392
-
393
  pred_masks_per_image = torch.split(pred_masks_all, token_counts)
394
  pred_logits_per_image = torch.split(pred_logits_all, token_counts)
395
-
396
  processed_results = []
397
  for img_idx in range(batch_size):
398
  pred_masks = pred_masks_per_image[img_idx]
@@ -410,14 +439,31 @@ class OpenWorldSAMModel(PreTrainedModel):
410
  dtype=torch.long, device=self.device,
411
  )
412
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  # Filter on low-res masks first; only the survivors get upsampled
414
  # to the original image size (upsampling the full query set before
415
  # filtering allocates one [num_queries, H, W] float32 tensor that
416
  # can reach double-digit GiB for large vocabularies).
417
  instances = self._instance_inference(
418
- pred_masks, pred_logits, class_labels, original_size_list[img_idx]
 
419
  )
420
-
421
  processed_results.append({"instances": instances})
422
 
423
  return processed_results
@@ -427,7 +473,93 @@ class OpenWorldSAMModel(PreTrainedModel):
427
  masks.float().unsqueeze(0), orig_hw, mode="bilinear", align_corners=False
428
  ).squeeze(0)
429
 
430
- def _instance_inference(self, pred_masks, iou_scores, class_labels, orig_hw):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  """Returns dict with keys: masks (bool), scores (float), class_ids (long)."""
432
  pred_masks = pred_masks.squeeze(1) if pred_masks.ndim == 4 else pred_masks
433
 
@@ -449,6 +581,12 @@ class OpenWorldSAMModel(PreTrainedModel):
449
  "class_ids": empty.long(),
450
  }
451
 
 
 
 
 
 
 
452
 
453
  # NMS on low-res masks — box IoU is scale-invariant, so this doesn't
454
  # need the full-res masks either.
 
180
  # Tokenizer loaded lazily on first forward
181
  self._tokenizer = None
182
 
183
+ # CLIP model/processor (semantic re-ranking) loaded lazily on first use
184
+ self._clip_model = None
185
+ self._clip_processor = None
186
+
187
  # Required by transformers>=5's from_pretrained (sets
188
  # self.all_tied_weights_keys and other bookkeeping consumed by
189
  # _finalize_model_loading); harmless no-op pre-checkpoint-load init.
 
203
  )
204
  return self._tokenizer
205
 
206
+ # ------------------------------------------------------------------
207
+ # CLIP (semantic re-ranking)
208
+ # ------------------------------------------------------------------
209
+
210
+ @property
211
+ def clip_model(self):
212
+ if self._clip_model is None:
213
+ from transformers import CLIPModel
214
+
215
+ self._clip_model = CLIPModel.from_pretrained(
216
+ self.config.clip_model_name_or_path
217
+ ).to(self.device)
218
+ self._clip_model.eval()
219
+ return self._clip_model
220
+
221
+ @property
222
+ def clip_processor(self):
223
+ if self._clip_processor is None:
224
+ from transformers import CLIPProcessor
225
+
226
+ self._clip_processor = CLIPProcessor.from_pretrained(
227
+ self.config.clip_model_name_or_path
228
+ )
229
+ return self._clip_processor
230
+
231
  def _tokenize_prompts(self, prompts):
232
  tok = self.tokenizer
233
  ids = [tok(p, return_tensors="pt").input_ids[0] for p in prompts]
 
417
  iou_pred_all = torch.cat(iou_pred_chunks, dim=0)
418
 
419
  pred_masks_all = low_res_masks_all.squeeze(1) # [total_tokens_all, H_low, W_low]
420
+ pred_logits_all = iou_pred_all.squeeze(1) # [total_tokens_all]
421
+
422
  pred_masks_per_image = torch.split(pred_masks_all, token_counts)
423
  pred_logits_per_image = torch.split(pred_logits_all, token_counts)
424
+
425
  processed_results = []
426
  for img_idx in range(batch_size):
427
  pred_masks = pred_masks_per_image[img_idx]
 
439
  dtype=torch.long, device=self.device,
440
  )
441
 
442
+ square_rgb = None
443
+ id_to_text = None
444
+ if self.config.use_clip_rerank:
445
+ # Reads from config directly rather than the pixel_mean/std
446
+ # buffers: those are registered persistent=False (excluded
447
+ # from the checkpoint on purpose), but transformers'
448
+ # from_pretrained fast-init path zeroes any buffer it
449
+ # doesn't find a matching checkpoint key for, config values
450
+ # aren't affected by that.
451
+ mean = torch.tensor(self.config.pixel_mean, device=images.device).view(-1, 1, 1)
452
+ std = torch.tensor(self.config.pixel_std, device=images.device).view(-1, 1, 1)
453
+ denorm = images[img_idx].float() * std + mean
454
+ square_rgb = denorm.clamp(0, 255).round().byte().permute(1, 2, 0).cpu().numpy()
455
+ id_to_text = dict(zip(
456
+ unique_categories, batched_inputs[img_idx]["prompt"]
457
+ ))
458
+
459
  # Filter on low-res masks first; only the survivors get upsampled
460
  # to the original image size (upsampling the full query set before
461
  # filtering allocates one [num_queries, H, W] float32 tensor that
462
  # can reach double-digit GiB for large vocabularies).
463
  instances = self._instance_inference(
464
+ pred_masks, pred_logits, class_labels, original_size_list[img_idx],
465
+ square_rgb=square_rgb, id_to_text=id_to_text,
466
  )
 
467
  processed_results.append({"instances": instances})
468
 
469
  return processed_results
 
473
  masks.float().unsqueeze(0), orig_hw, mode="bilinear", align_corners=False
474
  ).squeeze(0)
475
 
476
+ def _clip_rerank(self, pred_masks, class_labels, square_rgb, id_to_text):
477
+ """Re-scores/re-labels candidates using real CLIP image/text similarity.
478
+
479
+ The SAM mask-quality (IoU) score measures whether a mask is well
480
+ formed, not whether it matches the queried class: each prompt is run
481
+ through cross-attention independently, so several unrelated prompts
482
+ routinely produce near-identical masks over the same salient object,
483
+ each with a similarly high IoU score. This crops each surviving
484
+ candidate's mask region out of the original image and asks a real
485
+ (frozen, off-the-shelf) CLIP model which of the candidate class names
486
+ actually matches that crop, replacing both the class label and the
487
+ score with CLIP's pick/confidence.
488
+
489
+ Args:
490
+ pred_masks: bool/float tensor [N, h, w], low-res mask logits/mask
491
+ class_labels: long tensor [N], the prompt each mask was proposed under
492
+ square_rgb: uint8 numpy array [H, W, 3], the (square) SAM input image
493
+ id_to_text: dict mapping category id -> prompt string
494
+
495
+ Returns:
496
+ (class_labels, scores) tensors, both length N
497
+ """
498
+ n = pred_masks.shape[0]
499
+ if n == 0:
500
+ return class_labels, torch.empty(0, device=self.device)
501
+
502
+ from PIL import Image
503
+
504
+ H, W = square_rgb.shape[:2]
505
+ masks_up = F.interpolate(
506
+ (pred_masks > 0).float().unsqueeze(1), (H, W), mode="nearest"
507
+ ).squeeze(1).bool()
508
+
509
+ padding_frac = self.config.clip_crop_padding_frac
510
+ crops = []
511
+ for i in range(n):
512
+ ys, xs = torch.where(masks_up[i])
513
+ if ys.numel() == 0:
514
+ crops.append(None)
515
+ continue
516
+ y1, y2 = int(ys.min()), int(ys.max())
517
+ x1, x2 = int(xs.min()), int(xs.max())
518
+ ph = int((y2 - y1 + 1) * padding_frac)
519
+ pw = int((x2 - x1 + 1) * padding_frac)
520
+ y1, y2 = max(0, y1 - ph), min(H, y2 + 1 + ph)
521
+ x1, x2 = max(0, x1 - pw), min(W, x2 + 1 + pw)
522
+ crops.append(Image.fromarray(square_rgb[y1:y2, x1:x2]))
523
+
524
+ unique_ids = sorted(set(id_to_text.keys()))
525
+ texts = [id_to_text[c] for c in unique_ids]
526
+
527
+ valid_idx = [i for i, c in enumerate(crops) if c is not None]
528
+ new_class_labels = class_labels.clone()
529
+ new_scores = torch.zeros(n, device=self.device)
530
+ if not valid_idx:
531
+ return new_class_labels, new_scores
532
+
533
+ with torch.no_grad():
534
+ text_inputs = self.clip_processor(
535
+ text=texts, return_tensors="pt", padding=True
536
+ ).to(self.device)
537
+ # get_text_features/get_image_features return a
538
+ # BaseModelOutputWithPooling whose `.pooler_output` has been
539
+ # overwritten with the projected embedding, not a raw tensor.
540
+ text_feats = self.clip_model.get_text_features(**text_inputs).pooler_output
541
+ text_feats = text_feats / text_feats.norm(dim=-1, keepdim=True)
542
+
543
+ img_inputs = self.clip_processor(
544
+ images=[crops[i] for i in valid_idx], return_tensors="pt"
545
+ ).to(self.device)
546
+ img_feats = self.clip_model.get_image_features(**img_inputs).pooler_output
547
+ img_feats = img_feats / img_feats.norm(dim=-1, keepdim=True)
548
+
549
+ logit_scale = self.clip_model.logit_scale.exp()
550
+ probs = ((img_feats @ text_feats.T) * logit_scale).softmax(dim=-1)
551
+
552
+ for row, i in enumerate(valid_idx):
553
+ best = int(probs[row].argmax())
554
+ new_class_labels[i] = unique_ids[best]
555
+ new_scores[i] = probs[row, best]
556
+
557
+ return new_class_labels, new_scores
558
+
559
+ def _instance_inference(
560
+ self, pred_masks, iou_scores, class_labels, orig_hw,
561
+ square_rgb=None, id_to_text=None,
562
+ ):
563
  """Returns dict with keys: masks (bool), scores (float), class_ids (long)."""
564
  pred_masks = pred_masks.squeeze(1) if pred_masks.ndim == 4 else pred_masks
565
 
 
581
  "class_ids": empty.long(),
582
  }
583
 
584
+ # Re-score/re-label using real CLIP image/text similarity instead of
585
+ # the SAM mask-quality score, which doesn't reflect class match.
586
+ if self.config.use_clip_rerank and square_rgb is not None and id_to_text is not None:
587
+ class_labels, iou_scores = self._clip_rerank(
588
+ pred_masks, class_labels, square_rgb, id_to_text
589
+ )
590
 
591
  # NMS on low-res masks — box IoU is scale-invariant, so this doesn't
592
  # need the full-res masks either.