Spaces:
Running on Zero
Running on Zero
File size: 11,389 Bytes
f66bbd0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | """
model.py β The GazeRefine zero-shot segmentation model.
This is a 1:1 refactor of the two task-specific scripts (Kvasir-SEG polyp /
NCI-ISBI prostate MRI) into a single, dataset-agnostic module. The numerics
are unchanged from the original experiments β only structure, naming and
comments were cleaned up. Every per-dataset difference (sigma, kNN k,
gaze-anchor weight, max_iters, ...) is now a constructor / config argument
instead of a hardcoded default, see ``configs/*.yaml``.
Maps onto the paper (Section 2) as follows:
gaze.generate_gaze_heatmap -> H(u, v) (Eq. gaze prior)
W_fg, W_bg -> W_fg^(0), W_bg^(0) (gaze-derived weights)
F_proto_init, B_proto_init -> F^(0), B^(0) (initial prototypes)
contrast_method="difference" -> S_i^(t) = max(0, s_fg - alpha*s_bg) (contrastive cleaning)
knn_affinity_refinement -> SΜ_i^(t) (kNN affinity propagation)
gaze_anchor_weight -> lambda (anchor blending strength)
convergence check -> ||F^(t+1) - F^(t)|| < eps
"""
from __future__ import annotations
import torch
import torch.nn as nn
import torch.nn.functional as F
from .backbone import FrozenDINOv3
from .gaze import generate_gaze_heatmap
from .constants import IMG_SIZE, PATCH_SIZE
EPS = 1e-12
def knn_affinity_refinement(
Pv: torch.Tensor, S: torch.Tensor, k: int = 5, temperature: float = 0.05
) -> torch.Tensor:
"""Propagate a per-patch score map ``S`` across its k nearest neighbors in
frozen DINOv3 feature space (patch-to-patch affinity), encouraging
coherent, object-level responses instead of isolated high-confidence
patches. Corresponds to SΜ in the paper.
Pv : (B, N, D) raw (unnormalized) patch embeddings.
S : (B, N) current per-patch score to be smoothed.
"""
B, N, D = Pv.shape
Pv_norm = F.normalize(Pv, p=2, dim=-1)
sim_matrix = torch.bmm(Pv_norm, Pv_norm.transpose(1, 2)) # (B, N, N)
topk_vals, topk_indices = torch.topk(sim_matrix, k=k, dim=-1) # (B, N, k)
weights = F.softmax(topk_vals / temperature, dim=-1) # (B, N, k)
S_expanded = S.unsqueeze(1).expand(B, N, N)
S_neighbors = torch.gather(S_expanded, dim=2, index=topk_indices) # (B, N, k)
return torch.sum(weights * S_neighbors, dim=-1) # (B, N)
class GazeRefine(nn.Module):
"""Zero-shot, training-free, gaze-guided segmentation model.
The model has exactly one set of *learned* weights: the frozen,
pretrained DINOv3 backbone. Everything else β prototype construction,
contrastive cleaning, kNN propagation, gaze anchoring β is a closed-form
operation re-run from scratch on every image at inference time.
Parameters
----------
dino_name : timm DINOv3 checkpoint name.
img_size, patch_size : input resolution / ViT patch size. Must satisfy
``img_size % patch_size == 0``.
sigma : Gaussian spread (in patch units) for the gaze heatmap.
extract_mode : ``"last"`` (1 block) or ``"all"`` (4 blocks, averaged).
contrast_method : ``"difference"`` (paper default, contrastive cleaning),
``"softmax"`` (foreground/background softmax ratio), or
``"original"`` (plain foreground cosine similarity, no background
suppression β kept for the ablation in Table 2).
temperature : softmax temperature, only used when contrast_method="softmax".
max_iters : maximum recurrent refinement iterations (T in the paper).
knn_refine : whether to apply kNN affinity propagation each iteration.
knn_k, knn_temp : kNN neighborhood size / softmax temperature.
gaze_anchor_weight : lambda β how strongly each iteration's prototypes
are pulled back toward the original gaze-only prototypes. Higher =
trust the raw fixations more; lower = let the model drift further
from the initial gaze region.
"""
def __init__(
self,
dino_name: str = "vit_large_patch16_dinov3.lvd1689m",
img_size: int = IMG_SIZE,
patch_size: int = PATCH_SIZE,
sigma: float = 2.0,
extract_mode: str = "last",
contrast_method: str = "difference",
temperature: float = 0.05,
max_iters: int = 10,
knn_refine: bool = True,
knn_k: int = 5,
knn_temp: float = 0.1,
gaze_anchor_weight: float = 0.6,
):
super().__init__()
assert img_size % patch_size == 0, "img_size must be a multiple of patch_size"
self.img_size = img_size
self.h_patch = img_size // patch_size
self.sigma = sigma
self.contrast_method = contrast_method
self.temperature = temperature
self.max_iters = max_iters
self.knn_refine = knn_refine
self.knn_k = knn_k
self.knn_temp = knn_temp
self.gaze_anchor_weight = gaze_anchor_weight
self.visual_enc = FrozenDINOv3(dino_name, extract_mode=extract_mode)
# ------------------------------------------------------------------ #
def _gaze_weights(self, fixation: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Scanpath -> heatmap -> normalized foreground/background spatial weights."""
gaze_heatmap = generate_gaze_heatmap(fixation, h_patch=self.h_patch, sigma=self.sigma)
H_flat = gaze_heatmap.view(gaze_heatmap.size(0), -1)
W_fg = H_flat / (H_flat.sum(dim=-1, keepdim=True) + EPS)
bg_w = 1.0 - H_flat
W_bg = bg_w / (bg_w.sum(dim=-1, keepdim=True) + EPS)
return gaze_heatmap, W_fg, W_bg
def _score(self, Pv_norm: torch.Tensor, F_proto_norm: torch.Tensor, B_proto_norm: torch.Tensor) -> torch.Tensor:
"""Foreground/background contrastive scoring for one level, one iteration."""
if self.contrast_method == "original":
S = torch.bmm(Pv_norm, F_proto_norm.unsqueeze(-1)).squeeze(-1)
return torch.clamp(S, min=0.0)
sim_fg = torch.bmm(Pv_norm, F_proto_norm.unsqueeze(-1)).squeeze(-1)
sim_bg = torch.bmm(Pv_norm, B_proto_norm.unsqueeze(-1)).squeeze(-1)
if self.contrast_method == "difference":
return torch.clamp(sim_fg - sim_bg, min=0.0)
elif self.contrast_method == "softmax":
stacked = torch.stack([sim_fg, sim_bg], dim=-1) / self.temperature
probs = F.softmax(stacked, dim=-1)
return probs[:, :, 0]
raise ValueError(f"Unknown contrast_method: {self.contrast_method!r}")
def _refine_level(self, Pv: torch.Tensor, W_fg: torch.Tensor, W_bg: torch.Tensor) -> torch.Tensor:
"""Run the full recurrent gaze-anchored refinement loop for one DINOv3 level.
Returns the final (B, N) per-patch foreground score map for that level."""
B = Pv.size(0)
device = Pv.device
Pv_norm = F.normalize(Pv, p=2, dim=-1)
# Initial gaze-only prototypes β F^(0), B^(0)
F_proto_init = torch.sum(W_fg.unsqueeze(-1) * Pv, dim=1)
B_proto_init = torch.sum(W_bg.unsqueeze(-1) * Pv, dim=1)
W_fg_curr, W_bg_curr = W_fg.clone(), W_bg.clone()
best_S = W_fg.clone()
S_prev = W_fg.clone()
active = torch.ones(B, dtype=torch.bool, device=device)
for _ in range(self.max_iters):
W_fg_norm = W_fg_curr / (W_fg_curr.sum(dim=-1, keepdim=True) + EPS)
W_bg_norm = W_bg_curr / (W_bg_curr.sum(dim=-1, keepdim=True) + EPS)
F_proto_curr = torch.sum(W_fg_norm.unsqueeze(-1) * Pv, dim=1)
B_proto_curr = torch.sum(W_bg_norm.unsqueeze(-1) * Pv, dim=1)
# Anchor toward the initial gaze-only prototypes β lambda blending
lam = self.gaze_anchor_weight
F_proto = (1 - lam) * F_proto_curr + lam * F_proto_init
B_proto = (1 - lam) * B_proto_curr + lam * B_proto_init
F_proto_norm = F.normalize(F_proto, p=2, dim=-1)
B_proto_norm = F.normalize(B_proto, p=2, dim=-1)
S_iter = self._score(Pv_norm, F_proto_norm, B_proto_norm)
if self.knn_refine:
S_iter = knn_affinity_refinement(Pv, S_iter, k=self.knn_k, temperature=self.knn_temp)
S_new = S_prev.clone()
S_new[active] = S_iter[active]
# Collapse prevention: if an active sample's map flattened to ~0,
# roll it back and freeze it instead of letting it degenerate.
collapsed = S_new.max(dim=-1)[0] < 1e-4
freeze = active & collapsed
S_new[freeze] = S_prev[freeze]
active = active & ~collapsed
best_S = S_new.clone()
if not active.any():
break
# Turn the refined score into the next iteration's spatial weights
S_sig = torch.sigmoid(S_new)
W_fg_next = S_sig / (S_sig.sum(dim=-1, keepdim=True) + EPS)
bg_w = 1.0 - S_sig
bg_w = torch.clamp(bg_w - bg_w.min(dim=-1, keepdim=True).values, min=0.0)
W_bg_next = bg_w / (bg_w.sum(dim=-1, keepdim=True) + EPS)
F_proto_next = torch.sum(W_fg_next.unsqueeze(-1) * Pv, dim=1)
F_proto_next_norm = F.normalize(F_proto_next, p=2, dim=-1)
# Convergence: foreground prototype stopped moving
diff = torch.abs(F_proto_next_norm - F_proto_norm).mean(dim=-1)
if torch.all(diff < 1e-6):
break
S_prev = S_new.clone()
W_fg_curr[active] = W_fg_next[active]
W_bg_curr[active] = W_bg_next[active]
return best_S
# ------------------------------------------------------------------ #
def forward(self, image: torch.Tensor, fixation: torch.Tensor) -> dict:
"""
image : (B, 3, H, W) normalized RGB tensor (ImageNet mean/std).
fixation : (B, L, 3) zero-padded scanpath, see ``gazerefine.gaze.get_scanpath``.
Returns a dict with:
preds (B, 1, H, W) final mask in [0, 1] β threshold at 0.5 for a binary mask
gaze_heatmap (B, h, w) the raw gaze prior, useful for visualization
final_map (B, N) the un-upsampled patch-level score map
"""
B = image.size(0)
Pv_list = self.visual_enc(image)
gaze_heatmap, W_fg, W_bg = self._gaze_weights(fixation)
all_maps = [self._refine_level(Pv, W_fg, W_bg) for Pv in Pv_list]
final_map = torch.stack(all_maps, dim=1).mean(dim=1) # average across levels
grid = final_map.view(B, 1, self.h_patch, self.h_patch)
upsampled = F.interpolate(grid, size=(self.img_size, self.img_size), mode="bilinear", align_corners=False)
# per-sample min-max normalization -> ready-to-threshold mask
flat = upsampled.view(B, -1)
m_min = flat.min(dim=-1, keepdim=True).values.view(B, 1, 1, 1)
m_max = flat.max(dim=-1, keepdim=True).values.view(B, 1, 1, 1)
preds = (upsampled - m_min) / (m_max - m_min + 1e-8)
return dict(preds=preds, gaze_heatmap=gaze_heatmap, final_map=final_map)
|