| """ |
| Multiscale feature extraction for satellite imagery. |
| |
| Combines patch-level and global features for richer representations. |
| Uses DINOv2 for patch features and CLIP for global alignment. |
| """ |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from typing import Optional, Tuple, Dict, Any |
| from dataclasses import dataclass |
|
|
|
|
| @dataclass |
| class MultiscaleFeatures: |
| """Container for multiscale features.""" |
| global_feature: torch.Tensor |
| patch_features: torch.Tensor |
| patch_grid: Tuple[int, int] |
| combined: torch.Tensor |
|
|
|
|
| class PatchAggregator(nn.Module): |
| """ |
| Aggregates patch features into a single representation. |
| |
| Supports multiple aggregation strategies: |
| - mean: Average pooling |
| - max: Max pooling |
| - attention: Learnable attention pooling |
| """ |
| |
| def __init__(self, patch_dim: int, strategy: str = "attention"): |
| super().__init__() |
| |
| self.strategy = strategy |
| |
| if strategy == "attention": |
| self.attention = nn.Sequential( |
| nn.Linear(patch_dim, patch_dim // 4), |
| nn.Tanh(), |
| nn.Linear(patch_dim // 4, 1), |
| ) |
| elif strategy == "cls": |
| self.cls_token = nn.Parameter(torch.randn(1, 1, patch_dim)) |
| |
| def forward(self, patch_features: torch.Tensor) -> torch.Tensor: |
| """ |
| Aggregate patch features. |
| |
| Args: |
| patch_features: (B, num_patches, patch_dim) |
| |
| Returns: |
| Aggregated feature (B, patch_dim) |
| """ |
| if self.strategy == "mean": |
| return patch_features.mean(dim=1) |
| |
| elif self.strategy == "max": |
| return patch_features.max(dim=1)[0] |
| |
| elif self.strategy == "attention": |
| |
| attn_weights = self.attention(patch_features) |
| attn_weights = F.softmax(attn_weights, dim=1) |
| |
| return (patch_features * attn_weights).sum(dim=1) |
| |
| elif self.strategy == "cls": |
| B = patch_features.shape[0] |
| cls_tokens = self.cls_token.expand(B, -1, -1) |
| |
| x = torch.cat([cls_tokens, patch_features], dim=1) |
| return x[:, 0] |
| |
| else: |
| raise ValueError(f"Unknown strategy: {self.strategy}") |
|
|
|
|
| class MultiscaleExtractor(nn.Module): |
| """ |
| Extracts features at multiple scales from satellite imagery. |
| |
| Combines: |
| - Global features from CLIP (semantic alignment) |
| - Patch features from DINOv2 (spatial details) |
| - Cross-scale attention for feature fusion |
| """ |
| |
| def __init__( |
| self, |
| clip_model: nn.Module, |
| dinov2_model: Optional[nn.Module] = None, |
| embed_dim: int = 768, |
| patch_dim: int = 768, |
| fusion_dim: int = 512, |
| use_cross_attention: bool = True |
| ): |
| super().__init__() |
| |
| self.clip_model = clip_model |
| self.dinov2_model = dinov2_model |
| |
| self.embed_dim = embed_dim |
| self.patch_dim = patch_dim |
| self.fusion_dim = fusion_dim |
| |
| |
| self.patch_aggregator = PatchAggregator(patch_dim, strategy="attention") |
| |
| |
| self.use_cross_attention = use_cross_attention |
| if use_cross_attention: |
| self.cross_attn = nn.MultiheadAttention( |
| embed_dim=embed_dim, |
| num_heads=8, |
| dropout=0.1, |
| batch_first=True |
| ) |
| self.fusion_proj = nn.Linear(embed_dim + patch_dim, fusion_dim) |
| else: |
| |
| self.fusion_proj = nn.Linear(embed_dim + patch_dim, fusion_dim) |
| |
| |
| self.layer_norm = nn.LayerNorm(fusion_dim) |
| |
| @torch.no_grad() |
| def extract_clip_global(self, x: torch.Tensor) -> torch.Tensor: |
| """Extract global features from CLIP.""" |
| |
| if hasattr(self.clip_model, 'vision_model'): |
| output = self.clip_model.vision_model(pixel_values=x) |
| pooled = output.last_hidden_state[:, 0, :] |
| global_feat = self.clip_model.visual_projection(pooled) |
| else: |
| |
| global_feat = self.clip_model(x) |
| |
| return F.normalize(global_feat, dim=-1) |
| |
| @torch.no_grad() |
| def extract_dinov2_patches(self, x: torch.Tensor) -> torch.Tensor: |
| """Extract patch features from DINOv2.""" |
| if self.dinov2_model is None: |
| |
| B = x.shape[0] |
| num_patches = 196 |
| return torch.randn(B, num_patches, self.patch_dim, device=x.device) |
| |
| |
| output = self.dinov2_model(x) |
| |
| |
| if hasattr(output, 'last_hidden_state'): |
| patch_features = output.last_hidden_state[:, 1:] |
| elif isinstance(output, torch.Tensor): |
| patch_features = output[:, 1:] |
| else: |
| |
| patch_features = output |
| |
| return patch_features |
| |
| def fuse_features( |
| self, |
| global_feat: torch.Tensor, |
| patch_feat: torch.Tensor |
| ) -> torch.Tensor: |
| """ |
| Fuse global and patch features. |
| |
| Args: |
| global_feat: (B, embed_dim) |
| patch_feat: (B, patch_dim) |
| |
| Returns: |
| Fused feature (B, fusion_dim) |
| """ |
| if self.use_cross_attention: |
| |
| B = global_feat.shape[0] |
| global_seq = global_feat.unsqueeze(1) |
| patch_seq = patch_feat.unsqueeze(1) |
| |
| |
| attn_out, _ = self.cross_attn( |
| query=global_seq, |
| key=patch_seq, |
| value=patch_seq |
| ) |
| attn_out = attn_out.squeeze(1) |
| |
| |
| combined = torch.cat([attn_out, patch_feat], dim=-1) |
| else: |
| combined = torch.cat([global_feat, patch_feat], dim=-1) |
| |
| |
| fused = self.fusion_proj(combined) |
| fused = self.layer_norm(fused) |
| |
| return F.normalize(fused, dim=-1) |
| |
| def forward( |
| self, |
| x: torch.Tensor, |
| return_separate: bool = False |
| ) -> MultiscaleFeatures: |
| """ |
| Extract multiscale features. |
| |
| Args: |
| x: Input image tensor (B, C, H, W) |
| return_separate: If True, return separate features instead of fused |
| |
| Returns: |
| MultiscaleFeatures container |
| """ |
| |
| global_feat = self.extract_clip_global(x) |
| patch_feat = self.extract_dinov2_patches(x) |
| |
| |
| patch_agg = self.patch_aggregator(patch_feat) |
| |
| |
| B = x.shape[0] |
| num_patches = patch_feat.shape[1] |
| patch_grid = (int(num_patches ** 0.5), int(num_patches ** 0.5)) |
| |
| |
| combined = self.fuse_features(global_feat, patch_agg) |
| |
| return MultiscaleFeatures( |
| global_feature=global_feat.squeeze(0) if B == 1 else global_feat, |
| patch_features=patch_feat.squeeze(0) if B == 1 else patch_feat, |
| patch_grid=patch_grid, |
| combined=combined.squeeze(0) if B == 1 else combined |
| ) |
|
|
|
|
| class MultiscaleRetrievalHead(nn.Module): |
| """ |
| Retrieval head that combines multiscale features. |
| |
| Projects fused features to the final embedding space |
| used for similarity search. |
| """ |
| |
| def __init__( |
| self, |
| input_dim: int, |
| output_dim: int = 768, |
| hidden_dim: int = 256 |
| ): |
| super().__init__() |
| |
| self.projection = nn.Sequential( |
| nn.Linear(input_dim, hidden_dim), |
| nn.GELU(), |
| nn.Dropout(0.1), |
| nn.Linear(hidden_dim, output_dim), |
| ) |
| |
| def forward(self, features: MultiscaleFeatures) -> torch.Tensor: |
| """ |
| Project multiscale features to retrieval space. |
| |
| Args: |
| features: MultiscaleFeatures container |
| |
| Returns: |
| Projected embedding (output_dim,) |
| """ |
| return self.projection(features.combined) |
|
|
|
|
| |
| def create_multiscale_extractor( |
| clip_model: nn.Module, |
| dinov2_model: Optional[nn.Module] = None, |
| embed_dim: int = 768, |
| fusion_dim: int = 512 |
| ) -> MultiscaleExtractor: |
| """ |
| Create a multiscale feature extractor. |
| |
| Args: |
| clip_model: CLIP vision model for global features |
| dinov2_model: Optional DINOv2 model for patch features |
| embed_dim: CLIP embedding dimension |
| fusion_dim: Output fusion dimension |
| |
| Returns: |
| MultiscaleExtractor instance |
| """ |
| return MultiscaleExtractor( |
| clip_model=clip_model, |
| dinov2_model=dinov2_model, |
| embed_dim=embed_dim, |
| patch_dim=768, |
| fusion_dim=fusion_dim, |
| use_cross_attention=True |
| ) |
|
|
|
|
| |
| if __name__ == "__main__": |
| print("Testing MultiscaleExtractor...") |
| |
| |
| class DummyModel(nn.Module): |
| def __init__(self, output_dim=768): |
| super().__init__() |
| self.linear = nn.Linear(3, output_dim) |
| |
| def forward(self, x): |
| B = x.shape[0] |
| return torch.randn(B, 197, 768) |
| |
| dummy_clip = DummyModel(768) |
| dummy_dinov2 = DummyModel(768) |
| |
| extractor = MultiscaleExtractor( |
| clip_model=dummy_clip, |
| dinov2_model=dummy_dinov2, |
| embed_dim=768, |
| patch_dim=768, |
| fusion_dim=512 |
| ) |
| |
| |
| x = torch.randn(1, 3, 224, 224) |
| features = extractor(x) |
| |
| print(f"Global feature shape: {features.global_feature.shape}") |
| print(f"Patch features shape: {features.patch_features.shape}") |
| print(f"Patch grid: {features.patch_grid}") |
| print(f"Combined feature shape: {features.combined.shape}") |
| |
| |
| head = MultiscaleRetrievalHead(input_dim=512, output_dim=768) |
| embedding = head(features) |
| print(f"Final embedding shape: {embedding.shape}") |
| print(f"Embedding norm: {torch.norm(embedding).item():.4f}") |
| |
| print("\nMultiscaleExtractor test passed!") |
|
|