subirmansukhani commited on
Commit
707f439
·
1 Parent(s): 202cb33

Crop padding patches from SigLIP attention heatmaps

Browse files

resize_with_pad pads on top/left, creating pure-padding patch rows/cols
that distort attention heatmaps after baseline subtraction. This adds a
post-processing crop that removes padding patches before upsampling:

- Add compute_padding_patches() helper to calculate top/left pad counts
- Add content_crop param to attention_to_heatmap() to slice off padding
- Compute crop from vision encoder config and thread through self-attn
and per-head grid paths (cross-attention uses post-connector grid, unaffected)
- Fix preprocessing to use resize_with_pad + [-1,1] normalization matching
the real SigLIP pipeline, including for positional baseline computation

Files changed (1) hide show
  1. inspect_attention.py +86 -20
inspect_attention.py CHANGED
@@ -75,6 +75,25 @@ import matplotlib.gridspec as gridspec
75
  from PIL import Image
76
  from matplotlib.colors import LinearSegmentedColormap
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  _CYAN_CMAP = LinearSegmentedColormap.from_list("cyan", ["black", "cyan", "white"])
79
 
80
 
@@ -534,7 +553,21 @@ class GradCAMFallback:
534
  # 3. Attention-to-heatmap conversion
535
  # ---------------------------------------------------------------------------
536
 
537
- def attention_to_heatmap(attn_weights, grid_size, image_size):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  """
539
  Convert attention weights from patch-space to pixel-space heatmap.
540
 
@@ -565,7 +598,13 @@ def attention_to_heatmap(attn_weights, grid_size, image_size):
565
  attn_2d = attn_weights.reshape(h_patches, w_patches)
566
  else:
567
  attn_2d = attn_weights
568
-
 
 
 
 
 
 
569
  # Upsample to image resolution using bilinear interpolation
570
  attn_2d = attn_2d.float().unsqueeze(0).unsqueeze(0) # (1, 1, H, W)
571
  heatmap = F.interpolate(attn_2d, size=(h_img, w_img), mode="bilinear", align_corners=False)
@@ -658,7 +697,8 @@ def compute_attention_rollout(all_layer_attentions):
658
  return rollout
659
 
660
 
661
- def compute_positional_baseline(vision_encoder, attn_capture, device, method):
 
662
  """
663
  Compute the attention pattern produced by a content-free (mean-gray)
664
  image. This captures the fixed positional component of attention so
@@ -672,6 +712,10 @@ def compute_positional_baseline(vision_encoder, attn_capture, device, method):
672
  device: Torch device.
673
  method: ``"last-layer"`` or ``"rollout"`` — same aggregation used
674
  for real frames so the baseline is comparable.
 
 
 
 
675
 
676
  Returns:
677
  Tensor of shape ``(num_patches,)`` — per-patch baseline scores.
@@ -681,7 +725,15 @@ def compute_positional_baseline(vision_encoder, attn_capture, device, method):
681
  getattr(vision_encoder, "config", None), "image_size", None,
682
  ) or getattr(vision_encoder, "image_size", 384)
683
 
684
- gray = torch.full((1, 3, img_size, img_size), 0.5, device=device)
 
 
 
 
 
 
 
 
685
  try:
686
  enc_dtype = next(vision_encoder.parameters()).dtype
687
  gray = gray.to(enc_dtype)
@@ -928,7 +980,7 @@ def save_individual_frames(frames, heatmaps, output_dir, episode_idx=0):
928
 
929
 
930
  def create_per_head_grid(frame, attn_weights, grid_size, image_size,
931
- output_path="per_head_attention.png"):
932
  """
933
  Visualise each attention head's pattern individually for a single
934
  frame. Useful for identifying specialised heads (e.g. one tracking
@@ -965,7 +1017,7 @@ def create_per_head_grid(frame, attn_weights, grid_size, image_size,
965
  r, c = divmod(h, cols)
966
  head_attn = attn_weights[h] # (patches, patches)
967
  scores = head_attn.mean(dim=0) # per-patch importance
968
- hmap = attention_to_heatmap(scores, grid_size, image_size)
969
 
970
  blended = overlay_heatmap(frame_np.copy(), hmap, alpha=0.5)
971
  axes[r, c].imshow(blended)
@@ -1328,17 +1380,6 @@ def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
1328
  print(f" WARNING: Could not set up cross-attention capture: {e}")
1329
  cross_capture = None
1330
 
1331
- # --- Compute positional baseline (once) ---
1332
- baseline_scores = None
1333
- if not raw_attention:
1334
- baseline_scores = compute_positional_baseline(
1335
- vision_encoder, attn_capture, device, method,
1336
- )
1337
- if baseline_scores is not None:
1338
- print(" Positional baseline computed (subtracting to reveal content-dependent attention)")
1339
- else:
1340
- print(" Could not compute positional baseline, using raw attention")
1341
-
1342
  # --- Find image key in dataset ---
1343
  if image_key is None:
1344
  image_keys = find_image_keys(dataset)
@@ -1359,6 +1400,29 @@ def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
1359
  task_str = _resolve_task_string(first_sample, dataset)
1360
  print(f" Task string: \"{task_str}\"")
1361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1362
  # --- Run inference and collect attention ---
1363
  print(f"\n[4/4] Running forward passes and extracting attention (method={method})...")
1364
  frames = []
@@ -1411,10 +1475,10 @@ def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
1411
  getattr(vision_encoder, "config", None), "image_size", None,
1412
  ) or getattr(vision_encoder, "image_size", 384)
1413
  if img.shape[-1] != target_size or img.shape[-2] != target_size:
1414
- img_resized = F.interpolate(img, size=(target_size, target_size),
1415
- mode="bilinear", align_corners=False)
1416
  else:
1417
  img_resized = img
 
1418
 
1419
  try:
1420
  enc_dtype = next(vision_encoder.parameters()).dtype
@@ -1507,7 +1571,8 @@ def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
1507
  grid_h = grid_w = grid_side
1508
 
1509
  img_h, img_w = img_tensor.shape[1], img_tensor.shape[2]
1510
- heatmap = attention_to_heatmap(patch_scores, (grid_h, grid_w), (img_h, img_w))
 
1511
  heatmaps.append(heatmap)
1512
 
1513
  print(f" Frame {i}: {n_patches} patches → "
@@ -1520,6 +1585,7 @@ def extract_attention_maps(policy, dataset, episode_idx=0, num_frames=8,
1520
  img_tensor, raw_heads_attn,
1521
  (grid_h, grid_w), (img_h, img_w),
1522
  output_path=head_path,
 
1523
  )
1524
  raw_heads_attn = None # only once
1525
  else:
 
75
  from PIL import Image
76
  from matplotlib.colors import LinearSegmentedColormap
77
 
78
+ try:
79
+ from lerobot.policies.smolvla.modeling_smolvla import resize_with_pad
80
+ except ImportError:
81
+ def resize_with_pad(img, width, height, pad_value=-1):
82
+ """Aspect-ratio-preserving resize with top/left padding."""
83
+ if img.ndim != 4:
84
+ raise ValueError(f"(b,c,h,w) expected, but {img.shape}")
85
+ cur_height, cur_width = img.shape[2:]
86
+ ratio = max(cur_width / width, cur_height / height)
87
+ resized_height = int(cur_height / ratio)
88
+ resized_width = int(cur_width / ratio)
89
+ resized_img = F.interpolate(
90
+ img, size=(resized_height, resized_width), mode="bilinear", align_corners=False,
91
+ )
92
+ pad_height = max(0, int(height - resized_height))
93
+ pad_width = max(0, int(width - resized_width))
94
+ padded_img = F.pad(resized_img, (pad_width, 0, pad_height, 0), value=pad_value)
95
+ return padded_img
96
+
97
  _CYAN_CMAP = LinearSegmentedColormap.from_list("cyan", ["black", "cyan", "white"])
98
 
99
 
 
553
  # 3. Attention-to-heatmap conversion
554
  # ---------------------------------------------------------------------------
555
 
556
+ def compute_padding_patches(input_hw, target_size, patch_size):
557
+ """Number of pure-padding patch rows (top) and columns (left)
558
+ produced by resize_with_pad for the given input dimensions."""
559
+ if input_hw is None:
560
+ return (0, 0)
561
+ in_h, in_w = input_hw
562
+ ratio = max(in_w / target_size, in_h / target_size)
563
+ resized_h = int(in_h / ratio)
564
+ resized_w = int(in_w / ratio)
565
+ pad_h = max(0, target_size - resized_h)
566
+ pad_w = max(0, target_size - resized_w)
567
+ return (pad_h // patch_size, pad_w // patch_size)
568
+
569
+
570
+ def attention_to_heatmap(attn_weights, grid_size, image_size, content_crop=None):
571
  """
572
  Convert attention weights from patch-space to pixel-space heatmap.
573
 
 
598
  attn_2d = attn_weights.reshape(h_patches, w_patches)
599
  else:
600
  attn_2d = attn_weights
601
+
602
+ # Crop out pure-padding patches (top rows, left columns) before upsampling
603
+ if content_crop is not None:
604
+ crop_h, crop_w = content_crop
605
+ if crop_h > 0 or crop_w > 0:
606
+ attn_2d = attn_2d[crop_h:, crop_w:]
607
+
608
  # Upsample to image resolution using bilinear interpolation
609
  attn_2d = attn_2d.float().unsqueeze(0).unsqueeze(0) # (1, 1, H, W)
610
  heatmap = F.interpolate(attn_2d, size=(h_img, w_img), mode="bilinear", align_corners=False)
 
697
  return rollout
698
 
699
 
700
+ def compute_positional_baseline(vision_encoder, attn_capture, device, method,
701
+ input_hw=None):
702
  """
703
  Compute the attention pattern produced by a content-free (mean-gray)
704
  image. This captures the fixed positional component of attention so
 
712
  device: Torch device.
713
  method: ``"last-layer"`` or ``"rollout"`` — same aggregation used
714
  for real frames so the baseline is comparable.
715
+ input_hw: Optional ``(H, W)`` of the original input images. When
716
+ provided the baseline gray image is built at this aspect ratio
717
+ then preprocessed with ``resize_with_pad`` so padding patches
718
+ match those in real frames.
719
 
720
  Returns:
721
  Tensor of shape ``(num_patches,)`` — per-patch baseline scores.
 
725
  getattr(vision_encoder, "config", None), "image_size", None,
726
  ) or getattr(vision_encoder, "image_size", 384)
727
 
728
+ if input_hw is not None:
729
+ # Build gray at original aspect ratio, then resize_with_pad
730
+ # so padding patches match the real frames exactly.
731
+ in_h, in_w = input_hw
732
+ gray_content = torch.full((1, 3, in_h, in_w), 0.5, device=device)
733
+ gray = resize_with_pad(gray_content, img_size, img_size, pad_value=0)
734
+ gray = gray * 2.0 - 1.0 # normalize to [-1, 1] matching SigLIP
735
+ else:
736
+ gray = torch.full((1, 3, img_size, img_size), 0.5, device=device)
737
  try:
738
  enc_dtype = next(vision_encoder.parameters()).dtype
739
  gray = gray.to(enc_dtype)
 
980
 
981
 
982
  def create_per_head_grid(frame, attn_weights, grid_size, image_size,
983
+ output_path="per_head_attention.png", content_crop=None):
984
  """
985
  Visualise each attention head's pattern individually for a single
986
  frame. Useful for identifying specialised heads (e.g. one tracking
 
1017
  r, c = divmod(h, cols)
1018
  head_attn = attn_weights[h] # (patches, patches)
1019
  scores = head_attn.mean(dim=0) # per-patch importance
1020
+ hmap = attention_to_heatmap(scores, grid_size, image_size, content_crop=content_crop)
1021
 
1022
  blended = overlay_heatmap(frame_np.copy(), hmap, alpha=0.5)
1023
  axes[r, c].imshow(blended)
 
1380
  print(f" WARNING: Could not set up cross-attention capture: {e}")
1381
  cross_capture = None
1382
 
 
 
 
 
 
 
 
 
 
 
 
1383
  # --- Find image key in dataset ---
1384
  if image_key is None:
1385
  image_keys = find_image_keys(dataset)
 
1400
  task_str = _resolve_task_string(first_sample, dataset)
1401
  print(f" Task string: \"{task_str}\"")
1402
 
1403
+ # --- Compute positional baseline (once, after frames are loaded so we
1404
+ # know the input aspect ratio for a properly padded baseline) ---
1405
+ first_img = frame_pairs[0][1] # (C, H, W)
1406
+ input_hw = (first_img.shape[1], first_img.shape[2])
1407
+
1408
+ baseline_scores = None
1409
+ if not raw_attention:
1410
+ baseline_scores = compute_positional_baseline(
1411
+ vision_encoder, attn_capture, device, method, input_hw=input_hw,
1412
+ )
1413
+ if baseline_scores is not None:
1414
+ print(" Positional baseline computed (subtracting to reveal content-dependent attention)")
1415
+ else:
1416
+ print(" Could not compute positional baseline, using raw attention")
1417
+
1418
+ # --- Compute padding-patch crop so heatmaps exclude pad regions ---
1419
+ target_size = getattr(getattr(vision_encoder, "config", None), "image_size", None) or 384
1420
+ patch_size_cfg = getattr(vision_encoder, "patch_size", None) or getattr(
1421
+ getattr(vision_encoder, "config", None), "patch_size", 14)
1422
+ content_crop = compute_padding_patches(input_hw, target_size, patch_size_cfg)
1423
+ if content_crop != (0, 0):
1424
+ print(f" Padding crop: {content_crop[0]} top rows, {content_crop[1]} left cols of patches")
1425
+
1426
  # --- Run inference and collect attention ---
1427
  print(f"\n[4/4] Running forward passes and extracting attention (method={method})...")
1428
  frames = []
 
1475
  getattr(vision_encoder, "config", None), "image_size", None,
1476
  ) or getattr(vision_encoder, "image_size", 384)
1477
  if img.shape[-1] != target_size or img.shape[-2] != target_size:
1478
+ img_resized = resize_with_pad(img, target_size, target_size, pad_value=0)
 
1479
  else:
1480
  img_resized = img
1481
+ img_resized = img_resized * 2.0 - 1.0 # normalize to [-1, 1] matching SigLIP
1482
 
1483
  try:
1484
  enc_dtype = next(vision_encoder.parameters()).dtype
 
1571
  grid_h = grid_w = grid_side
1572
 
1573
  img_h, img_w = img_tensor.shape[1], img_tensor.shape[2]
1574
+ heatmap = attention_to_heatmap(patch_scores, (grid_h, grid_w), (img_h, img_w),
1575
+ content_crop=content_crop)
1576
  heatmaps.append(heatmap)
1577
 
1578
  print(f" Frame {i}: {n_patches} patches → "
 
1585
  img_tensor, raw_heads_attn,
1586
  (grid_h, grid_w), (img_h, img_w),
1587
  output_path=head_path,
1588
+ content_crop=content_crop,
1589
  )
1590
  raw_heads_attn = None # only once
1591
  else: