| """ |
| Cross-modal alignment for satellite imagery retrieval. |
| |
| Implements multiple approaches: |
| 1. Modality-specific projection heads |
| 2. Contrastive cross-modal loss |
| 3. Wavelength-aware encoding |
| 4. Domain adaptation |
| """ |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from typing import Dict, List, Optional, Tuple |
| from dataclasses import dataclass |
|
|
|
|
| @dataclass |
| class CrossModalConfig: |
| """Configuration for cross-modal alignment.""" |
| embed_dim: int = 768 |
| projection_dim: int = 256 |
| modalities: List[str] = None |
| temperature: float = 0.07 |
| use_wavelength_encoding: bool = True |
| use_domain_adaptation: bool = True |
| |
| def __post_init__(self): |
| if self.modalities is None: |
| self.modalities = ["optical", "sar", "multispectral"] |
|
|
|
|
| class ModalityProjectionHead(nn.Module): |
| """Projection head for a single modality.""" |
| |
| def __init__(self, input_dim: int, output_dim: int): |
| super().__init__() |
| self.projection = nn.Sequential( |
| nn.Linear(input_dim, input_dim), |
| nn.GELU(), |
| nn.Linear(input_dim, output_dim), |
| ) |
| |
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| return F.normalize(self.projection(x), dim=-1) |
|
|
|
|
| class WavelengthEncoder(nn.Module): |
| """Encode wavelength information for each modality.""" |
| |
| def __init__(self, num_channels: int, output_dim: int): |
| super().__init__() |
| self.encoder = nn.Sequential( |
| nn.Linear(num_channels, output_dim), |
| nn.GELU(), |
| nn.Linear(output_dim, output_dim), |
| ) |
| |
| def forward(self, wavelengths: torch.Tensor) -> torch.Tensor: |
| return self.encoder(wavelengths) |
|
|
|
|
| class CrossModalAligner(nn.Module): |
| """ |
| Cross-modal alignment using modality-specific projections. |
| |
| Based on CLOSP and DOFA-CLIP approaches: |
| - Each modality has its own projection head |
| - Wavelength encoding for channel-aware processing |
| - Contrastive loss for alignment |
| """ |
| |
| def __init__(self, config: CrossModalConfig): |
| super().__init__() |
| self.config = config |
| |
| |
| self.projection_heads = nn.ModuleDict({ |
| mod: ModalityProjectionHead(config.embed_dim, config.projection_dim) |
| for mod in config.modalities |
| }) |
| |
| |
| if config.use_wavelength_encoding: |
| self.wavelength_encoders = nn.ModuleDict({ |
| mod: WavelengthEncoder(3, config.projection_dim) |
| for mod in config.modalities |
| }) |
| |
| |
| if config.use_domain_adaptation: |
| self.domain_adaptor = nn.Sequential( |
| nn.Linear(config.projection_dim, config.projection_dim), |
| nn.GELU(), |
| nn.Linear(config.projection_dim, config.projection_dim), |
| ) |
| |
| |
| self.logit_scale = nn.Parameter(torch.ones([]) * torch.log(torch.tensor(1.0 / config.temperature))) |
| |
| def project(self, features: torch.Tensor, modality: str) -> torch.Tensor: |
| """Project features using modality-specific head.""" |
| return self.projection_heads[modality](features) |
| |
| def align_with_wavelength( |
| self, |
| features: torch.Tensor, |
| modality: str, |
| wavelengths: Optional[torch.Tensor] = None |
| ) -> torch.Tensor: |
| """Align features using wavelength encoding.""" |
| if not self.config.use_wavelength_encoding or wavelengths is None: |
| return self.project(features, modality) |
| |
| |
| wave_emb = self.wavelength_encoders[modality](wavelengths) |
| |
| |
| combined = features + wave_emb |
| return self.projection_heads[modality](combined) |
| |
| def contrastive_loss( |
| self, |
| features_a: torch.Tensor, |
| features_b: torch.Tensor, |
| temperature: Optional[float] = None |
| ) -> torch.Tensor: |
| """Compute contrastive loss between two sets of features.""" |
| if temperature is None: |
| temperature = self.config.temperature |
| |
| |
| features_a = F.normalize(features_a, dim=-1) |
| features_b = F.normalize(features_b, dim=-1) |
| |
| |
| logit_scale = self.logit_scale.exp() |
| logits = logit_scale * features_a @ features_b.t() |
| |
| |
| labels = torch.arange(len(features_a), device=features_a.device) |
| |
| |
| loss_a = F.cross_entropy(logits, labels) |
| loss_b = F.cross_entropy(logits.t(), labels) |
| |
| return (loss_a + loss_b) / 2 |
| |
| def cross_modal_retrieve( |
| self, |
| query_features: torch.Tensor, |
| query_modality: str, |
| gallery_features: Dict[str, torch.Tensor], |
| k: int = 5 |
| ) -> Tuple[torch.Tensor, torch.Tensor]: |
| """ |
| Cross-modal retrieval. |
| |
| Args: |
| query_features: Query features from source modality |
| query_modality: Modality of query |
| gallery_features: Dict of gallery features per modality |
| k: Number of results |
| |
| Returns: |
| (indices, scores) for top-k results |
| """ |
| |
| query_proj = self.project(query_features, query_modality) |
| |
| all_scores = [] |
| all_indices = [] |
| |
| |
| offset = 0 |
| for mod, features in gallery_features.items(): |
| |
| gallery_proj = self.project(features, mod) |
| |
| |
| scores = query_proj @ gallery_proj.t() |
| all_scores.append(scores) |
| all_indices.append(torch.arange(len(features), device=features.device) + offset) |
| offset += len(features) |
| |
| |
| all_scores = torch.cat(all_scores, dim=-1) |
| all_indices = torch.cat(all_indices, dim=-1) |
| |
| |
| topk_scores, topk_idx = all_scores.topk(k, dim=-1) |
| topk_indices = all_indices[topk_idx] |
| |
| return topk_indices, topk_scores |
|
|
|
|
| class ContrastiveCrossModalLoss(nn.Module): |
| """ |
| Contrastive loss for cross-modal alignment. |
| |
| Based on CLOSP approach: align SAR and optical via shared text anchor. |
| """ |
| |
| def __init__(self, temperature: float = 0.07): |
| super().__init__() |
| self.temperature = temperature |
| self.logit_scale = nn.Parameter(torch.ones([]) * torch.log(torch.tensor(1.0 / temperature))) |
| |
| def forward( |
| self, |
| features_a: torch.Tensor, |
| features_b: torch.Tensor, |
| features_text: Optional[torch.Tensor] = None |
| ) -> torch.Tensor: |
| """ |
| Compute cross-modal contrastive loss. |
| |
| Args: |
| features_a: Features from modality A (e.g., optical) |
| features_b: Features from modality B (e.g., SAR) |
| features_text: Optional text features for triple alignment |
| |
| Returns: |
| Loss value |
| """ |
| features_a = F.normalize(features_a, dim=-1) |
| features_b = F.normalize(features_b, dim=-1) |
| |
| logit_scale = self.logit_scale.exp() |
| |
| |
| logits_ab = logit_scale * features_a @ features_b.t() |
| logits_ba = logits_ab.t() |
| |
| labels = torch.arange(len(features_a), device=features_a.device) |
| |
| loss_a2b = F.cross_entropy(logits_ab, labels) |
| loss_b2a = F.cross_entropy(logits_ba, labels) |
| |
| loss = (loss_a2b + loss_b2a) / 2 |
| |
| |
| if features_text is not None: |
| features_text = F.normalize(features_text, dim=-1) |
| |
| logits_t2a = logit_scale * features_text @ features_a.t() |
| logits_t2b = logit_scale * features_text @ features_b.t() |
| |
| loss_t2a = F.cross_entropy(logits_t2a, labels) |
| loss_t2b = F.cross_entropy(logits_t2b, labels) |
| |
| loss = loss + (loss_t2a + loss_t2b) / 2 |
| |
| return loss |
|
|
|
|
| class DomainAdaptationLayer(nn.Module): |
| """ |
| Domain adaptation for bridging modality gaps. |
| |
| Based on SARCLIP approach: transfer knowledge from optical to SAR. |
| """ |
| |
| def __init__(self, embed_dim: int, num_modalities: int = 3): |
| super().__init__() |
| |
| |
| self.adapters = nn.ModuleList([ |
| nn.Sequential( |
| nn.Linear(embed_dim, embed_dim), |
| nn.GELU(), |
| nn.Linear(embed_dim, embed_dim), |
| ) |
| for _ in range(num_modalities) |
| ]) |
| |
| |
| self.shared_adapter = nn.Sequential( |
| nn.Linear(embed_dim, embed_dim), |
| nn.GELU(), |
| nn.Linear(embed_dim, embed_dim), |
| ) |
| |
| def forward( |
| self, |
| features: torch.Tensor, |
| modality_idx: int |
| ) -> torch.Tensor: |
| """ |
| Apply domain adaptation. |
| |
| Args: |
| features: Input features |
| modality_idx: Index of the modality |
| |
| Returns: |
| Adapted features |
| """ |
| |
| adapted = self.adapters[modality_idx](features) |
| |
| |
| shared = self.shared_adapter(features) |
| |
| |
| return adapted + shared |
|
|
|
|
| |
| if __name__ == "__main__": |
| print("Testing Cross-Modal Alignment...") |
| |
| config = CrossModalConfig() |
| aligner = CrossModalAligner(config) |
| |
| |
| features = torch.randn(8, 768) |
| optical_proj = aligner.project(features, "optical") |
| sar_proj = aligner.project(features, "sar") |
| |
| print(f"Optical projection shape: {optical_proj.shape}") |
| print(f"SAR projection shape: {sar_proj.shape}") |
| |
| |
| loss = aligner.contrastive_loss(optical_proj, sar_proj) |
| print(f"Contrastive loss: {loss.item():.4f}") |
| |
| |
| gallery_features = { |
| "optical": torch.randn(100, 768), |
| "sar": torch.randn(100, 768), |
| "multispectral": torch.randn(100, 768), |
| } |
| |
| query = torch.randn(1, 768) |
| indices, scores = aligner.cross_modal_retrieve(query, "optical", gallery_features, k=5) |
| |
| print(f"Retrieved indices: {indices}") |
| print(f"Retrieved scores: {scores}") |
| |
| print("\nCross-Modal Alignment test passed!") |
|
|