Sync Coast-style multi-scale fusion (part 2)
Browse files- marine_dual_dinov3_backbone.py +180 -30
marine_dual_dinov3_backbone.py
CHANGED
|
@@ -55,6 +55,163 @@ def _set_module_trainable(module: nn.Module, trainable: bool) -> None:
|
|
| 55 |
param.requires_grad = trainable
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
class DINOv3ViTConvNeXtBackbone(nn.Module):
|
| 59 |
"""Dual-branch DINOv3 backbone with explicit partial unfreezing.
|
| 60 |
|
|
@@ -88,30 +245,21 @@ class DINOv3ViTConvNeXtBackbone(nn.Module):
|
|
| 88 |
)
|
| 89 |
self.vit_channels = int(getattr(self.vit, "embed_dim"))
|
| 90 |
self.convnext_channels = int(getattr(self.convnext, "embed_dim"))
|
|
|
|
| 91 |
self.out_channels = out_channels
|
| 92 |
|
| 93 |
-
self.
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
nn.Conv2d(self.convnext_channels, out_channels, kernel_size=1, bias=False),
|
| 100 |
-
nn.BatchNorm2d(out_channels),
|
| 101 |
-
nn.GELU(),
|
| 102 |
-
)
|
| 103 |
-
self.fusion = nn.Sequential(
|
| 104 |
-
nn.Conv2d(out_channels * 2, out_channels, kernel_size=3, padding=1, bias=False),
|
| 105 |
-
nn.BatchNorm2d(out_channels),
|
| 106 |
-
nn.GELU(),
|
| 107 |
)
|
| 108 |
|
| 109 |
self.apply_finetune_policy(vit_unfrozen_blocks, convnext_unfrozen_stages)
|
| 110 |
|
| 111 |
def apply_finetune_policy(self, vit_unfrozen_blocks: int = 2, convnext_unfrozen_stages: int = 2) -> None:
|
| 112 |
self.requires_grad_(False)
|
| 113 |
-
self.vit_proj.requires_grad_(True)
|
| 114 |
-
self.convnext_proj.requires_grad_(True)
|
| 115 |
self.fusion.requires_grad_(True)
|
| 116 |
self._unfreeze_vit_tail(vit_unfrozen_blocks)
|
| 117 |
self._unfreeze_convnext_tail(convnext_unfrozen_stages)
|
|
@@ -148,15 +296,15 @@ class DINOv3ViTConvNeXtBackbone(nn.Module):
|
|
| 148 |
|
| 149 |
def forward(self, x: Tensor) -> dict[str, Tensor]:
|
| 150 |
vit_map = self._vit_feature_map(x)
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
vit_feature = self.vit_proj(vit_map)
|
| 154 |
-
convnext_feature = self.convnext_proj(convnext_map)
|
| 155 |
-
fused = self.fusion(torch.cat([vit_feature, convnext_feature], dim=1))
|
| 156 |
return {
|
| 157 |
-
"fused": fused,
|
| 158 |
-
"
|
| 159 |
-
"
|
|
|
|
|
|
|
|
|
|
| 160 |
}
|
| 161 |
|
| 162 |
def _vit_feature_map(self, x: Tensor) -> Tensor:
|
|
@@ -166,12 +314,14 @@ class DINOv3ViTConvNeXtBackbone(nn.Module):
|
|
| 166 |
w = x.shape[-1] // int(getattr(self.vit, "patch_size"))
|
| 167 |
return patch_tokens.transpose(1, 2).reshape(x.shape[0], self.vit_channels, h, w)
|
| 168 |
|
| 169 |
-
def
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
| 175 |
|
| 176 |
def trainable_summary(self) -> TrainableBackboneSummary:
|
| 177 |
trainable_names = [name for name, param in self.named_parameters() if param.requires_grad]
|
|
|
|
| 55 |
param.requires_grad = trainable
|
| 56 |
|
| 57 |
|
| 58 |
+
class CrossScaleAttention(nn.Module):
|
| 59 |
+
"""Use ViT tokens as queries to align one ConvNeXt feature scale."""
|
| 60 |
+
|
| 61 |
+
def __init__(self, dim: int, num_heads: int = 8, mlp_ratio: int = 4) -> None:
|
| 62 |
+
super().__init__()
|
| 63 |
+
self.q_norm = nn.LayerNorm(dim)
|
| 64 |
+
self.kv_norm = nn.LayerNorm(dim)
|
| 65 |
+
self.attn = nn.MultiheadAttention(embed_dim=dim, num_heads=num_heads, batch_first=True)
|
| 66 |
+
self.norm = nn.LayerNorm(dim)
|
| 67 |
+
self.ffn = nn.Sequential(
|
| 68 |
+
nn.Linear(dim, dim * mlp_ratio),
|
| 69 |
+
nn.GELU(),
|
| 70 |
+
nn.Linear(dim * mlp_ratio, dim),
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
def forward(self, vit_tokens: Tensor, conv_tokens: Tensor) -> Tensor:
|
| 74 |
+
aligned, _ = self.attn(
|
| 75 |
+
query=self.q_norm(vit_tokens),
|
| 76 |
+
key=self.kv_norm(conv_tokens),
|
| 77 |
+
value=self.kv_norm(conv_tokens),
|
| 78 |
+
need_weights=False,
|
| 79 |
+
)
|
| 80 |
+
x = vit_tokens + aligned
|
| 81 |
+
return x + self.ffn(self.norm(x))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class CoastStyleMultiScaleFusion(nn.Module):
|
| 85 |
+
"""CoastGPT-inspired heterogeneous multi-scale fusion for dense heads."""
|
| 86 |
+
|
| 87 |
+
def __init__(
|
| 88 |
+
self,
|
| 89 |
+
vit_dim: int,
|
| 90 |
+
convnext_dims: tuple[int, int, int, int],
|
| 91 |
+
out_channels: int = 256,
|
| 92 |
+
semantic_grid_size: tuple[int, int] | None = None,
|
| 93 |
+
detail_grid_size: tuple[int, int] = (28, 28),
|
| 94 |
+
num_heads: int = 8,
|
| 95 |
+
) -> None:
|
| 96 |
+
super().__init__()
|
| 97 |
+
self.vit_dim = int(vit_dim)
|
| 98 |
+
self.out_channels = int(out_channels)
|
| 99 |
+
self.semantic_grid_size = semantic_grid_size
|
| 100 |
+
self.detail_grid_size = detail_grid_size
|
| 101 |
+
|
| 102 |
+
c4_dim, c8_dim, c16_dim, c32_dim = [int(v) for v in convnext_dims]
|
| 103 |
+
self.c4_proj = nn.Sequential(nn.Linear(c4_dim, vit_dim), nn.LayerNorm(vit_dim))
|
| 104 |
+
self.c16_proj = nn.Sequential(nn.Linear(c16_dim, vit_dim), nn.LayerNorm(vit_dim))
|
| 105 |
+
self.c32_proj = nn.Sequential(nn.Linear(c32_dim, vit_dim), nn.LayerNorm(vit_dim))
|
| 106 |
+
self.c4_attn = CrossScaleAttention(vit_dim, num_heads=num_heads)
|
| 107 |
+
self.c16_attn = CrossScaleAttention(vit_dim, num_heads=num_heads)
|
| 108 |
+
self.c32_attn = CrossScaleAttention(vit_dim, num_heads=num_heads)
|
| 109 |
+
self.semantic_logits = nn.Parameter(torch.zeros(4))
|
| 110 |
+
self.semantic_compress = nn.Sequential(
|
| 111 |
+
nn.Linear(vit_dim * 4, vit_dim),
|
| 112 |
+
nn.LayerNorm(vit_dim),
|
| 113 |
+
nn.GELU(),
|
| 114 |
+
nn.Linear(vit_dim, out_channels),
|
| 115 |
+
nn.LayerNorm(out_channels),
|
| 116 |
+
)
|
| 117 |
+
self.detail_proj = nn.Sequential(
|
| 118 |
+
nn.Linear(c8_dim, vit_dim),
|
| 119 |
+
nn.LayerNorm(vit_dim),
|
| 120 |
+
nn.GELU(),
|
| 121 |
+
nn.Linear(vit_dim, out_channels),
|
| 122 |
+
nn.LayerNorm(out_channels),
|
| 123 |
+
)
|
| 124 |
+
self.detail_to_semantic = nn.Sequential(
|
| 125 |
+
nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1, groups=out_channels, bias=False),
|
| 126 |
+
nn.Conv2d(out_channels, out_channels, kernel_size=1, bias=False),
|
| 127 |
+
nn.GroupNorm(1, out_channels),
|
| 128 |
+
nn.GELU(),
|
| 129 |
+
)
|
| 130 |
+
self.fusion_gate = nn.Sequential(
|
| 131 |
+
nn.Conv2d(out_channels * 2, out_channels, kernel_size=1),
|
| 132 |
+
nn.GELU(),
|
| 133 |
+
nn.Conv2d(out_channels, out_channels, kernel_size=1),
|
| 134 |
+
nn.Sigmoid(),
|
| 135 |
+
)
|
| 136 |
+
self.output_smooth = nn.Sequential(
|
| 137 |
+
nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1, bias=False),
|
| 138 |
+
nn.GroupNorm(1, out_channels),
|
| 139 |
+
nn.GELU(),
|
| 140 |
+
)
|
| 141 |
+
self._last_fusion_weights: Tensor | None = None
|
| 142 |
+
|
| 143 |
+
@staticmethod
|
| 144 |
+
def _add_2d_sincos_pos_embed(tokens: Tensor, h: int, w: int) -> Tensor:
|
| 145 |
+
device, dtype = tokens.device, tokens.dtype
|
| 146 |
+
channels = tokens.shape[-1]
|
| 147 |
+
quarter = max(channels // 4, 1)
|
| 148 |
+
yy, xx = torch.meshgrid(
|
| 149 |
+
torch.arange(h, device=device),
|
| 150 |
+
torch.arange(w, device=device),
|
| 151 |
+
indexing="ij",
|
| 152 |
+
)
|
| 153 |
+
omega = torch.arange(quarter, device=device, dtype=dtype)
|
| 154 |
+
omega = 1.0 / (10000 ** (omega / quarter))
|
| 155 |
+
yy = yy.reshape(-1, 1).to(dtype) * omega
|
| 156 |
+
xx = xx.reshape(-1, 1).to(dtype) * omega
|
| 157 |
+
pos = torch.cat([torch.sin(yy), torch.cos(yy), torch.sin(xx), torch.cos(xx)], dim=1)
|
| 158 |
+
if pos.shape[1] < channels:
|
| 159 |
+
pad = torch.zeros(pos.shape[0], channels - pos.shape[1], device=device, dtype=dtype)
|
| 160 |
+
pos = torch.cat([pos, pad], dim=1)
|
| 161 |
+
return tokens + pos[:, :channels].unsqueeze(0)
|
| 162 |
+
|
| 163 |
+
@classmethod
|
| 164 |
+
def _feature_to_tokens(cls, feat: Tensor, target_size: tuple[int, int] | None = None) -> Tensor:
|
| 165 |
+
if target_size is not None and feat.shape[-2:] != target_size:
|
| 166 |
+
feat = F.interpolate(feat, size=target_size, mode="bilinear", align_corners=False)
|
| 167 |
+
_, _, h, w = feat.shape
|
| 168 |
+
tokens = feat.flatten(2).transpose(1, 2).contiguous()
|
| 169 |
+
return cls._add_2d_sincos_pos_embed(tokens, h, w)
|
| 170 |
+
|
| 171 |
+
@staticmethod
|
| 172 |
+
def _tokens_to_map(tokens: Tensor, h: int, w: int) -> Tensor:
|
| 173 |
+
return tokens.transpose(1, 2).reshape(tokens.shape[0], tokens.shape[-1], h, w).contiguous()
|
| 174 |
+
|
| 175 |
+
def forward(self, vit_map: Tensor, convnext_feats: tuple[Tensor, Tensor, Tensor, Tensor]) -> dict[str, Tensor]:
|
| 176 |
+
c4, c8, c16, c32 = convnext_feats
|
| 177 |
+
if self.semantic_grid_size is not None and vit_map.shape[-2:] != self.semantic_grid_size:
|
| 178 |
+
vit_map = F.interpolate(vit_map, size=self.semantic_grid_size, mode="bilinear", align_corners=False)
|
| 179 |
+
h, w = vit_map.shape[-2:]
|
| 180 |
+
vit_tokens = self._feature_to_tokens(vit_map)
|
| 181 |
+
|
| 182 |
+
f4 = self.c4_attn(vit_tokens, self.c4_proj(self._feature_to_tokens(c4)))
|
| 183 |
+
f16 = self.c16_attn(vit_tokens, self.c16_proj(self._feature_to_tokens(c16)))
|
| 184 |
+
f32 = self.c32_attn(vit_tokens, self.c32_proj(self._feature_to_tokens(c32)))
|
| 185 |
+
|
| 186 |
+
weights = torch.softmax(self.semantic_logits, dim=0)
|
| 187 |
+
semantic_tokens = torch.cat(
|
| 188 |
+
[
|
| 189 |
+
vit_tokens * weights[0],
|
| 190 |
+
f4 * weights[1],
|
| 191 |
+
f16 * weights[2],
|
| 192 |
+
f32 * weights[3],
|
| 193 |
+
],
|
| 194 |
+
dim=-1,
|
| 195 |
+
)
|
| 196 |
+
semantic_tokens = self.semantic_compress(semantic_tokens)
|
| 197 |
+
semantic = self._tokens_to_map(semantic_tokens, h, w)
|
| 198 |
+
|
| 199 |
+
detail_tokens = self.detail_proj(self._feature_to_tokens(c8, target_size=self.detail_grid_size))
|
| 200 |
+
detail = self._tokens_to_map(detail_tokens, self.detail_grid_size[0], self.detail_grid_size[1])
|
| 201 |
+
detail_down = F.interpolate(detail, size=semantic.shape[-2:], mode="bilinear", align_corners=False)
|
| 202 |
+
detail_down = self.detail_to_semantic(detail_down)
|
| 203 |
+
|
| 204 |
+
gate = self.fusion_gate(torch.cat([semantic, detail_down], dim=1))
|
| 205 |
+
fused = self.output_smooth(semantic + gate * detail_down)
|
| 206 |
+
self._last_fusion_weights = weights.detach()
|
| 207 |
+
return {
|
| 208 |
+
"fused": fused,
|
| 209 |
+
"semantic": semantic,
|
| 210 |
+
"detail": detail,
|
| 211 |
+
"fusion_weights": weights,
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
|
| 215 |
class DINOv3ViTConvNeXtBackbone(nn.Module):
|
| 216 |
"""Dual-branch DINOv3 backbone with explicit partial unfreezing.
|
| 217 |
|
|
|
|
| 245 |
)
|
| 246 |
self.vit_channels = int(getattr(self.vit, "embed_dim"))
|
| 247 |
self.convnext_channels = int(getattr(self.convnext, "embed_dim"))
|
| 248 |
+
self.convnext_dims = tuple(int(v) for v in getattr(self.convnext, "embed_dims"))
|
| 249 |
self.out_channels = out_channels
|
| 250 |
|
| 251 |
+
self.fusion = CoastStyleMultiScaleFusion(
|
| 252 |
+
vit_dim=self.vit_channels,
|
| 253 |
+
convnext_dims=self.convnext_dims,
|
| 254 |
+
out_channels=out_channels,
|
| 255 |
+
semantic_grid_size=None,
|
| 256 |
+
detail_grid_size=(28, 28),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
)
|
| 258 |
|
| 259 |
self.apply_finetune_policy(vit_unfrozen_blocks, convnext_unfrozen_stages)
|
| 260 |
|
| 261 |
def apply_finetune_policy(self, vit_unfrozen_blocks: int = 2, convnext_unfrozen_stages: int = 2) -> None:
|
| 262 |
self.requires_grad_(False)
|
|
|
|
|
|
|
| 263 |
self.fusion.requires_grad_(True)
|
| 264 |
self._unfreeze_vit_tail(vit_unfrozen_blocks)
|
| 265 |
self._unfreeze_convnext_tail(convnext_unfrozen_stages)
|
|
|
|
| 296 |
|
| 297 |
def forward(self, x: Tensor) -> dict[str, Tensor]:
|
| 298 |
vit_map = self._vit_feature_map(x)
|
| 299 |
+
convnext_feats = self._convnext_pyramid(x)
|
| 300 |
+
fused_outputs = self.fusion(vit_map, convnext_feats)
|
|
|
|
|
|
|
|
|
|
| 301 |
return {
|
| 302 |
+
"fused": fused_outputs["fused"],
|
| 303 |
+
"semantic": fused_outputs["semantic"],
|
| 304 |
+
"detail": fused_outputs["detail"],
|
| 305 |
+
"vit": vit_map,
|
| 306 |
+
"convnext_pyramid": convnext_feats,
|
| 307 |
+
"fusion_weights": fused_outputs["fusion_weights"],
|
| 308 |
}
|
| 309 |
|
| 310 |
def _vit_feature_map(self, x: Tensor) -> Tensor:
|
|
|
|
| 314 |
w = x.shape[-1] // int(getattr(self.vit, "patch_size"))
|
| 315 |
return patch_tokens.transpose(1, 2).reshape(x.shape[0], self.vit_channels, h, w)
|
| 316 |
|
| 317 |
+
def _convnext_pyramid(self, x: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]:
|
| 318 |
+
feats = []
|
| 319 |
+
y = x
|
| 320 |
+
for downsample, stage in zip(self.convnext.downsample_layers, self.convnext.stages):
|
| 321 |
+
y = downsample(y)
|
| 322 |
+
y = stage(y)
|
| 323 |
+
feats.append(y)
|
| 324 |
+
return tuple(feats[:4])
|
| 325 |
|
| 326 |
def trainable_summary(self) -> TrainableBackboneSummary:
|
| 327 |
trainable_names = [name for name, param in self.named_parameters() if param.requires_grad]
|