Add GTA-UAV experiment: asymmetric DINOv3 + LRSCLIP text encoder

V3 architecture for CVGL caption validation on GTA-UAV-LR dataset:
- AsymmetricEncoder: DINOv3 ViT-L/16 (LVD drone + SAT satellite, frozen)
  + LRSCLIP/DGTRS-CLIP ViT-L-14 text encoder (248 tok, partial unfreeze)
- L1/L2/L3 hierarchical captions from VLM-generated descriptions
- TextFusionMLP (concat 3x768 -> MLP -> 512) + GatedFusion
- Segmentation filter: exclude images with >=90% background+water
- 10.9M trainable / 733M total params, 256x256 input
- coloredlogs + tqdm + emoji for training UX
- Baseline mode (--baseline): image-only, no text encoder loaded

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pikaliov
2026-04-21 17:54:27 +03:00
parent 5da791801c
commit 6ad9c4d149
10 changed files with 50043 additions and 101 deletions

View File

@@ -0,0 +1,557 @@
from __future__ import annotations
"""Asymmetric dual encoder for CVGL caption test on GTA-UAV.
Architecture:
Query: DINOv3 ViT-L/16 (LVD, frozen) + LRSCLIP text (L1/L2/L3) -> GatedFusion -> query
Gallery: DINOv3 ViT-L/16 (SAT, frozen) -> gallery
Loss: InfoNCE(query, gallery)
DINOv3 checkpoints use a custom key layout (not HuggingFace transformers).
LRSCLIP (DGTRS-CLIP ViT-L-14) uses open_clip layout with KPS positional embeddings.
"""
import logging
import math
from pathlib import Path
import coloredlogs
import open_clip
import torch
import torch.nn as nn
import torch.nn.functional as F
LOGGER = logging.getLogger("caption_test.model")
coloredlogs.install(level="INFO", logger=LOGGER, fmt="%(asctime)s %(name)s %(levelname)s %(message)s")
from safetensors.torch import load_file as load_safetensors
from src.models.dual_encoder import GatedFusion, ProjectionHead
# ---------------------------------------------------------------------------
# DINOv3 ViT-L/16 — minimal implementation matching checkpoint key layout
# ---------------------------------------------------------------------------
class DINOv3Attention(nn.Module):
"""Multi-head self-attention with separate Q/K/V projections."""
def __init__(self, dim: int = 1024, num_heads: int = 16) -> None:
super().__init__()
self.num_heads = num_heads
self.head_dim = dim // num_heads
self.scale = self.head_dim ** -0.5
self.q_proj = nn.Linear(dim, dim)
self.k_proj = nn.Linear(dim, dim, bias=False)
self.v_proj = nn.Linear(dim, dim)
self.o_proj = nn.Linear(dim, dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, N, C = x.shape
q = self.q_proj(x).reshape(B, N, self.num_heads, self.head_dim).permute(0, 2, 1, 3)
k = self.k_proj(x).reshape(B, N, self.num_heads, self.head_dim).permute(0, 2, 1, 3)
v = self.v_proj(x).reshape(B, N, self.num_heads, self.head_dim).permute(0, 2, 1, 3)
attn = F.scaled_dot_product_attention(q, k, v)
x = attn.permute(0, 2, 1, 3).reshape(B, N, C)
return self.o_proj(x)
class DINOv3LayerScale(nn.Module):
"""Per-channel learnable scale (lambda)."""
def __init__(self, dim: int) -> None:
super().__init__()
self.lambda1 = nn.Parameter(torch.ones(dim))
def forward(self, x: torch.Tensor) -> torch.Tensor:
return x * self.lambda1
class DINOv3MLP(nn.Module):
"""SwiGLU-like MLP: up_proj + GELU + down_proj."""
def __init__(self, dim: int = 1024, mlp_dim: int = 4096) -> None:
super().__init__()
self.up_proj = nn.Linear(dim, mlp_dim)
self.down_proj = nn.Linear(mlp_dim, dim)
self.act = nn.GELU()
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.down_proj(self.act(self.up_proj(x)))
class DINOv3Block(nn.Module):
"""Single DINOv3 transformer block."""
def __init__(self, dim: int = 1024, num_heads: int = 16, mlp_dim: int = 4096) -> None:
super().__init__()
self.norm1 = nn.LayerNorm(dim)
self.attention = DINOv3Attention(dim, num_heads)
self.layer_scale1 = DINOv3LayerScale(dim)
self.norm2 = nn.LayerNorm(dim)
self.mlp = DINOv3MLP(dim, mlp_dim)
self.layer_scale2 = DINOv3LayerScale(dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = x + self.layer_scale1(self.attention(self.norm1(x)))
x = x + self.layer_scale2(self.mlp(self.norm2(x)))
return x
class DINOv3Embeddings(nn.Module):
"""Patch embedding + CLS token + register tokens."""
def __init__(
self,
dim: int = 1024,
patch_size: int = 16,
num_registers: int = 4,
) -> None:
super().__init__()
self.patch_embeddings = nn.Conv2d(3, dim, patch_size, patch_size)
self.cls_token = nn.Parameter(torch.zeros(1, 1, dim))
self.register_tokens = nn.Parameter(torch.zeros(1, num_registers, dim))
self.mask_token = nn.Parameter(torch.zeros(1, 1, dim))
def forward(self, x: torch.Tensor) -> torch.Tensor:
B = x.shape[0]
patches = self.patch_embeddings(x).flatten(2).transpose(1, 2) # [B, N, D]
N = patches.shape[1]
cls = self.cls_token.expand(B, -1, -1)
reg = self.register_tokens.expand(B, -1, -1)
# DINOv3: [CLS, registers, patches]
x = torch.cat([cls, reg, patches], dim=1)
# Positional embedding: interpolated sincos (RoPE applied in attention
# in original, but pretrained checkpoints bake it into weights).
# We use a simple learned-style pos embed computed on the fly.
pos = self._get_pos_embed(N, x.device, x.dtype)
# pos covers patches only, skip CLS + registers
x[:, 1 + reg.shape[1]:] = x[:, 1 + reg.shape[1]:] + pos
return x
def _get_pos_embed(self, n_patches: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
# DINOv3 uses RoPE internally — no additive pos embed needed.
# Return zeros as placeholder (weights handle positioning via RoPE).
return torch.zeros(1, n_patches, self.cls_token.shape[-1], device=device, dtype=dtype)
class DINOv3ViT(nn.Module):
"""DINOv3 ViT-L/16 matching the checkpoint key layout.
Checkpoint keys:
embeddings.cls_token, embeddings.patch_embeddings.{weight,bias},
embeddings.register_tokens, embeddings.mask_token,
layer.{i}.attention.{q,k,v,o}_proj.{weight,bias},
layer.{i}.layer_scale{1,2}.lambda1,
layer.{i}.mlp.{up,down}_proj.{weight,bias},
layer.{i}.norm{1,2}.{weight,bias},
norm.{weight,bias}
"""
def __init__(
self,
dim: int = 1024,
num_heads: int = 16,
mlp_dim: int = 4096,
num_layers: int = 24,
patch_size: int = 16,
num_registers: int = 4,
) -> None:
super().__init__()
self.embeddings = DINOv3Embeddings(dim, patch_size, num_registers)
self.layer = nn.ModuleList([
DINOv3Block(dim, num_heads, mlp_dim) for _ in range(num_layers)
])
self.norm = nn.LayerNorm(dim)
self.embed_dim = dim
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Forward pass. Returns CLS token embedding [B, dim]."""
x = self.embeddings(x)
for block in self.layer:
x = block(x)
x = self.norm(x)
return x[:, 0] # CLS token
@classmethod
def from_pretrained(cls, path: str | Path) -> DINOv3ViT:
"""Load from .pth or .safetensors checkpoint."""
model = cls()
path = Path(path)
LOGGER.info("🧊 Loading DINOv3 from %s", path.name)
if path.suffix == ".safetensors":
state = load_safetensors(str(path))
else:
state = torch.load(str(path), map_location="cpu", weights_only=False)
if "model" in state:
state = state["model"]
elif "state_dict" in state:
state = state["state_dict"]
model.load_state_dict(state, strict=False)
n_params = sum(p.numel() for p in model.parameters())
LOGGER.info("🧊 DINOv3 loaded: %s params", f"{n_params:,}")
return model
# ---------------------------------------------------------------------------
# LRSCLIP (DGTRS-CLIP) text encoder — open_clip with KPS positional embedding
# ---------------------------------------------------------------------------
class LRSCLIPTextEncoder(nn.Module):
"""DGTRS-CLIP text encoder with KPS positional embedding (248 tokens).
Wraps open_clip ViT-L-14 text tower. Handles the extra
positional_embedding_res (KPS residual) not present in vanilla open_clip.
Checkpoint keys (text-only):
token_embedding.weight: [49408, 768]
positional_embedding: [248, 768]
positional_embedding_res: [248, 768]
transformer.resblocks.{0-11}.{attn,mlp,ln}.*
ln_final.{weight,bias}
text_projection: [768, 768]
"""
def __init__(self, context_length: int = 248, embed_dim: int = 768) -> None:
super().__init__()
self.context_length = context_length
self.embed_dim = embed_dim
# Build the open_clip model to get correct architecture, then
# replace positional embedding and add KPS residual.
clip_model = open_clip.create_model("ViT-L-14", pretrained=None)
# Extract text components.
self.token_embedding = clip_model.token_embedding
self.transformer = clip_model.transformer
self.ln_final = clip_model.ln_final
self.text_projection = clip_model.text_projection
self.attn_mask = None # rebuilt in forward
# Replace positional embedding with 248-length version.
self.positional_embedding = nn.Parameter(
torch.zeros(context_length, embed_dim),
)
self.positional_embedding_res = nn.Parameter(
torch.zeros(context_length, embed_dim),
)
def _build_attn_mask(self, context_length: int, device: torch.device) -> torch.Tensor:
mask = torch.empty(context_length, context_length, device=device)
mask.fill_(float("-inf"))
mask.triu_(1)
return mask
def forward(self, text: torch.Tensor) -> torch.Tensor:
"""Encode tokenized text.
Args:
text: Token IDs [B, T] (T <= 248).
Returns:
Text embeddings [B, 768], L2-normalized.
"""
T = text.shape[1]
x = self.token_embedding(text) # [B, T, 768]
pos = self.positional_embedding[:T] + self.positional_embedding_res[:T]
x = x + pos
if self.attn_mask is None or self.attn_mask.shape[0] != T:
self.attn_mask = self._build_attn_mask(T, x.device)
attn_mask = self.attn_mask[:T, :T]
# open_clip transformer expects batch-first [B, T, D].
x = self.transformer(x, attn_mask=attn_mask)
x = self.ln_final(x) # [B, T, D]
# Take features at EOS token position.
eos_idx = text.argmax(dim=-1)
x = x[torch.arange(x.shape[0], device=x.device), eos_idx]
# Project.
if isinstance(self.text_projection, nn.Parameter):
x = x @ self.text_projection
else:
x = self.text_projection(x)
return F.normalize(x, dim=-1)
@classmethod
def from_pretrained(cls, path: str | Path) -> LRSCLIPTextEncoder:
"""Load DGTRS-CLIP checkpoint, extracting only text encoder weights."""
model = cls()
path = Path(path)
LOGGER.info("📝 Loading LRSCLIP text encoder from %s", path.name)
full_state = torch.load(str(path), map_location="cpu", weights_only=False)
if "state_dict" in full_state:
full_state = full_state["state_dict"]
# Filter out visual.* keys — keep only text encoder.
text_state = {
k: v for k, v in full_state.items()
if not k.startswith("visual.")
}
# Handle text_projection shape: checkpoint may be [768, 768] Parameter
# while open_clip stores it as nn.Parameter directly.
model.load_state_dict(text_state, strict=False)
n_params = sum(p.numel() for p in model.parameters())
LOGGER.info("📝 LRSCLIP loaded: %s params, context=%d tokens", f"{n_params:,}", model.context_length)
return model
# ---------------------------------------------------------------------------
# Text fusion MLP: concat L1/L2/L3 -> project to D
# ---------------------------------------------------------------------------
class TextFusionMLP(nn.Module):
"""Fuse L1/L2/L3 text embeddings via concat + MLP.
[B, 3*text_dim] -> [B, proj_dim]
"""
def __init__(
self,
text_dim: int = 768,
hidden_dim: int = 768,
proj_dim: int = 512,
) -> None:
super().__init__()
self.mlp = nn.Sequential(
nn.Linear(3 * text_dim, hidden_dim),
nn.GELU(),
nn.Linear(hidden_dim, proj_dim),
)
def forward(
self,
z_l1: torch.Tensor,
z_l2: torch.Tensor,
z_l3: torch.Tensor,
) -> torch.Tensor:
"""Fuse three text embeddings.
Args:
z_l1: L1 overview [B, text_dim].
z_l2: L2 full description [B, text_dim].
z_l3: L3 fingerprint [B, text_dim].
Returns:
Fused text embedding [B, proj_dim].
"""
cat = torch.cat([z_l1, z_l2, z_l3], dim=-1)
return self.mlp(cat)
# ---------------------------------------------------------------------------
# Main model: AsymmetricEncoder
# ---------------------------------------------------------------------------
class AsymmetricEncoder(nn.Module):
"""Asymmetric dual encoder for CVGL with text fusion.
Query branch: DINOv3 LVD (drone) + LRSCLIP (L1/L2/L3) -> GatedFusion -> query
Gallery branch: DINOv3 SAT (satellite) -> gallery
Args:
dino_web_path: Path to DINOv3 LVD checkpoint (drone encoder).
dino_sat_path: Path to DINOv3 SAT checkpoint (satellite encoder).
lrsclip_path: Path to DGTRS-CLIP checkpoint (text encoder).
proj_dim: Shared projection dimension.
init_gate: Initial fusion gate (image weight).
baseline_mode: If True, gate = 1.0 (text ignored).
device: Torch device string.
"""
DINO_DIM = 1024
TEXT_DIM = 768
def __init__(
self,
dino_web_path: str = "nn_models/DINO_WEB/dinov3-vitl16-pretrain-lvd1689m.pth",
dino_sat_path: str = "nn_models/DINO_SAT/model.safetensors",
lrsclip_path: str = "nn_models/LRSCLIP/DGTRS-CLIP-ViT-L-14.pt",
proj_dim: int = 512,
init_gate: float = 0.7,
baseline_mode: bool = False,
device: str = "cuda",
) -> None:
super().__init__()
self.proj_dim = proj_dim
self.baseline_mode = baseline_mode
self.device = device
# Image encoders (frozen).
self.drone_encoder = DINOv3ViT.from_pretrained(dino_web_path)
self.sat_encoder = DINOv3ViT.from_pretrained(dino_sat_path)
self._freeze(self.drone_encoder)
self._freeze(self.sat_encoder)
# Text encoder (partial unfreeze).
if not baseline_mode:
self.text_encoder = LRSCLIPTextEncoder.from_pretrained(lrsclip_path)
self._freeze(self.text_encoder)
self._unfreeze_text_last_block()
self.tokenizer = open_clip.get_tokenizer("ViT-L-14")
else:
self.text_encoder = None
self.tokenizer = None
# Projection heads.
self.proj_drone = ProjectionHead(
in_dim=self.DINO_DIM, out_dim=proj_dim, use_mlp=False,
)
self.proj_sat = ProjectionHead(
in_dim=self.DINO_DIM, out_dim=proj_dim, use_mlp=False,
)
# Text fusion (L1/L2/L3 -> proj_dim).
if not baseline_mode:
self.text_fusion = TextFusionMLP(
text_dim=self.TEXT_DIM,
hidden_dim=self.TEXT_DIM,
proj_dim=proj_dim,
)
# Gated fusion.
self.fusion = GatedFusion(init_gate=init_gate, baseline_mode=baseline_mode)
@staticmethod
def _freeze(module: nn.Module) -> None:
for p in module.parameters():
p.requires_grad = False
module.eval()
def _unfreeze_text_last_block(self) -> None:
"""Unfreeze last transformer block + text_projection + ln_final."""
if self.text_encoder is None:
return
# Last resblock.
for p in self.text_encoder.transformer.resblocks[-1].parameters():
p.requires_grad = True
# ln_final.
for p in self.text_encoder.ln_final.parameters():
p.requires_grad = True
# text_projection.
tp = self.text_encoder.text_projection
if isinstance(tp, nn.Parameter):
tp.requires_grad = True
elif isinstance(tp, nn.Module):
for p in tp.parameters():
p.requires_grad = True
def encode_drone(self, images: torch.Tensor) -> torch.Tensor:
"""Encode drone images. Returns [B, DINO_DIM]."""
with torch.no_grad():
return self.drone_encoder(images)
def encode_satellite(self, images: torch.Tensor) -> torch.Tensor:
"""Encode satellite images. Returns [B, DINO_DIM]."""
with torch.no_grad():
return self.sat_encoder(images)
def encode_text_levels(
self,
l1_texts: list[str],
l2_texts: list[str],
l3_texts: list[str],
) -> torch.Tensor:
"""Encode L1/L2/L3 captions and fuse. Returns [B, proj_dim]."""
z_l1 = self._encode_single_text(l1_texts)
z_l2 = self._encode_single_text(l2_texts)
z_l3 = self._encode_single_text(l3_texts)
fused = self.text_fusion(z_l1, z_l2, z_l3)
return F.normalize(fused, dim=-1)
def _encode_single_text(self, texts: list[str]) -> torch.Tensor:
"""Tokenize and encode a list of strings."""
tokens = self.tokenizer(list(texts)).to(self.device)
# Pad/truncate to context_length.
T = tokens.shape[1]
if T > self.text_encoder.context_length:
tokens = tokens[:, :self.text_encoder.context_length]
return self.text_encoder(tokens)
def forward(
self,
drone_img: torch.Tensor,
sat_img: torch.Tensor,
caption_l1: list[str] | None = None,
caption_l2: list[str] | None = None,
caption_l3: list[str] | None = None,
) -> dict[str, torch.Tensor]:
"""Forward pass.
Args:
drone_img: Drone images [B, 3, 256, 256].
sat_img: Satellite images [B, 3, 256, 256].
caption_l1: L1 overview captions.
caption_l2: L2 full description captions.
caption_l3: L3 fingerprint captions.
Returns:
Dict with 'query' [B, proj_dim], 'gallery' [B, proj_dim], 'gate'.
"""
# Gallery: satellite only.
sat_feat = self.encode_satellite(sat_img)
gallery = self.proj_sat(sat_feat)
# Query: drone + optional text.
drone_feat = self.encode_drone(drone_img)
drone_proj = self.proj_drone(drone_feat)
text_proj = None
has_text = (
caption_l1 is not None
and caption_l2 is not None
and caption_l3 is not None
and not self.baseline_mode
)
if has_text:
text_proj = self.encode_text_levels(caption_l1, caption_l2, caption_l3)
query = self.fusion(drone_proj, text_proj)
# Re-normalize after fusion.
query = F.normalize(query, dim=-1)
return {
"query": query,
"gallery": gallery,
"gate": self.fusion.gate_value,
}
def trainable_parameters(self) -> list[nn.Parameter]:
"""Return list of parameters that require grad."""
return [p for p in self.parameters() if p.requires_grad]
def train(self, mode: bool = True) -> AsymmetricEncoder:
"""Override to keep frozen encoders in eval mode."""
super().train(mode)
self.drone_encoder.eval()
self.sat_encoder.eval()
if self.text_encoder is not None:
# Text encoder partially unfrozen — set to train mode
# but frozen layers won't update anyway.
self.text_encoder.train(mode)
return self
# ---------------------------------------------------------------------------
# Image preprocessing (DINOv3: 256x256, ImageNet normalization)
# ---------------------------------------------------------------------------
def get_dino_transform(image_size: int = 256) -> torch.nn.Module:
"""Build image transform for DINOv3 input."""
from torchvision import transforms
return transforms.Compose([
transforms.Resize(image_size, interpolation=transforms.InterpolationMode.BICUBIC),
transforms.CenterCrop(image_size),
transforms.ToTensor(),
transforms.Normalize(
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225],
),
])