forked from Pikaliov/fuze_task
fuse_proj: Initial operational package for 3 researchers (Pavlenko/Blizno/Moroz)
Multimodal fusion research on StripNet+GTA-UAV proxy: - 3 independent fusion tracks: condition-aware (A), token/bottleneck (B), role-aware (C) - Shared interfaces, protocol, dataset audit, baseline benchmarks - Canonical version-chain references to vault (SPEC, ANALYSIS, TRIAGE) - Personalized task plans and decision tables for each researcher - 3 generated DOCX task assignment files with milestones and DoD checklist - Full modality dropout diagnostics and missing-modality robustness requirements - Data contract, benchmark registry, experiment tracking infrastructure Operational documents: - docs/00_project/: MERIDIAN context, protocol, repository reuse guide, experiment specification - docs/01_tasks/: Master assignment + 3 individual researcher tracks + joint integration - docs/02_references/: Core literature, version-chain bases, code maps - docs/03_codebase_guides/: Existing code snapshots from vault - scripts/: gen_task_plans.js (DOCX generation), placeholder infrastructure - vendor_reference/: Snapshots of caption_test, depth_edges_annotate, existing SOFIA/SegModel code - reports/, results/, experiments/: Shared output structure for all 3 researchers 3 DOCX files generated from gen_task_plans.js (Times New Roman 14pt, GOST format): - План_заданий_Павленко_БВ.docx (Condition-Aware track, fusion API owner) - План_заданий_Близно_МВ.docx (Token/Bottleneck track, benchmark owner) - План_заданий_Мороз_ЕС.docx (Role-Aware track, data contract owner) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
153
vendor_reference/caption_test/src/eval/evaluate.py
Normal file
153
vendor_reference/caption_test/src/eval/evaluate.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Evaluation for caption quality test.
|
||||
|
||||
Recall@K for query(drone+text) -> gallery(satellite).
|
||||
delta_r_at_1 compares caption-aware vs baseline runs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import gin
|
||||
import torch
|
||||
from torch.utils.data import DataLoader
|
||||
|
||||
from src.models.dual_encoder import DualEncoderCaptionTest
|
||||
|
||||
LOGGER = logging.getLogger("caption_test.eval")
|
||||
|
||||
|
||||
def _recall_at_k(
|
||||
similarity: torch.Tensor,
|
||||
k_values: tuple[int, ...] = (1, 5, 10),
|
||||
) -> dict[int, float]:
|
||||
"""Recall@K assuming positives on the diagonal."""
|
||||
n_query = similarity.size(0)
|
||||
targets = torch.arange(n_query, device=similarity.device)
|
||||
sorted_idx = similarity.argsort(dim=1, descending=True)
|
||||
result: dict[int, float] = {}
|
||||
for k in k_values:
|
||||
top_k = sorted_idx[:, :k]
|
||||
hit = (top_k == targets.unsqueeze(1)).any(dim=1).float()
|
||||
result[k] = float(hit.mean().item())
|
||||
return result
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def _encode_dataset(
|
||||
model: DualEncoderCaptionTest,
|
||||
loader: DataLoader,
|
||||
device: str,
|
||||
) -> dict[str, torch.Tensor]:
|
||||
"""Encode all samples into query and gallery embeddings."""
|
||||
model.eval()
|
||||
all_query: list[torch.Tensor] = []
|
||||
all_gallery: list[torch.Tensor] = []
|
||||
|
||||
for batch in loader:
|
||||
drone_img = batch["drone_img"].to(device, non_blocking=True)
|
||||
sat_img = batch["sat_img"].to(device, non_blocking=True)
|
||||
caption_drone = batch["caption_drone"]
|
||||
|
||||
embeddings = model(
|
||||
drone_img=drone_img,
|
||||
sat_img=sat_img,
|
||||
caption_drone=caption_drone,
|
||||
)
|
||||
all_query.append(embeddings["query"].cpu())
|
||||
all_gallery.append(embeddings["gallery"].cpu())
|
||||
|
||||
return {
|
||||
"query": torch.cat(all_query, dim=0),
|
||||
"gallery": torch.cat(all_gallery, dim=0),
|
||||
}
|
||||
|
||||
|
||||
def evaluate_retrieval(
|
||||
model: DualEncoderCaptionTest,
|
||||
loader: DataLoader,
|
||||
device: str,
|
||||
k_values: tuple[int, ...] = (1, 5, 10),
|
||||
) -> dict[str, float]:
|
||||
"""Compute R@K for query->gallery and gallery->query.
|
||||
|
||||
Returns:
|
||||
Flat dict: r@1_query_to_gallery, r@5_query_to_gallery, etc.
|
||||
"""
|
||||
feats = _encode_dataset(model=model, loader=loader, device=device)
|
||||
|
||||
metrics: dict[str, float] = {}
|
||||
|
||||
sim_q2g = feats["query"] @ feats["gallery"].t()
|
||||
|
||||
for k, val in _recall_at_k(sim_q2g, k_values).items():
|
||||
metrics[f"r@{k}_query_to_gallery"] = val
|
||||
for k, val in _recall_at_k(sim_q2g.t(), k_values).items():
|
||||
metrics[f"r@{k}_gallery_to_query"] = val
|
||||
|
||||
# Gate value for diagnostics.
|
||||
metrics["gate"] = model.fusion.gate_value
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def delta_r_at_1(
|
||||
full_metrics: dict[str, float],
|
||||
baseline_metrics: dict[str, float],
|
||||
) -> float:
|
||||
"""R@1 gain from adding captions: full - baseline."""
|
||||
key = "r@1_query_to_gallery"
|
||||
return full_metrics[key] - baseline_metrics[key]
|
||||
|
||||
|
||||
@gin.configurable
|
||||
def run_evaluation_from_checkpoint(
|
||||
checkpoint_path: str,
|
||||
test_query_file: str,
|
||||
data_root: str,
|
||||
output_path: str = "eval_report.json",
|
||||
batch_size: int = 128,
|
||||
num_workers: int = 4,
|
||||
device: str = "cuda",
|
||||
) -> dict[str, float]:
|
||||
"""Standalone evaluation from checkpoint."""
|
||||
from src.datasets.visloc_with_captions import (
|
||||
GeoLocCaptionDataset,
|
||||
collate_caption_batch,
|
||||
)
|
||||
|
||||
model = DualEncoderCaptionTest().to(device)
|
||||
ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
||||
model.load_state_dict(ckpt["model_state"])
|
||||
model.eval()
|
||||
|
||||
test_ds = GeoLocCaptionDataset(
|
||||
query_file=test_query_file,
|
||||
data_root=data_root,
|
||||
image_transform=model.preprocess,
|
||||
)
|
||||
test_loader = DataLoader(
|
||||
test_ds,
|
||||
batch_size=batch_size,
|
||||
shuffle=False,
|
||||
num_workers=num_workers,
|
||||
collate_fn=collate_caption_batch,
|
||||
pin_memory=True,
|
||||
)
|
||||
|
||||
metrics = evaluate_retrieval(model=model, loader=test_loader, device=device)
|
||||
|
||||
report = {
|
||||
"checkpoint": checkpoint_path,
|
||||
"test_query_file": test_query_file,
|
||||
"metrics": metrics,
|
||||
}
|
||||
out = Path(output_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out.open("w", encoding="utf-8") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
|
||||
LOGGER.info("evaluation report saved to %s", out)
|
||||
return metrics
|
||||
70
vendor_reference/caption_test/src/losses/hard_negatives.py
Normal file
70
vendor_reference/caption_test/src/losses/hard_negatives.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Hard negative memory bank for contrastive learning.
|
||||
|
||||
MoCo-style FIFO queue of recent gallery embeddings. Each batch gets
|
||||
B in-batch negatives + Q queue negatives, significantly increasing
|
||||
the effective number of negatives without extra VRAM for forward pass.
|
||||
|
||||
Usage:
|
||||
bank = NegativeMemoryBank(size=4096, dim=1024)
|
||||
# In training loop:
|
||||
sim = bank.compute_similarity(query, gallery) # [B, B + Q]
|
||||
bank.enqueue(gallery.detach())
|
||||
"""
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
|
||||
class NegativeMemoryBank(nn.Module):
|
||||
"""FIFO queue of detached gallery embeddings for hard negatives.
|
||||
|
||||
Args:
|
||||
size: Queue capacity (number of stored embeddings).
|
||||
dim: Embedding dimension.
|
||||
"""
|
||||
|
||||
def __init__(self, size: int = 4096, dim: int = 1024) -> None:
|
||||
super().__init__()
|
||||
self.size = size
|
||||
self.dim = dim
|
||||
# Queue stored as buffer (not a parameter, moves with .to(device)).
|
||||
self.register_buffer("queue", torch.randn(size, dim))
|
||||
self.queue = nn.functional.normalize(self.queue, dim=-1)
|
||||
self.register_buffer("ptr", torch.zeros(1, dtype=torch.long))
|
||||
self.register_buffer("full", torch.zeros(1, dtype=torch.bool))
|
||||
|
||||
@torch.no_grad()
|
||||
def enqueue(self, embeddings: torch.Tensor) -> None:
|
||||
"""Add embeddings to the queue (FIFO). Oldest are overwritten."""
|
||||
batch_size = embeddings.shape[0]
|
||||
ptr = int(self.ptr.item())
|
||||
|
||||
if ptr + batch_size <= self.size:
|
||||
self.queue[ptr:ptr + batch_size] = embeddings.detach()
|
||||
else:
|
||||
# Wrap around.
|
||||
overflow = (ptr + batch_size) - self.size
|
||||
self.queue[ptr:] = embeddings[:batch_size - overflow].detach()
|
||||
self.queue[:overflow] = embeddings[batch_size - overflow:].detach()
|
||||
|
||||
new_ptr = (ptr + batch_size) % self.size
|
||||
self.ptr[0] = new_ptr
|
||||
if not self.full.item() and (new_ptr < ptr or new_ptr == 0):
|
||||
self.full[0] = True
|
||||
|
||||
def get_queue(self) -> torch.Tensor:
|
||||
"""Return valid queue entries [Q, dim]."""
|
||||
if self.full.item():
|
||||
return self.queue
|
||||
ptr = int(self.ptr.item())
|
||||
if ptr == 0:
|
||||
return self.queue[:0] # empty
|
||||
return self.queue[:ptr]
|
||||
|
||||
@property
|
||||
def current_size(self) -> int:
|
||||
if self.full.item():
|
||||
return self.size
|
||||
return int(self.ptr.item())
|
||||
203
vendor_reference/caption_test/src/losses/multi_infonce.py
Normal file
203
vendor_reference/caption_test/src/losses/multi_infonce.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""InfoNCE loss for cross-view geo-localization with optional text fusion.
|
||||
|
||||
Single symmetric InfoNCE between query (drone+text fused) and gallery (satellite).
|
||||
Asymmetric weighting: query->gallery weighted higher (real use-case direction).
|
||||
|
||||
Supports both learnable temperature (CLIP-style logit_scale) and fixed/scheduled.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import gin
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
def _symmetric_info_nce(
|
||||
emb_a: torch.Tensor,
|
||||
emb_b: torch.Tensor,
|
||||
temperature: float | torch.Tensor,
|
||||
label_smoothing: float,
|
||||
weight_a2b: float = 0.5,
|
||||
weight_b2a: float = 0.5,
|
||||
queue_negatives: torch.Tensor | None = None,
|
||||
hard_mining_k: int = 0,
|
||||
) -> torch.Tensor:
|
||||
"""Weighted symmetric InfoNCE with optional hard negative queue.
|
||||
|
||||
Args:
|
||||
emb_a: Query embeddings [B, D].
|
||||
emb_b: Gallery embeddings [B, D]. Positives on diagonal.
|
||||
queue_negatives: Extra gallery negatives [Q, D] from memory bank.
|
||||
hard_mining_k: If > 0 and queue is non-empty, use only the top-K
|
||||
hardest (highest-similarity) queue entries per query instead
|
||||
of the full queue. Per-query selection — each row gets its
|
||||
own K negatives gathered via `topk`.
|
||||
"""
|
||||
batch_size = emb_a.size(0)
|
||||
emb_a_f = emb_a.float()
|
||||
emb_b_f = emb_b.float()
|
||||
|
||||
if queue_negatives is not None and queue_negatives.shape[0] > 0:
|
||||
queue_f = queue_negatives.float()
|
||||
sim_inbatch = emb_a_f @ emb_b_f.t() / temperature # [B, B]
|
||||
sim_queue = emb_a_f @ queue_f.t() / temperature # [B, Q]
|
||||
|
||||
if hard_mining_k > 0 and hard_mining_k < queue_f.shape[0]:
|
||||
# Per-row top-K — each query gets its own hardest negatives.
|
||||
sim_queue, _ = sim_queue.topk(k=hard_mining_k, dim=1) # [B, K]
|
||||
|
||||
# a→b: [B, B + (Q or K)]. Positive at column `i` for row `i`.
|
||||
logits_a2b = torch.cat([sim_inbatch, sim_queue], dim=1)
|
||||
targets_a = torch.arange(batch_size, device=emb_a.device)
|
||||
loss_a2b = F.cross_entropy(logits_a2b, targets_a, label_smoothing=label_smoothing)
|
||||
|
||||
# b→a: gallery sees B in-batch queries (queue is gallery-side, irrelevant here).
|
||||
logits_b2a = sim_inbatch.t() # [B, B]
|
||||
targets_b = torch.arange(batch_size, device=emb_a.device)
|
||||
loss_b2a = F.cross_entropy(logits_b2a, targets_b, label_smoothing=label_smoothing)
|
||||
else:
|
||||
logits = emb_a_f @ emb_b_f.t() / temperature
|
||||
targets = torch.arange(batch_size, device=emb_a.device)
|
||||
loss_a2b = F.cross_entropy(logits, targets, label_smoothing=label_smoothing)
|
||||
loss_b2a = F.cross_entropy(logits.t(), targets, label_smoothing=label_smoothing)
|
||||
|
||||
return weight_a2b * loss_a2b + weight_b2a * loss_b2a
|
||||
|
||||
|
||||
def cosine_temperature(
|
||||
epoch: int,
|
||||
total_epochs: int,
|
||||
tau_init: float = 0.1,
|
||||
tau_final: float = 0.01,
|
||||
) -> float:
|
||||
"""Cosine-decay schedule for InfoNCE temperature."""
|
||||
total_epochs = max(total_epochs, 1)
|
||||
progress = min(max(epoch / total_epochs, 0.0), 1.0)
|
||||
cosine = 0.5 * (1.0 + math.cos(math.pi * progress))
|
||||
return tau_final + (tau_init - tau_final) * cosine
|
||||
|
||||
|
||||
@gin.configurable
|
||||
class InfoNCELoss(nn.Module):
|
||||
"""Symmetric InfoNCE with learnable or scheduled temperature.
|
||||
|
||||
Args:
|
||||
temperature_init: Initial temperature value.
|
||||
temperature_final: Final temperature (only used if learnable=False).
|
||||
label_smoothing: Cross-entropy label smoothing.
|
||||
weight_q2g: Weight for query->gallery direction.
|
||||
weight_g2q: Weight for gallery->query direction.
|
||||
learnable_temperature: If True, temperature is a learnable parameter
|
||||
(CLIP-style logit_scale). If False, uses cosine schedule.
|
||||
tau_min: Minimum clamp for learnable temperature.
|
||||
tau_max: Maximum clamp for learnable temperature.
|
||||
hard_mining_k: If > 0, mine top-K hardest negatives per query from
|
||||
the memory bank queue instead of using the full queue. 0 disables
|
||||
mining (queue used whole). Typical values: 256-1024 for queue=4096.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
temperature_init: float = 0.07,
|
||||
temperature_final: float = 0.01,
|
||||
label_smoothing: float = 0.1,
|
||||
weight_q2g: float = 0.6,
|
||||
weight_g2q: float = 0.4,
|
||||
learnable_temperature: bool = True,
|
||||
tau_min: float = 0.01,
|
||||
tau_max: float = 0.1,
|
||||
hard_mining_k: int = 0,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.temperature_init = temperature_init
|
||||
self.temperature_final = temperature_final
|
||||
self.label_smoothing = label_smoothing
|
||||
self.weight_q2g = weight_q2g
|
||||
self.weight_g2q = weight_g2q
|
||||
self.learnable_temperature = learnable_temperature
|
||||
self.tau_min = tau_min
|
||||
self.tau_max = tau_max
|
||||
self.hard_mining_k = hard_mining_k
|
||||
|
||||
if learnable_temperature:
|
||||
# Store as log(1/tau) like CLIP's logit_scale.
|
||||
init_logit_scale = math.log(1.0 / temperature_init)
|
||||
self.logit_scale = nn.Parameter(torch.tensor(init_logit_scale))
|
||||
else:
|
||||
self.logit_scale = None
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
"""Current temperature value (for logging)."""
|
||||
if self.logit_scale is not None:
|
||||
tau = 1.0 / self.logit_scale.exp().clamp(
|
||||
min=1.0 / self.tau_max, max=1.0 / self.tau_min,
|
||||
).item()
|
||||
return tau
|
||||
return self.temperature_init
|
||||
|
||||
def forward(
|
||||
self,
|
||||
embeddings: dict[str, torch.Tensor],
|
||||
epoch: int,
|
||||
total_epochs: int,
|
||||
queue_negatives: torch.Tensor | None = None,
|
||||
) -> dict[str, torch.Tensor]:
|
||||
"""Compute InfoNCE loss with optional hard negative queue.
|
||||
|
||||
Args:
|
||||
embeddings: Dict with 'query' and 'gallery' [B, D] L2-normalized.
|
||||
epoch: Current epoch (0-indexed).
|
||||
total_epochs: Total epochs for temperature schedule.
|
||||
queue_negatives: Extra gallery negatives [Q, D] from memory bank.
|
||||
|
||||
Returns:
|
||||
Dict with 'total', 'temperature', 'gate_q', 'gate_g'.
|
||||
"""
|
||||
if self.learnable_temperature:
|
||||
# Clamp logit_scale in logit space first to prevent exp() overflow in fp16.
|
||||
# tau_min=0.01 -> max logit_scale=ln(1/0.01)=4.6
|
||||
# tau_max=0.1 -> min logit_scale=ln(1/0.1)=2.30
|
||||
clamped = self.logit_scale.float().clamp(
|
||||
min=math.log(1.0 / self.tau_max),
|
||||
max=math.log(1.0 / self.tau_min),
|
||||
)
|
||||
logit_scale = clamped.exp()
|
||||
tau = 1.0 / logit_scale
|
||||
else:
|
||||
tau = cosine_temperature(
|
||||
epoch=epoch,
|
||||
total_epochs=total_epochs,
|
||||
tau_init=self.temperature_init,
|
||||
tau_final=self.temperature_final,
|
||||
)
|
||||
|
||||
loss = _symmetric_info_nce(
|
||||
emb_a=embeddings["query"],
|
||||
emb_b=embeddings["gallery"],
|
||||
temperature=tau,
|
||||
label_smoothing=self.label_smoothing,
|
||||
weight_a2b=self.weight_q2g,
|
||||
weight_b2a=self.weight_g2q,
|
||||
queue_negatives=queue_negatives,
|
||||
hard_mining_k=self.hard_mining_k,
|
||||
)
|
||||
|
||||
gate_q = embeddings.get("gate_q", embeddings.get("gate", 1.0))
|
||||
gate_g = embeddings.get("gate_g", 1.0)
|
||||
|
||||
if isinstance(tau, float):
|
||||
tau_out = torch.tensor(tau, device=loss.device)
|
||||
else:
|
||||
tau_out = tau.detach().clone()
|
||||
|
||||
return {
|
||||
"total": loss,
|
||||
"temperature": tau_out,
|
||||
"gate_q": torch.tensor(gate_q, device=loss.device),
|
||||
"gate_g": torch.tensor(gate_g, device=loss.device),
|
||||
}
|
||||
148
vendor_reference/caption_test/src/losses/weighted_infonce.py
Normal file
148
vendor_reference/caption_test/src/losses/weighted_infonce.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Weighted InfoNCE loss for GTA-UAV cross-view geo-localization.
|
||||
|
||||
Adapted from Game4Loc (https://github.com/Yux1angJi/GTA-UAV).
|
||||
Uses per-sample label smoothing based on positive_weights (IoU/distance)
|
||||
to handle partial overlap between drone and satellite crops.
|
||||
|
||||
Standard InfoNCE assumes strict 1-to-1 pairs and treats all non-diagonal
|
||||
entries as negatives. In GTA-UAV, multiple satellite crops can validly
|
||||
match one drone image (partial IoU overlap), causing false negatives.
|
||||
WeightedInfoNCE softens this with adaptive label smoothing per sample.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import gin
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
@gin.configurable
|
||||
class WeightedInfoNCELoss(nn.Module):
|
||||
"""Weighted InfoNCE with adaptive per-sample label smoothing.
|
||||
|
||||
For each sample i, eps_i = 1 - (1 - base_smoothing) / (1 + exp(-k * w_i))
|
||||
where w_i is the positive weight (e.g. IoU with matched satellite crop).
|
||||
Higher weight → lower eps → sharper target (strong positive).
|
||||
Lower weight → higher eps → softer target (weak/semi-positive).
|
||||
|
||||
Args:
|
||||
temperature_init: Initial temperature (or learnable logit_scale).
|
||||
learnable_temperature: If True, temperature is learnable (CLIP-style).
|
||||
label_smoothing: Base label smoothing (used when no weights provided).
|
||||
k: Sigmoid steepness for weight → eps mapping.
|
||||
tau_min: Min clamp for learnable temperature.
|
||||
tau_max: Max clamp for learnable temperature.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
temperature_init: float = 0.07,
|
||||
learnable_temperature: bool = True,
|
||||
label_smoothing: float = 0.1,
|
||||
k: float = 5.0,
|
||||
tau_min: float = 0.01,
|
||||
tau_max: float = 0.1,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.label_smoothing = label_smoothing
|
||||
self.k = k
|
||||
self.tau_min = tau_min
|
||||
self.tau_max = tau_max
|
||||
self.learnable_temperature = learnable_temperature
|
||||
|
||||
if learnable_temperature:
|
||||
self.logit_scale = nn.Parameter(
|
||||
torch.tensor(math.log(1.0 / temperature_init))
|
||||
)
|
||||
else:
|
||||
self.logit_scale = None
|
||||
self.temperature = temperature_init
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
if self.logit_scale is not None:
|
||||
tau = 1.0 / self.logit_scale.exp().clamp(
|
||||
min=1.0 / self.tau_max, max=1.0 / self.tau_min,
|
||||
).item()
|
||||
return tau
|
||||
return self.temperature
|
||||
|
||||
def _compute_eps(self, positive_weights: torch.Tensor | None, n: int) -> torch.Tensor | list[float]:
|
||||
"""Compute per-sample label smoothing from positive weights."""
|
||||
if positive_weights is not None:
|
||||
# Higher weight → lower eps (sharper, stronger positive).
|
||||
return 1.0 - (1.0 - self.label_smoothing) / (1.0 + torch.exp(-self.k * positive_weights))
|
||||
return [self.label_smoothing] * n
|
||||
|
||||
def _weighted_loss(
|
||||
self,
|
||||
sim_matrix: torch.Tensor,
|
||||
eps_all: torch.Tensor | list[float],
|
||||
) -> torch.Tensor:
|
||||
"""Weighted InfoNCE: per-sample interpolation between hard and uniform targets.
|
||||
|
||||
For each row i:
|
||||
L_i = (1-eps_i) * [-sim[i,i] + logsumexp(sim[i,:])]
|
||||
+ eps_i * [-mean(sim[i,:]) + logsumexp(sim[i,:])]
|
||||
"""
|
||||
n = sim_matrix.shape[0]
|
||||
total_loss = torch.tensor(0.0, device=sim_matrix.device)
|
||||
for i in range(n):
|
||||
eps = eps_all[i] if isinstance(eps_all, list) else eps_all[i]
|
||||
logsumexp = torch.logsumexp(sim_matrix[i, :], dim=0)
|
||||
total_loss += (1 - eps) * (-sim_matrix[i, i] + logsumexp)
|
||||
total_loss += eps * (-sim_matrix[i, :].mean() + logsumexp)
|
||||
return total_loss / n
|
||||
|
||||
def forward(
|
||||
self,
|
||||
embeddings: dict[str, torch.Tensor],
|
||||
epoch: int = 0,
|
||||
total_epochs: int = 1,
|
||||
positive_weights: torch.Tensor | None = None,
|
||||
queue_negatives: torch.Tensor | None = None,
|
||||
) -> dict[str, torch.Tensor]:
|
||||
"""Compute weighted InfoNCE loss.
|
||||
|
||||
Args:
|
||||
embeddings: Dict with 'query' [B,D], 'gallery' [B,D], 'gate_q', 'gate_g'.
|
||||
positive_weights: Per-sample weight [B] (e.g. IoU with matched sat crop).
|
||||
queue_negatives: Extra negatives [Q,D] from memory bank (not used with weighted loss).
|
||||
"""
|
||||
query = embeddings["query"].float()
|
||||
gallery = embeddings["gallery"].float()
|
||||
|
||||
# Temperature.
|
||||
if self.learnable_temperature:
|
||||
clamped = self.logit_scale.float().clamp(
|
||||
min=math.log(1.0 / self.tau_max),
|
||||
max=math.log(1.0 / self.tau_min),
|
||||
)
|
||||
logit_scale = clamped.exp()
|
||||
tau = 1.0 / logit_scale
|
||||
else:
|
||||
logit_scale = 1.0 / self.temperature
|
||||
tau = self.temperature
|
||||
|
||||
sim_q2g = logit_scale * query @ gallery.t()
|
||||
sim_g2q = sim_q2g.t()
|
||||
|
||||
eps = self._compute_eps(positive_weights, query.shape[0])
|
||||
|
||||
loss_q2g = self._weighted_loss(sim_q2g, eps)
|
||||
loss_g2q = self._weighted_loss(sim_g2q, eps)
|
||||
total = (loss_q2g + loss_g2q) / 2
|
||||
|
||||
gate_q = embeddings.get("gate_q", 1.0)
|
||||
gate_g = embeddings.get("gate_g", 1.0)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"temperature": tau if isinstance(tau, torch.Tensor) else torch.tensor(tau, device=total.device),
|
||||
"gate_q": torch.tensor(gate_q, device=total.device) if not isinstance(gate_q, torch.Tensor) else gate_q.detach(),
|
||||
"gate_g": torch.tensor(gate_g, device=total.device) if not isinstance(gate_g, torch.Tensor) else gate_g.detach(),
|
||||
}
|
||||
656
vendor_reference/caption_test/src/models/asymmetric_encoder.py
Normal file
656
vendor_reference/caption_test/src/models/asymmetric_encoder.py
Normal file
@@ -0,0 +1,656 @@
|
||||
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
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import coloredlogs
|
||||
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.adapters import inject_lora_into_dgtrs, inject_mona_into_dinov3
|
||||
from src.models.dgtrs.model import DGTRSTextEncoder, load_dgtrs_text_encoder, tokenize_dgtrs
|
||||
from src.models.dual_encoder import GatedFusion, ProjectionHead
|
||||
from src.models.stripnet import inject_conv_mona_into_stripnet
|
||||
from src.models.stripnet_encoder import StripNetEncoder
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
self.gradient_checkpointing = False
|
||||
|
||||
def set_gradient_checkpointing(self, enable: bool = True) -> None:
|
||||
"""Enable/disable gradient checkpointing to trade compute for VRAM."""
|
||||
self.gradient_checkpointing = enable
|
||||
|
||||
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:
|
||||
if self.gradient_checkpointing and self.training:
|
||||
x = torch.utils.checkpoint.checkpoint(
|
||||
block, x, use_reentrant=False,
|
||||
)
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
# LRSCLIPTextEncoder removed — replaced by official DGTRS architecture
|
||||
# in src/models/dgtrs/model.py (DGTRSTextEncoder)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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, out_dim]
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text_dim: int = 768,
|
||||
out_dim: int = 1024,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.mlp = nn.Sequential(
|
||||
nn.Linear(3 * text_dim, out_dim),
|
||||
nn.GELU(),
|
||||
nn.Linear(out_dim, out_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, out_dim].
|
||||
"""
|
||||
cat = torch.cat([z_l1, z_l2, z_l3], dim=-1)
|
||||
return self.mlp(cat)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main model: AsymmetricEncoder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ResidualGateFusin experiment
|
||||
from .residual_fusions import ResidualGateType, GatedFusionResidual
|
||||
|
||||
class AsymmetricEncoder(nn.Module):
|
||||
"""Dual encoder for CVGL with text fusion on both branches.
|
||||
|
||||
Supports two modes:
|
||||
- **shared** (default): single DINOv3 WEB encoder for both drone and satellite,
|
||||
one set of MONA adapters. Saves ~4-5 GB VRAM and halves adapter params.
|
||||
- **asymmetric**: separate DINOv3 encoders (LVD for drone, SAT for satellite),
|
||||
each with their own MONA adapters (legacy mode).
|
||||
|
||||
Query branch: DINOv3 (drone) + text(L1/L2/L3) -> GatedFusion_q -> query [1024]
|
||||
Gallery branch: DINOv3 (sat) + text(L1/L2/L3) -> GatedFusion_g -> gallery [1024]
|
||||
|
||||
No projection layers — retrieval space is DINOv3 native 1024-dim.
|
||||
Text fusion MLP is shared between branches (same caption format).
|
||||
Two separate GatedFusion gates (drone/sat may weight text differently).
|
||||
|
||||
For satellite images without captions, GatedFusion passes image features through
|
||||
(text_feat=None → gate acts as identity).
|
||||
|
||||
Args:
|
||||
dino_web_path: Path to DINOv3 LVD checkpoint (used for both branches in shared mode).
|
||||
dino_sat_path: Path to DINOv3 SAT checkpoint (only used in asymmetric mode).
|
||||
lrsclip_path: Path to DGTRS-CLIP checkpoint (text encoder).
|
||||
init_gate: Initial fusion gate (image weight).
|
||||
baseline_mode: If True, gate = 1.0 (text ignored), DGTRS not loaded.
|
||||
shared_encoder: If True, use single DINOv3 WEB for both branches.
|
||||
device: Torch device string.
|
||||
"""
|
||||
|
||||
DINO_DIM = 1024
|
||||
TEXT_DIM = 768
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# !!! ---------------------------------------------------------------
|
||||
gate_type: ResidualGateType = ResidualGateType.simple_residual_one_gate,
|
||||
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",
|
||||
init_gate: float = 0.7,
|
||||
baseline_mode: bool = False,
|
||||
shared_encoder: bool = False,
|
||||
mona_bottleneck: int = 64,
|
||||
mona_last_n_blocks: int = 24,
|
||||
lora_rank: int = 4,
|
||||
device: str = "cuda",
|
||||
backbone: str = "dinov3",
|
||||
stripnet_path: str = "nn_models/STRIPNET/stripnet_s.pth",
|
||||
stripnet_mona_last_n_stages: int = 2,
|
||||
stripnet_freeze: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.embed_dim = self.DINO_DIM # native 1024 (StripNet projects 512 -> 1024)
|
||||
self.baseline_mode = baseline_mode
|
||||
self.shared_encoder = shared_encoder
|
||||
self.backbone = backbone
|
||||
self.device = device
|
||||
|
||||
# Image encoder(s) (frozen + MONA adapters).
|
||||
if backbone == "stripnet":
|
||||
# StripNet always operates as shared encoder (one CNN for both branches).
|
||||
self.shared_encoder = True
|
||||
self.image_encoder = StripNetEncoder(checkpoint_path=stripnet_path, out_dim=self.DINO_DIM)
|
||||
if stripnet_freeze:
|
||||
self._freeze(self.image_encoder.backbone)
|
||||
LOGGER.info("StripNet backbone: frozen (Conv-MONA + projection trainable)")
|
||||
else:
|
||||
LOGGER.info("StripNet backbone: UNFROZEN — full fine-tune (use lower lr factor)")
|
||||
if stripnet_mona_last_n_stages > 0:
|
||||
inject_conv_mona_into_stripnet(
|
||||
self.image_encoder.backbone,
|
||||
bottleneck=mona_bottleneck,
|
||||
last_n_stages=stripnet_mona_last_n_stages,
|
||||
)
|
||||
else:
|
||||
LOGGER.info("Conv-MONA disabled (stripnet_mona_last_n_stages=0)")
|
||||
LOGGER.info("StripNet backbone: shared encoder, projection 512 -> %d", self.DINO_DIM)
|
||||
elif shared_encoder:
|
||||
self.image_encoder = DINOv3ViT.from_pretrained(dino_web_path)
|
||||
self._freeze(self.image_encoder)
|
||||
inject_mona_into_dinov3(self.image_encoder, bottleneck=mona_bottleneck, last_n_blocks=mona_last_n_blocks)
|
||||
LOGGER.info("Shared encoder mode: single DINOv3 WEB for drone + satellite")
|
||||
else:
|
||||
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)
|
||||
inject_mona_into_dinov3(self.drone_encoder, bottleneck=mona_bottleneck, last_n_blocks=mona_last_n_blocks)
|
||||
inject_mona_into_dinov3(self.sat_encoder, bottleneck=mona_bottleneck, last_n_blocks=mona_last_n_blocks)
|
||||
LOGGER.info("Asymmetric encoder mode: DINOv3 WEB (drone) + DINOv3 SAT (satellite)")
|
||||
|
||||
# Text encoder — official DGTRS architecture (frozen + LoRA).
|
||||
if not baseline_mode:
|
||||
self.text_encoder = load_dgtrs_text_encoder(lrsclip_path)
|
||||
self._freeze(self.text_encoder)
|
||||
inject_lora_into_dgtrs(self.text_encoder, rank=lora_rank)
|
||||
else:
|
||||
self.text_encoder = None
|
||||
|
||||
# Shared text fusion MLP: 3×768 -> 1024 (native DINOv3 dim).
|
||||
if not baseline_mode:
|
||||
self.text_fusion = TextFusionMLP(
|
||||
text_dim=self.TEXT_DIM,
|
||||
out_dim=self.DINO_DIM,
|
||||
)
|
||||
|
||||
# Separate gated fusion for query and gallery branches.
|
||||
#! Experimental Gated fusion on query branch.
|
||||
self.fusion_query = GatedFusionResidual(gate_type=gate_type,
|
||||
init_gate=init_gate, baseline_mode=baseline_mode)
|
||||
self.fusion_gallery = GatedFusionResidual(gate_type=gate_type,
|
||||
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 encode_drone(self, images: torch.Tensor) -> torch.Tensor:
|
||||
"""Encode drone images with MONA adapters. Returns [B, 1024]."""
|
||||
if self.shared_encoder:
|
||||
return self.image_encoder(images)
|
||||
return self.drone_encoder(images)
|
||||
|
||||
def encode_satellite(self, images: torch.Tensor) -> torch.Tensor:
|
||||
"""Encode satellite images with MONA adapters. Returns [B, 1024]."""
|
||||
if self.shared_encoder:
|
||||
return self.image_encoder(images)
|
||||
return self.sat_encoder(images)
|
||||
|
||||
def encode_text_levels(
|
||||
self,
|
||||
l1_texts: list[str],
|
||||
l2_texts: list[str],
|
||||
l3_texts: list[str],
|
||||
) -> torch.Tensor | None:
|
||||
"""Encode L1/L2/L3 captions and fuse. Returns [B, 1024] or None.
|
||||
|
||||
Returns None if all captions are empty (no text available).
|
||||
For mixed batches (some have captions, some don't), encodes all
|
||||
texts (empty strings tokenize to pad+EOS — their outputs must be
|
||||
masked downstream, see `_fuse_with_mask`).
|
||||
"""
|
||||
# Check if any caption is non-empty.
|
||||
if all(t == "" for t in l1_texts):
|
||||
return None
|
||||
|
||||
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 using DGTRS tokenizer."""
|
||||
tokens = tokenize_dgtrs(list(texts)).to(self.device)
|
||||
return self.text_encoder(tokens)
|
||||
|
||||
def _fuse_with_mask(
|
||||
self,
|
||||
img_feat: torch.Tensor,
|
||||
l1_texts: list[str] | None,
|
||||
l2_texts: list[str] | None,
|
||||
l3_texts: list[str] | None,
|
||||
fusion: GatedFusionResidual,
|
||||
) -> torch.Tensor:
|
||||
"""Fuse image features with optional text, respecting per-sample presence.
|
||||
|
||||
For samples where caption is an empty string, output falls back to
|
||||
pure image features (avoiding noise contamination from empty-string
|
||||
text embeddings). For samples with captions, applies the standard
|
||||
gated fusion `σ(α)·img + (1-σ(α))·text`.
|
||||
|
||||
Returns L2-normalized [B, D] embedding.
|
||||
"""
|
||||
if (
|
||||
self.baseline_mode
|
||||
or l1_texts is None
|
||||
or l2_texts is None
|
||||
or l3_texts is None
|
||||
):
|
||||
return F.normalize(fusion(img_feat, None), dim=-1)
|
||||
|
||||
has_text = torch.tensor(
|
||||
[t != "" for t in l1_texts], dtype=torch.bool, device=img_feat.device,
|
||||
)
|
||||
if not has_text.any():
|
||||
return F.normalize(fusion(img_feat, None), dim=-1)
|
||||
|
||||
z_text = self.encode_text_levels(l1_texts, l2_texts, l3_texts)
|
||||
if z_text is None:
|
||||
return F.normalize(fusion(img_feat, None), dim=-1)
|
||||
|
||||
# Per-sample fusion: text-present samples use full gated fusion,
|
||||
# empty-caption samples pass through pure image features.
|
||||
fused_with_text = fusion(img_feat, z_text)
|
||||
out = torch.where(has_text.unsqueeze(-1), fused_with_text, img_feat)
|
||||
return F.normalize(out, dim=-1)
|
||||
|
||||
def encode_query(
|
||||
self,
|
||||
drone_img: torch.Tensor,
|
||||
caption_l1: list[str] | None = None,
|
||||
caption_l2: list[str] | None = None,
|
||||
caption_l3: list[str] | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Encode drone → normalized query embedding with per-sample text mask."""
|
||||
drone_feat = self.encode_drone(drone_img)
|
||||
return self._fuse_with_mask(
|
||||
drone_feat, caption_l1, caption_l2, caption_l3, self.fusion_query,
|
||||
)
|
||||
|
||||
def encode_gallery(
|
||||
self,
|
||||
sat_img: torch.Tensor,
|
||||
sat_caption_l1: list[str] | None = None,
|
||||
sat_caption_l2: list[str] | None = None,
|
||||
sat_caption_l3: list[str] | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Encode satellite → normalized gallery embedding with per-sample text mask."""
|
||||
sat_feat = self.encode_satellite(sat_img)
|
||||
return self._fuse_with_mask(
|
||||
sat_feat, sat_caption_l1, sat_caption_l2, sat_caption_l3, self.fusion_gallery,
|
||||
)
|
||||
|
||||
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,
|
||||
sat_caption_l1: list[str] | None = None,
|
||||
sat_caption_l2: list[str] | None = None,
|
||||
sat_caption_l3: list[str] | None = None,
|
||||
) -> dict[str, torch.Tensor]:
|
||||
"""Forward pass.
|
||||
|
||||
Both branches use per-sample caption masking: samples with an empty
|
||||
caption string fall back to pure image features instead of being
|
||||
fused with noise from empty-string text embeddings.
|
||||
|
||||
Args:
|
||||
drone_img: Drone images [B, 3, 256, 256].
|
||||
sat_img: Satellite images [B, 3, 256, 256].
|
||||
caption_l1/l2/l3: Drone L1/L2/L3 captions.
|
||||
sat_caption_l1/l2/l3: Satellite L1/L2/L3 captions.
|
||||
|
||||
Returns:
|
||||
Dict with 'query' [B, embed_dim], 'gallery' [B, embed_dim],
|
||||
'gate_q', 'gate_g'.
|
||||
"""
|
||||
query = self.encode_query(drone_img, caption_l1, caption_l2, caption_l3)
|
||||
gallery = self.encode_gallery(sat_img, sat_caption_l1, sat_caption_l2, sat_caption_l3)
|
||||
return {
|
||||
"query": query,
|
||||
"gallery": gallery,
|
||||
"gate_q": self.fusion_query.gate_value,
|
||||
"gate_g": self.fusion_gallery.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 save_checkpoint(self, path: str | Path, **extra) -> None:
|
||||
"""Save model checkpoint with metadata."""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
ckpt = {
|
||||
"model_state": self.state_dict(),
|
||||
"baseline_mode": self.baseline_mode,
|
||||
"shared_encoder": self.shared_encoder,
|
||||
**extra,
|
||||
}
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
torch.save(ckpt, tmp)
|
||||
tmp.replace(path)
|
||||
LOGGER.info("💾 Model saved to %s", path)
|
||||
|
||||
@classmethod
|
||||
def load_checkpoint(
|
||||
cls,
|
||||
path: str | Path,
|
||||
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",
|
||||
device: str = "cuda",
|
||||
) -> tuple[AsymmetricEncoder, dict]:
|
||||
"""Load model from checkpoint.
|
||||
|
||||
First builds the model (loading frozen backbones), then loads
|
||||
the saved trainable weights on top.
|
||||
|
||||
Returns:
|
||||
(model, checkpoint_dict) — model ready for eval/resume,
|
||||
checkpoint_dict has optimizer_state, epoch, etc.
|
||||
"""
|
||||
path = Path(path)
|
||||
LOGGER.info("📂 Loading checkpoint from %s", path)
|
||||
ckpt = torch.load(str(path), map_location="cpu", weights_only=False)
|
||||
|
||||
model = cls(
|
||||
dino_web_path=dino_web_path,
|
||||
dino_sat_path=dino_sat_path,
|
||||
lrsclip_path=lrsclip_path,
|
||||
baseline_mode=ckpt.get("baseline_mode", False),
|
||||
shared_encoder=ckpt.get("shared_encoder", False),
|
||||
mona_bottleneck=ckpt.get("mona_bottleneck", 64),
|
||||
mona_last_n_blocks=ckpt.get("mona_last_n_blocks", 24),
|
||||
device=device,
|
||||
)
|
||||
model.load_state_dict(ckpt["model_state"], strict=False)
|
||||
model = model.to(device)
|
||||
LOGGER.info("✅ Checkpoint loaded (epoch=%s)", ckpt.get("epoch", "?"))
|
||||
return model, ckpt
|
||||
|
||||
def train(self, mode: bool = True) -> AsymmetricEncoder:
|
||||
"""Override to keep frozen encoders in eval mode."""
|
||||
super().train(mode)
|
||||
if self.shared_encoder:
|
||||
self.image_encoder.eval()
|
||||
else:
|
||||
self.drone_encoder.eval()
|
||||
self.sat_encoder.eval()
|
||||
if self.text_encoder is not None:
|
||||
self.text_encoder.train(mode)
|
||||
return self
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image preprocessing (DINOv3: 256x256, ImageNet normalization)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IMAGENET_MEAN = [0.485, 0.456, 0.406]
|
||||
_IMAGENET_STD = [0.229, 0.224, 0.225]
|
||||
|
||||
|
||||
def get_dino_transform(image_size: int = 256) -> torch.nn.Module:
|
||||
"""Build eval/inference 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=_IMAGENET_MEAN, std=_IMAGENET_STD),
|
||||
])
|
||||
|
||||
|
||||
def get_drone_train_transform(image_size: int = 256) -> torch.nn.Module:
|
||||
"""Build training augmentation for drone images.
|
||||
|
||||
Includes: RandomResizedCrop, HFlip, rotation, color jitter,
|
||||
grayscale, Gaussian blur.
|
||||
"""
|
||||
from torchvision import transforms
|
||||
return transforms.Compose([
|
||||
transforms.RandomResizedCrop(
|
||||
image_size, scale=(0.7, 1.0),
|
||||
interpolation=transforms.InterpolationMode.BICUBIC,
|
||||
),
|
||||
transforms.RandomHorizontalFlip(p=0.5),
|
||||
transforms.RandomRotation(degrees=15),
|
||||
transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2, hue=0.1),
|
||||
transforms.RandomGrayscale(p=0.05),
|
||||
transforms.GaussianBlur(kernel_size=3, sigma=(0.1, 2.0)),
|
||||
transforms.ToTensor(),
|
||||
transforms.Normalize(mean=_IMAGENET_MEAN, std=_IMAGENET_STD),
|
||||
])
|
||||
|
||||
|
||||
def get_satellite_train_transform(image_size: int = 256) -> torch.nn.Module:
|
||||
"""Build training augmentation for satellite images.
|
||||
|
||||
Lighter than drone: no rotation or blur (satellite is nadir/consistent).
|
||||
Includes: RandomResizedCrop, HFlip, color jitter, grayscale.
|
||||
"""
|
||||
from torchvision import transforms
|
||||
return transforms.Compose([
|
||||
transforms.RandomResizedCrop(
|
||||
image_size, scale=(0.7, 1.0),
|
||||
interpolation=transforms.InterpolationMode.BICUBIC,
|
||||
),
|
||||
transforms.RandomHorizontalFlip(p=0.5),
|
||||
transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2, hue=0.1),
|
||||
transforms.RandomGrayscale(p=0.05),
|
||||
transforms.ToTensor(),
|
||||
transforms.Normalize(mean=_IMAGENET_MEAN, std=_IMAGENET_STD),
|
||||
])
|
||||
154
vendor_reference/caption_test/src/models/residual_fusions.py
Normal file
154
vendor_reference/caption_test/src/models/residual_fusions.py
Normal file
@@ -0,0 +1,154 @@
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import gin
|
||||
|
||||
#! GATE-FUSIONS MODIFICATIONS ---------------------------------
|
||||
#! in_dim = 1024
|
||||
|
||||
from enum import Enum
|
||||
import math
|
||||
|
||||
class ResidualGateType(Enum):
|
||||
simple_residual_one_gate = 0,
|
||||
cross_gate = 1,
|
||||
gate_sum = 2,
|
||||
alpha_res_cat = 3,
|
||||
alpha_res_sum = 4
|
||||
|
||||
# TODO: add GatedFusionresidual class to gin
|
||||
|
||||
def init_bias_for_sigmoid(linear: nn.Linear, value: float) -> None:
|
||||
nn.init.zeros_(linear.weight)
|
||||
nn.init.constant_(linear.bias, math.log(value / (1.0 - value)))
|
||||
|
||||
def init_residual_projs(linear: nn.Linear, scale: float) -> None:
|
||||
nn.init.xavier_uniform_(linear.weight, gain=scale)
|
||||
nn.init.zeros_(linear.bias)
|
||||
|
||||
RESIDUAL_GATES = {
|
||||
ResidualGateType.alpha_res_sum,
|
||||
ResidualGateType.alpha_res_cat
|
||||
}
|
||||
|
||||
@gin.configurable
|
||||
class GatedFusionResidual(nn.Module):
|
||||
"""Learnable gated fusion of image and text embeddings.
|
||||
|
||||
V1 - Simple residual gating with 1 common gate:
|
||||
V2 - Cross residual gating with 2 cross-gates
|
||||
V3 - Gate + Simple Sum of feats x & y
|
||||
V4 - Alpha-weighted residual sum (per sample)
|
||||
V5 - Alpha-weighted residual concat (per sample)
|
||||
"""
|
||||
|
||||
def __init__(self, gate_type: ResidualGateType,
|
||||
init_gate: float = 0.7, in_dim = 1024,
|
||||
init_res_weight: float = 0.1, residual_proj_scale: float = 0.1,
|
||||
baseline_mode: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
# alpha is in logit space: sigmoid(alpha) = init_gate
|
||||
init_alpha = torch.log(torch.tensor(init_gate / (1.0 - init_gate)))
|
||||
self.alpha = nn.Parameter(init_alpha)
|
||||
|
||||
# alphas for separated cases
|
||||
if gate_type == ResidualGateType.cross_gate:
|
||||
init_alpha_img_cross_gate = torch.log(torch.tensor(init_gate / (1.0 - init_gate)))
|
||||
init_alpha_text_cross_gate = torch.log(torch.tensor(init_gate / (1.0 - init_gate)))
|
||||
self.alpha_img = nn.Parameter(init_alpha_img_cross_gate)
|
||||
self.alpha_text = nn.Parameter(init_alpha_text_cross_gate)
|
||||
|
||||
# weight for sum and cat residual
|
||||
if gate_type in RESIDUAL_GATES:
|
||||
self.final_cat_residual_proj = nn.Linear(in_dim * 2, in_dim)
|
||||
self.weight_net_for_sum = nn.Linear(in_dim, 1)
|
||||
self.weight_net_for_cat = nn.Linear(in_dim * 2, 1)
|
||||
init_bias_for_sigmoid(self.weight_net_for_sum, value=init_res_weight)
|
||||
init_bias_for_sigmoid(self.weight_net_for_cat, value=init_res_weight)
|
||||
init_residual_projs(self.final_cat_residual_proj, scale=residual_proj_scale)
|
||||
|
||||
self.gate_type = gate_type
|
||||
self.baseline_mode = baseline_mode
|
||||
|
||||
def FuseSRGF(self,
|
||||
img_feat: torch.Tensor,
|
||||
text_feat: torch.Tensor | None,
|
||||
) -> torch.Tensor:
|
||||
gate = torch.sigmoid(self.alpha)
|
||||
img_res = img_feat * gate + img_feat
|
||||
text_res = text_feat * (1 - gate) + text_feat
|
||||
fused_vec = img_res + text_res
|
||||
return fused_vec
|
||||
|
||||
def FuseRCGF(self,
|
||||
img_feat: torch.Tensor,
|
||||
text_feat: torch.Tensor | None,
|
||||
) -> torch.Tensor:
|
||||
gate_img = torch.sigmoid(self.alpha_img)
|
||||
gate_text = torch.sigmoid(self.alpha_text)
|
||||
z_img = img_feat + gate_text * img_feat
|
||||
z_text = text_feat + gate_img * text_feat
|
||||
fused_vec = z_img + z_text
|
||||
return fused_vec
|
||||
|
||||
def FuseGSUM(self,
|
||||
img_feat: torch.Tensor,
|
||||
text_feat: torch.Tensor | None,
|
||||
) -> torch.Tensor:
|
||||
gate = torch.sigmoid(self.alpha)
|
||||
fuzed_vec = img_feat + text_feat + gate * img_feat + (1.0 - gate) * text_feat
|
||||
return fuzed_vec
|
||||
|
||||
def FuseARGFSum(
|
||||
self,
|
||||
img_feat: torch.Tensor,
|
||||
text_feat: torch.Tensor | None,
|
||||
) -> torch.Tensor:
|
||||
gate = torch.sigmoid(self.alpha)
|
||||
residual = img_feat + text_feat
|
||||
res_weight = torch.sigmoid(self.weight_net_for_sum(residual))
|
||||
fuzed_vec = gate * img_feat + (1.0 - gate) * text_feat + res_weight * residual
|
||||
return fuzed_vec
|
||||
|
||||
def FuseARGFCat(
|
||||
self,
|
||||
img_feat: torch.Tensor,
|
||||
text_feat: torch.Tensor | None,
|
||||
) -> torch.Tensor:
|
||||
gate = torch.sigmoid(self.alpha)
|
||||
cat_vec = torch.cat([img_feat, text_feat], dim=-1)
|
||||
residual = self.final_cat_residual_proj(cat_vec)
|
||||
res_weight = torch.sigmoid(self.weight_net_for_cat(cat_vec))
|
||||
fuzed_vec = gate * img_feat + (1.0 - gate) * text_feat + res_weight * residual
|
||||
return fuzed_vec
|
||||
|
||||
def forward(
|
||||
self,
|
||||
img_feat: torch.Tensor,
|
||||
text_feat: torch.Tensor | None,
|
||||
) -> torch.Tensor:
|
||||
if text_feat is None or self.baseline_mode:
|
||||
return img_feat
|
||||
|
||||
if self.gate_type == ResidualGateType.simple_residual_one_gate:
|
||||
fused_vec = self.FuseSRGF(img_feat=img_feat, text_feat=text_feat)
|
||||
if self.gate_type == ResidualGateType.cross_gate:
|
||||
fused_vec = self.FuseRCGF(img_feat=img_feat, text_feat=text_feat)
|
||||
if self.gate_type == ResidualGateType.gate_sum:
|
||||
fused_vec = self.FuseGSUM(img_feat=img_feat, text_feat=text_feat)
|
||||
if self.gate_type == ResidualGateType.alpha_res_sum:
|
||||
fused_vec = self.FuseARGFSum(img_feat=img_feat, text_feat=text_feat)
|
||||
if self.gate_type == ResidualGateType.alpha_res_cat:
|
||||
fused_vec = self.FuseARGFCat(img_feat=img_feat, text_feat=text_feat)
|
||||
|
||||
return fused_vec
|
||||
|
||||
@property
|
||||
def gate_value(self) -> float:
|
||||
"""Current gate value (image weight). 1.0 = text ignored."""
|
||||
if self.baseline_mode:
|
||||
return 1.0
|
||||
return torch.sigmoid(self.alpha).item()
|
||||
|
||||
|
||||
|
||||
106
vendor_reference/caption_test/src/models/stripnet/conv_mona.py
Normal file
106
vendor_reference/caption_test/src/models/stripnet/conv_mona.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Conv-MONA: 2D adaptation of MONA (CVPR 2025) for hierarchical CNN backbones.
|
||||
|
||||
MONA paper applies sequence-form adapters after MSA / MLP in ViT blocks. Here we
|
||||
mirror that idea in [B, C, H, W] form: BN → 1×1 Down(C→bn) → multi-scale DWConv
|
||||
{3,5,7} mean → +residual → GELU → 1×1 Up(bn→C). Layer scale (γ) channel-wise,
|
||||
init 1e-6 for near-identity start. Two adapters per StripNet Block: post-attn
|
||||
and post-mlp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from src.models.stripnet.model import StripNet, Block
|
||||
|
||||
LOGGER = logging.getLogger("caption_test.stripnet.adapters")
|
||||
|
||||
|
||||
class ConvMona(nn.Module):
|
||||
"""Single Conv-MONA adapter.
|
||||
|
||||
Args:
|
||||
dim: input channel dim.
|
||||
bottleneck: bottleneck channel dim (e.g. 64).
|
||||
gamma_init: layer-scale init value (1e-6 for near-identity at start).
|
||||
"""
|
||||
|
||||
def __init__(self, dim: int, bottleneck: int = 64, gamma_init: float = 1e-6) -> None:
|
||||
super().__init__()
|
||||
self.norm = nn.BatchNorm2d(dim)
|
||||
self.down = nn.Conv2d(dim, bottleneck, kernel_size=1, bias=True)
|
||||
self.dw3 = nn.Conv2d(bottleneck, bottleneck, kernel_size=3, padding=1, groups=bottleneck, bias=True)
|
||||
self.dw5 = nn.Conv2d(bottleneck, bottleneck, kernel_size=5, padding=2, groups=bottleneck, bias=True)
|
||||
self.dw7 = nn.Conv2d(bottleneck, bottleneck, kernel_size=7, padding=3, groups=bottleneck, bias=True)
|
||||
self.act = nn.GELU()
|
||||
self.up = nn.Conv2d(bottleneck, dim, kernel_size=1, bias=True)
|
||||
# Channel-wise layer scale (γ), broadcast across H, W.
|
||||
self.gamma = nn.Parameter(gamma_init * torch.ones(dim), requires_grad=True)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
h = self.norm(x)
|
||||
h = self.down(h)
|
||||
h = (self.dw3(h) + self.dw5(h) + self.dw7(h)) / 3.0 + h
|
||||
h = self.act(h)
|
||||
h = self.up(h)
|
||||
return self.gamma.view(1, -1, 1, 1) * h
|
||||
|
||||
|
||||
def _patched_block_forward(block: Block, mona_attn: ConvMona, mona_mlp: ConvMona):
|
||||
"""Closure that wraps a Block.forward with two Conv-MONA residuals."""
|
||||
orig_attn = block.attn
|
||||
orig_mlp = block.mlp
|
||||
orig_norm1 = block.norm1
|
||||
orig_norm2 = block.norm2
|
||||
orig_drop = block.drop_path
|
||||
ls1 = block.layer_scale_1
|
||||
ls2 = block.layer_scale_2
|
||||
|
||||
def forward(x: torch.Tensor) -> torch.Tensor:
|
||||
x = x + orig_drop(ls1.unsqueeze(-1).unsqueeze(-1) * orig_attn(orig_norm1(x))) + mona_attn(x)
|
||||
x = x + orig_drop(ls2.unsqueeze(-1).unsqueeze(-1) * orig_mlp(orig_norm2(x))) + mona_mlp(x)
|
||||
return x
|
||||
|
||||
return forward
|
||||
|
||||
|
||||
def inject_conv_mona_into_stripnet(
|
||||
model: StripNet,
|
||||
bottleneck: int = 64,
|
||||
last_n_stages: int = 2,
|
||||
use_bf16: bool = False,
|
||||
) -> int:
|
||||
"""Inject Conv-MONA adapters into the deepest `last_n_stages` of StripNet.
|
||||
|
||||
Each Block in the targeted stages gets two adapters (post-attn, post-mlp).
|
||||
Returns the number of adapters injected.
|
||||
|
||||
Stages are 1-indexed in StripNet (block1..block4). With `last_n_stages=2`
|
||||
we adapt block3 and block4 — the deepest, semantically richest features.
|
||||
"""
|
||||
n_stages = model.num_stages
|
||||
target_stages = list(range(max(1, n_stages - last_n_stages + 1), n_stages + 1))
|
||||
n_added = 0
|
||||
|
||||
for stage_idx in target_stages:
|
||||
blocks: nn.ModuleList = getattr(model, f"block{stage_idx}")
|
||||
dim = model.embed_dims[stage_idx - 1]
|
||||
for blk_idx, block in enumerate(blocks):
|
||||
mona_a = ConvMona(dim=dim, bottleneck=bottleneck)
|
||||
mona_m = ConvMona(dim=dim, bottleneck=bottleneck)
|
||||
if use_bf16:
|
||||
mona_a.to(dtype=torch.bfloat16)
|
||||
mona_m.to(dtype=torch.bfloat16)
|
||||
# Register as submodules so they get moved by .to(device) / .train() etc.
|
||||
block.add_module(f"mona_attn", mona_a)
|
||||
block.add_module(f"mona_mlp", mona_m)
|
||||
block.forward = _patched_block_forward(block, mona_a, mona_m)
|
||||
n_added += 2
|
||||
|
||||
n_trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
||||
LOGGER.info(
|
||||
"🔧 Conv-MONA injected: %d adapters in stages %s, %d trainable params (bottleneck=%d)",
|
||||
n_added, target_stages, n_trainable, bottleneck,
|
||||
)
|
||||
return n_added
|
||||
253
vendor_reference/caption_test/src/models/stripnet/model.py
Normal file
253
vendor_reference/caption_test/src/models/stripnet/model.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""StripNet (small) backbone — adapted from Strip-R-CNN (HVision-NKU).
|
||||
|
||||
Self-contained: no external utils. State-dict naming follows the upstream
|
||||
ImageNet-pretrained checkpoint (`conv_spatial1/2` for the strip kernels).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
LOGGER = logging.getLogger("caption_test.stripnet")
|
||||
|
||||
|
||||
def _to_2tuple(x):
|
||||
if isinstance(x, (tuple, list)):
|
||||
return tuple(x)
|
||||
return (x, x)
|
||||
|
||||
|
||||
def drop_path(x: torch.Tensor, drop_prob: float = 0.0, training: bool = False) -> torch.Tensor:
|
||||
if drop_prob == 0.0 or not training:
|
||||
return x
|
||||
keep = 1.0 - drop_prob
|
||||
shape = (x.shape[0],) + (1,) * (x.ndim - 1)
|
||||
rand = x.new_empty(shape).bernoulli_(keep)
|
||||
if keep > 0:
|
||||
rand.div_(keep)
|
||||
return x * rand
|
||||
|
||||
|
||||
class DropPath(nn.Module):
|
||||
def __init__(self, p: float = 0.0) -> None:
|
||||
super().__init__()
|
||||
self.p = p
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
return drop_path(x, self.p, self.training)
|
||||
|
||||
|
||||
class DWConv(nn.Module):
|
||||
def __init__(self, dim: int) -> None:
|
||||
super().__init__()
|
||||
self.dwconv = nn.Conv2d(dim, dim, 3, 1, 1, bias=True, groups=dim)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
return self.dwconv(x)
|
||||
|
||||
|
||||
class Mlp(nn.Module):
|
||||
def __init__(self, in_features: int, hidden_features: int, drop: float = 0.0) -> None:
|
||||
super().__init__()
|
||||
self.fc1 = nn.Conv2d(in_features, hidden_features, 1)
|
||||
self.dwconv = DWConv(hidden_features)
|
||||
self.act = nn.GELU()
|
||||
self.fc2 = nn.Conv2d(hidden_features, in_features, 1)
|
||||
self.drop = nn.Dropout(drop)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
x = self.fc1(x)
|
||||
x = self.dwconv(x)
|
||||
x = self.act(x)
|
||||
x = self.drop(x)
|
||||
x = self.fc2(x)
|
||||
x = self.drop(x)
|
||||
return x
|
||||
|
||||
|
||||
class StripGatingUnit(nn.Module):
|
||||
"""Strip spatial gating: 5x5 DWConv -> (1, k2) -> (k2, 1) -> 1x1 -> gate."""
|
||||
|
||||
def __init__(self, dim: int, k1: int, k2: int) -> None:
|
||||
super().__init__()
|
||||
self.conv0 = nn.Conv2d(dim, dim, 5, padding=2, groups=dim)
|
||||
# Names match upstream pretrained checkpoint: conv_spatial1 / conv_spatial2.
|
||||
self.conv_spatial1 = nn.Conv2d(dim, dim, kernel_size=(k1, k2), stride=1,
|
||||
padding=(k1 // 2, k2 // 2), groups=dim)
|
||||
self.conv_spatial2 = nn.Conv2d(dim, dim, kernel_size=(k2, k1), stride=1,
|
||||
padding=(k2 // 2, k1 // 2), groups=dim)
|
||||
self.conv1 = nn.Conv2d(dim, dim, 1)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
attn = self.conv0(x)
|
||||
attn = self.conv_spatial1(attn)
|
||||
attn = self.conv_spatial2(attn)
|
||||
attn = self.conv1(attn)
|
||||
return x * attn
|
||||
|
||||
|
||||
class StripAttention(nn.Module):
|
||||
def __init__(self, dim: int, k1: int, k2: int) -> None:
|
||||
super().__init__()
|
||||
self.proj_1 = nn.Conv2d(dim, dim, 1)
|
||||
self.activation = nn.GELU()
|
||||
self.spatial_gating_unit = StripGatingUnit(dim, k1, k2)
|
||||
self.proj_2 = nn.Conv2d(dim, dim, 1)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
residual = x
|
||||
x = self.proj_1(x)
|
||||
x = self.activation(x)
|
||||
x = self.spatial_gating_unit(x)
|
||||
x = self.proj_2(x)
|
||||
return x + residual
|
||||
|
||||
|
||||
class Block(nn.Module):
|
||||
def __init__(self, dim: int, mlp_ratio: float, k1: int, k2: int, drop: float, drop_path: float) -> None:
|
||||
super().__init__()
|
||||
self.norm1 = nn.BatchNorm2d(dim)
|
||||
self.norm2 = nn.BatchNorm2d(dim)
|
||||
self.attn = StripAttention(dim, k1, k2)
|
||||
self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity()
|
||||
self.mlp = Mlp(dim, int(dim * mlp_ratio), drop=drop)
|
||||
ls_init = 1e-2
|
||||
self.layer_scale_1 = nn.Parameter(ls_init * torch.ones(dim), requires_grad=True)
|
||||
self.layer_scale_2 = nn.Parameter(ls_init * torch.ones(dim), requires_grad=True)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
x = x + self.drop_path(
|
||||
self.layer_scale_1.unsqueeze(-1).unsqueeze(-1) * self.attn(self.norm1(x))
|
||||
)
|
||||
x = x + self.drop_path(
|
||||
self.layer_scale_2.unsqueeze(-1).unsqueeze(-1) * self.mlp(self.norm2(x))
|
||||
)
|
||||
return x
|
||||
|
||||
|
||||
class OverlapPatchEmbed(nn.Module):
|
||||
def __init__(self, patch_size: int, stride: int, in_chans: int, embed_dim: int) -> None:
|
||||
super().__init__()
|
||||
ph, pw = _to_2tuple(patch_size)
|
||||
self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=(ph, pw), stride=stride,
|
||||
padding=(ph // 2, pw // 2))
|
||||
self.norm = nn.BatchNorm2d(embed_dim)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, int, int]:
|
||||
x = self.proj(x)
|
||||
_, _, H, W = x.shape
|
||||
x = self.norm(x)
|
||||
return x, H, W
|
||||
|
||||
|
||||
class StripNet(nn.Module):
|
||||
"""Strip-R-CNN backbone: 4-stage hierarchical CNN with strip-shaped DWConv attention.
|
||||
|
||||
Output: list of [B, C_i, H/s_i, W/s_i] per stage. Use `forward_last_features` for
|
||||
the deepest stage only.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
embed_dims: List[int] = [64, 128, 320, 512],
|
||||
mlp_ratios: List[int] = [8, 8, 4, 4],
|
||||
k1s: List[int] = [1, 1, 1, 1],
|
||||
k2s: List[int] = [19, 19, 19, 19],
|
||||
depths: List[int] = [2, 2, 4, 2],
|
||||
drop_rate: float = 0.1,
|
||||
drop_path_rate: float = 0.15,
|
||||
in_chans: int = 3,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.depths = depths
|
||||
self.num_stages = len(depths)
|
||||
self.embed_dims = embed_dims
|
||||
|
||||
dpr = [x.item() for x in torch.linspace(0, drop_path_rate, sum(depths))]
|
||||
cur = 0
|
||||
for i in range(self.num_stages):
|
||||
patch_embed = OverlapPatchEmbed(
|
||||
patch_size=7 if i == 0 else 3,
|
||||
stride=4 if i == 0 else 2,
|
||||
in_chans=in_chans if i == 0 else embed_dims[i - 1],
|
||||
embed_dim=embed_dims[i],
|
||||
)
|
||||
block = nn.ModuleList([
|
||||
Block(dim=embed_dims[i], mlp_ratio=mlp_ratios[i], k1=k1s[i], k2=k2s[i],
|
||||
drop=drop_rate, drop_path=dpr[cur + j])
|
||||
for j in range(depths[i])
|
||||
])
|
||||
norm = nn.LayerNorm(embed_dims[i], eps=1e-6)
|
||||
cur += depths[i]
|
||||
setattr(self, f"patch_embed{i + 1}", patch_embed)
|
||||
setattr(self, f"block{i + 1}", block)
|
||||
setattr(self, f"norm{i + 1}", norm)
|
||||
|
||||
def forward_features(self, x: torch.Tensor) -> List[torch.Tensor]:
|
||||
B = x.shape[0]
|
||||
outs: List[torch.Tensor] = []
|
||||
for i in range(self.num_stages):
|
||||
patch_embed = getattr(self, f"patch_embed{i + 1}")
|
||||
block = getattr(self, f"block{i + 1}")
|
||||
norm = getattr(self, f"norm{i + 1}")
|
||||
x, H, W = patch_embed(x)
|
||||
for blk in block:
|
||||
x = blk(x)
|
||||
x = x.flatten(2).transpose(1, 2)
|
||||
x = norm(x)
|
||||
x = x.reshape(B, H, W, -1).permute(0, 3, 1, 2).contiguous()
|
||||
outs.append(x)
|
||||
return outs
|
||||
|
||||
def forward_last_features(self, x: torch.Tensor) -> torch.Tensor:
|
||||
return self.forward_features(x)[-1]
|
||||
|
||||
|
||||
def get_stripnet_small() -> StripNet:
|
||||
return StripNet(
|
||||
embed_dims=[64, 128, 320, 512],
|
||||
mlp_ratios=[8, 8, 4, 4],
|
||||
k1s=[1, 1, 1, 1],
|
||||
k2s=[19, 19, 19, 19],
|
||||
depths=[2, 2, 4, 2],
|
||||
drop_rate=0.1,
|
||||
drop_path_rate=0.15,
|
||||
)
|
||||
|
||||
|
||||
def load_stripnet_small_pretrained(checkpoint_path: str | Path) -> StripNet:
|
||||
"""Build StripNet-small and load ImageNet-pretrained weights.
|
||||
|
||||
Strips the classification `head.*` keys. Tolerates missing/extra keys
|
||||
(norm{N}.* are LayerNorm here vs BatchNorm in some forks — we keep LN).
|
||||
"""
|
||||
LOGGER.info("📐 Loading StripNet-small from %s", checkpoint_path)
|
||||
model = get_stripnet_small()
|
||||
raw = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
||||
state = raw.get("state_dict", raw) if isinstance(raw, dict) else raw
|
||||
|
||||
# Drop classification head + the BN-form norm{N} keys if present (we use LN here).
|
||||
drop_prefixes = ("head.",)
|
||||
cleaned = {k: v for k, v in state.items() if not any(k.startswith(p) for p in drop_prefixes)}
|
||||
|
||||
# The pretrained checkpoint stores norm{N} as BatchNorm2d (running_mean/var/num_batches_tracked).
|
||||
# Our code uses LayerNorm at this position. Strip BN running stats if found; copy weight/bias.
|
||||
for n in (1, 2, 3, 4):
|
||||
for suffix in ("running_mean", "running_var", "num_batches_tracked"):
|
||||
cleaned.pop(f"norm{n}.{suffix}", None)
|
||||
|
||||
missing, unexpected = model.load_state_dict(cleaned, strict=False)
|
||||
if missing:
|
||||
LOGGER.info("StripNet missing keys (expected for newly-init layers): %d", len(missing))
|
||||
if unexpected:
|
||||
LOGGER.info("StripNet unexpected keys (ignored): %d", len(unexpected))
|
||||
LOGGER.info("📐 StripNet-small loaded: %d params", sum(p.numel() for p in model.parameters()))
|
||||
return model
|
||||
48
vendor_reference/caption_test/src/models/stripnet_encoder.py
Normal file
48
vendor_reference/caption_test/src/models/stripnet_encoder.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""StripNet image encoder wrapper for the caption-test pipeline.
|
||||
|
||||
Exposes the same interface as DINOv3ViT: `forward(images) -> [B, embed_dim]`.
|
||||
StripNet's deepest stage produces [B, 512, H/32, W/32]; we apply global average
|
||||
pooling (GAP) and project to the target retrieval dimension via Linear(512→1024)
|
||||
to match DINOv3 native dim and keep TextFusionMLP unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from src.models.stripnet import StripNet, load_stripnet_small_pretrained
|
||||
|
||||
LOGGER = logging.getLogger("caption_test.stripnet_encoder")
|
||||
|
||||
|
||||
class StripNetEncoder(nn.Module):
|
||||
"""StripNet-small + GAP + projection to `out_dim`.
|
||||
|
||||
Frozen backbone (BatchNorm in eval mode); only the projection head and
|
||||
any injected Conv-MONA adapters are trainable.
|
||||
"""
|
||||
|
||||
LAST_STAGE_DIM = 512 # StripNet-small last stage embed dim
|
||||
|
||||
def __init__(self, checkpoint_path: str, out_dim: int = 1024) -> None:
|
||||
super().__init__()
|
||||
self.out_dim = out_dim
|
||||
self.backbone: StripNet = load_stripnet_small_pretrained(checkpoint_path)
|
||||
self.pool = nn.AdaptiveAvgPool2d(1)
|
||||
self.projection = nn.Linear(self.LAST_STAGE_DIM, out_dim)
|
||||
nn.init.trunc_normal_(self.projection.weight, std=0.02)
|
||||
nn.init.zeros_(self.projection.bias)
|
||||
|
||||
def train(self, mode: bool = True):
|
||||
"""Override: keep frozen backbone in eval mode (BN running stats stable)."""
|
||||
super().train(mode)
|
||||
# Frozen backbone always in eval; trainable adapters/projection follow `mode`.
|
||||
if not any(p.requires_grad for p in self.backbone.parameters()):
|
||||
self.backbone.eval()
|
||||
return self
|
||||
|
||||
def forward(self, images: torch.Tensor) -> torch.Tensor:
|
||||
feat = self.backbone.forward_last_features(images) # [B, 512, H/32, W/32]
|
||||
pooled = self.pool(feat).flatten(1) # [B, 512]
|
||||
return self.projection(pooled) # [B, out_dim]
|
||||
166
vendor_reference/caption_test/src/training/profiling.py
Normal file
166
vendor_reference/caption_test/src/training/profiling.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""PyTorch Profiler wrapper for training performance analysis.
|
||||
|
||||
Profiles the first N batches of training to identify bottlenecks
|
||||
in CUDA/CPU execution, memory allocation, and data loading.
|
||||
|
||||
Exports:
|
||||
- Chrome trace (viewable in chrome://tracing)
|
||||
- TensorBoard plugin data (if TB available)
|
||||
- Summary table to console
|
||||
|
||||
Usage:
|
||||
profiler = TrainingProfiler(output_dir, n_warmup=3, n_active=5)
|
||||
for batch_idx, batch in enumerate(loader):
|
||||
with profiler.step_context(batch_idx):
|
||||
# ... training step ...
|
||||
if profiler.is_done(batch_idx):
|
||||
break
|
||||
profiler.export()
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
from torch.profiler import ProfilerActivity, profile, schedule, tensorboard_trace_handler
|
||||
|
||||
LOGGER = logging.getLogger("caption_test.profiler")
|
||||
|
||||
|
||||
class TrainingProfiler:
|
||||
"""PyTorch profiler for first N training batches.
|
||||
|
||||
Args:
|
||||
output_dir: Directory for profiler output.
|
||||
n_warmup: Number of warmup steps (not profiled).
|
||||
n_active: Number of steps to actively profile.
|
||||
n_repeat: Number of profiling cycles.
|
||||
record_shapes: Record tensor shapes.
|
||||
profile_memory: Track memory allocation.
|
||||
with_stack: Record Python call stacks.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output_dir: str | Path,
|
||||
n_warmup: int = 3,
|
||||
n_active: int = 5,
|
||||
n_repeat: int = 1,
|
||||
record_shapes: bool = True,
|
||||
profile_memory: bool = True,
|
||||
with_stack: bool = False,
|
||||
) -> None:
|
||||
self.output_dir = Path(output_dir) / "profiler"
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.n_warmup = n_warmup
|
||||
self.n_active = n_active
|
||||
self.n_repeat = n_repeat
|
||||
self.total_steps = (n_warmup + n_active) * n_repeat
|
||||
|
||||
self._profiler = profile(
|
||||
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
|
||||
schedule=schedule(
|
||||
wait=0,
|
||||
warmup=n_warmup,
|
||||
active=n_active,
|
||||
repeat=n_repeat,
|
||||
),
|
||||
on_trace_ready=tensorboard_trace_handler(str(self.output_dir)),
|
||||
record_shapes=record_shapes,
|
||||
profile_memory=profile_memory,
|
||||
with_stack=with_stack,
|
||||
)
|
||||
self._started = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the profiler."""
|
||||
self._profiler.__enter__()
|
||||
self._started = True
|
||||
LOGGER.info(
|
||||
"Profiler started: %d warmup + %d active steps, output: %s",
|
||||
self.n_warmup, self.n_active, self.output_dir,
|
||||
)
|
||||
|
||||
def step(self) -> None:
|
||||
"""Signal end of a profiling step."""
|
||||
if self._started:
|
||||
self._profiler.step()
|
||||
|
||||
def is_done(self, batch_idx: int) -> bool:
|
||||
"""Check if profiling is complete."""
|
||||
return batch_idx >= self.total_steps
|
||||
|
||||
def export(self) -> None:
|
||||
"""Export profiling results and print summary."""
|
||||
if not self._started:
|
||||
return
|
||||
|
||||
self._profiler.__exit__(None, None, None)
|
||||
self._started = False
|
||||
|
||||
# Print key averages summary.
|
||||
summary = self._profiler.key_averages().table(
|
||||
sort_by="cuda_time_total", row_limit=20,
|
||||
)
|
||||
LOGGER.info("Profiler summary (top 20 by CUDA time):\n%s", summary)
|
||||
|
||||
# Export Chrome trace.
|
||||
trace_path = self.output_dir / "chrome_trace.json"
|
||||
self._profiler.export_chrome_trace(str(trace_path))
|
||||
LOGGER.info("Chrome trace exported: %s", trace_path)
|
||||
|
||||
# Memory summary if available.
|
||||
if torch.cuda.is_available():
|
||||
mem_summary = torch.cuda.memory_summary(abbreviated=True)
|
||||
summary_path = self.output_dir / "memory_summary.txt"
|
||||
summary_path.write_text(mem_summary)
|
||||
LOGGER.info("CUDA memory summary: %s", summary_path)
|
||||
|
||||
|
||||
def print_model_summary(model: torch.nn.Module, device: str = "cuda") -> str:
|
||||
"""Print model summary using torchinfo (if available).
|
||||
|
||||
Falls back to a simple parameter count if torchinfo is not installed.
|
||||
|
||||
Returns:
|
||||
Summary string.
|
||||
"""
|
||||
try:
|
||||
from torchinfo import summary as torchinfo_summary
|
||||
info = torchinfo_summary(
|
||||
model,
|
||||
input_data={
|
||||
"drone_img": torch.randn(1, 3, 256, 256, device=device),
|
||||
"sat_img": torch.randn(1, 3, 256, 256, device=device),
|
||||
},
|
||||
col_names=["input_size", "output_size", "num_params", "trainable"],
|
||||
verbose=0,
|
||||
depth=3,
|
||||
)
|
||||
summary_str = str(info)
|
||||
LOGGER.info("Model summary (torchinfo):\n%s", summary_str)
|
||||
return summary_str
|
||||
except ImportError:
|
||||
LOGGER.info("torchinfo not installed, using basic parameter count")
|
||||
except Exception as e:
|
||||
LOGGER.warning("torchinfo failed (%s), using basic parameter count", e)
|
||||
|
||||
# Fallback: simple param count.
|
||||
lines = []
|
||||
total = 0
|
||||
trainable = 0
|
||||
for name, param in model.named_parameters():
|
||||
total += param.numel()
|
||||
if param.requires_grad:
|
||||
trainable += param.numel()
|
||||
lines.append(f" [trainable] {name}: {list(param.shape)} ({param.numel():,})")
|
||||
|
||||
summary_str = (
|
||||
f"Total parameters: {total:,}\n"
|
||||
f"Trainable parameters: {trainable:,} ({100*trainable/max(total,1):.2f}%)\n"
|
||||
+ "\n".join(lines[:30])
|
||||
)
|
||||
LOGGER.info("Model summary:\n%s", summary_str)
|
||||
return summary_str
|
||||
206
vendor_reference/caption_test/src/training/trackers.py
Normal file
206
vendor_reference/caption_test/src/training/trackers.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Unified experiment tracking: W&B + TensorBoard + CSV.
|
||||
|
||||
Auto-detects available backends. Falls back gracefully if wandb/tensorboard
|
||||
are not installed.
|
||||
|
||||
Usage:
|
||||
tracker = ExperimentTracker(output_dir, config_dict, use_wandb=True, use_tb=True)
|
||||
tracker.log_train(epoch, {"loss": 0.5, "lr": 1e-4})
|
||||
tracker.log_val(epoch, {"r@1_q2g": 0.3})
|
||||
tracker.log_gradients(epoch, grad_norms_dict)
|
||||
tracker.log_image(epoch, "gradcam/drone", image_tensor)
|
||||
tracker.close()
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
LOGGER = logging.getLogger("caption_test.trackers")
|
||||
|
||||
|
||||
def _try_import_wandb():
|
||||
try:
|
||||
import wandb
|
||||
return wandb
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def _try_import_tb():
|
||||
try:
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
return SummaryWriter
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
class ExperimentTracker:
|
||||
"""Unified tracker dispatching to W&B, TensorBoard, and CSV.
|
||||
|
||||
Args:
|
||||
output_dir: Base output directory.
|
||||
config: Dict of hyperparameters to log.
|
||||
use_wandb: Enable Weights & Biases tracking.
|
||||
use_tb: Enable TensorBoard tracking.
|
||||
wandb_project: W&B project name.
|
||||
wandb_run_name: W&B run name (auto-generated if None).
|
||||
wandb_entity: W&B entity (team/user).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output_dir: str | Path,
|
||||
config: dict[str, Any] | None = None,
|
||||
use_wandb: bool = False,
|
||||
use_tb: bool = True,
|
||||
wandb_project: str = "caption-test-gtauav",
|
||||
wandb_run_name: str | None = None,
|
||||
wandb_entity: str | None = None,
|
||||
) -> None:
|
||||
self.output_dir = Path(output_dir)
|
||||
self._wandb_run = None
|
||||
self._tb_writer = None
|
||||
|
||||
# W&B init.
|
||||
if use_wandb:
|
||||
wandb = _try_import_wandb()
|
||||
if wandb is not None:
|
||||
self._wandb_run = wandb.init(
|
||||
project=wandb_project,
|
||||
name=wandb_run_name,
|
||||
entity=wandb_entity,
|
||||
config=config or {},
|
||||
dir=str(self.output_dir),
|
||||
reinit=True,
|
||||
)
|
||||
LOGGER.info("W&B initialized: %s", self._wandb_run.url)
|
||||
else:
|
||||
LOGGER.warning("wandb not installed, skipping W&B tracking")
|
||||
|
||||
# TensorBoard init.
|
||||
if use_tb:
|
||||
SummaryWriter = _try_import_tb()
|
||||
if SummaryWriter is not None:
|
||||
tb_dir = self.output_dir / "tb_logs"
|
||||
tb_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._tb_writer = SummaryWriter(log_dir=str(tb_dir))
|
||||
LOGGER.info("TensorBoard initialized: %s", tb_dir)
|
||||
else:
|
||||
LOGGER.warning("tensorboard not installed, skipping TB tracking")
|
||||
|
||||
@property
|
||||
def has_wandb(self) -> bool:
|
||||
return self._wandb_run is not None
|
||||
|
||||
@property
|
||||
def has_tb(self) -> bool:
|
||||
return self._tb_writer is not None
|
||||
|
||||
def log_train(self, epoch: int, metrics: dict[str, float], step: int | None = None) -> None:
|
||||
"""Log training metrics for an epoch."""
|
||||
if self._wandb_run is not None:
|
||||
self._wandb_run.log(
|
||||
{f"train/{k}": v for k, v in metrics.items()},
|
||||
step=step or epoch,
|
||||
)
|
||||
if self._tb_writer is not None:
|
||||
for k, v in metrics.items():
|
||||
self._tb_writer.add_scalar(f"train/{k}", v, global_step=step or epoch)
|
||||
|
||||
def log_val(self, epoch: int, metrics: dict[str, float], step: int | None = None) -> None:
|
||||
"""Log validation metrics."""
|
||||
if self._wandb_run is not None:
|
||||
self._wandb_run.log(
|
||||
{f"val/{k}": v for k, v in metrics.items()},
|
||||
step=step or epoch,
|
||||
)
|
||||
if self._tb_writer is not None:
|
||||
for k, v in metrics.items():
|
||||
self._tb_writer.add_scalar(f"val/{k}", v, global_step=step or epoch)
|
||||
|
||||
def log_gradients(self, epoch: int, grad_norms: dict[str, float], step: int | None = None) -> None:
|
||||
"""Log gradient norms per parameter group."""
|
||||
if self._wandb_run is not None:
|
||||
self._wandb_run.log(
|
||||
{f"gradients/{k}": v for k, v in grad_norms.items()},
|
||||
step=step or epoch,
|
||||
)
|
||||
if self._tb_writer is not None:
|
||||
for k, v in grad_norms.items():
|
||||
self._tb_writer.add_scalar(f"gradients/{k}", v, global_step=step or epoch)
|
||||
|
||||
def log_scalar(self, tag: str, value: float, step: int) -> None:
|
||||
"""Log a single scalar."""
|
||||
if self._wandb_run is not None:
|
||||
self._wandb_run.log({tag: value}, step=step)
|
||||
if self._tb_writer is not None:
|
||||
self._tb_writer.add_scalar(tag, value, global_step=step)
|
||||
|
||||
def log_image(self, tag: str, image: Any, step: int, caption: str | None = None) -> None:
|
||||
"""Log an image (numpy HWC or torch CHW).
|
||||
|
||||
Args:
|
||||
tag: Image tag/name.
|
||||
image: numpy array [H,W,C] or torch tensor [C,H,W].
|
||||
step: Global step.
|
||||
caption: Optional caption for W&B.
|
||||
"""
|
||||
if self._wandb_run is not None:
|
||||
wandb = _try_import_wandb()
|
||||
if isinstance(image, torch.Tensor):
|
||||
image_np = image.detach().cpu().permute(1, 2, 0).numpy()
|
||||
else:
|
||||
image_np = image
|
||||
self._wandb_run.log(
|
||||
{tag: wandb.Image(image_np, caption=caption)},
|
||||
step=step,
|
||||
)
|
||||
if self._tb_writer is not None:
|
||||
if isinstance(image, torch.Tensor):
|
||||
self._tb_writer.add_image(tag, image.detach().cpu(), global_step=step)
|
||||
else:
|
||||
self._tb_writer.add_image(tag, image, global_step=step, dataformats="HWC")
|
||||
|
||||
def log_histogram(self, tag: str, values: torch.Tensor, step: int) -> None:
|
||||
"""Log a histogram of values (weights, activations, etc.)."""
|
||||
if self._wandb_run is not None:
|
||||
wandb = _try_import_wandb()
|
||||
self._wandb_run.log(
|
||||
{tag: wandb.Histogram(values.detach().cpu().numpy())},
|
||||
step=step,
|
||||
)
|
||||
if self._tb_writer is not None:
|
||||
self._tb_writer.add_histogram(tag, values.detach().cpu(), global_step=step)
|
||||
|
||||
def log_model_graph(self, model: torch.nn.Module, input_example: Any = None) -> None:
|
||||
"""Log model graph to TensorBoard (if available)."""
|
||||
if self._tb_writer is not None and input_example is not None:
|
||||
try:
|
||||
self._tb_writer.add_graph(model, input_example)
|
||||
except Exception as e:
|
||||
LOGGER.warning("Failed to log model graph: %s", e)
|
||||
|
||||
def watch_model(self, model: torch.nn.Module, log_freq: int = 100) -> None:
|
||||
"""Enable W&B gradient/weight watching."""
|
||||
if self._wandb_run is not None:
|
||||
wandb = _try_import_wandb()
|
||||
wandb.watch(model, log="all", log_freq=log_freq)
|
||||
|
||||
def log_summary(self, summary: dict[str, Any]) -> None:
|
||||
"""Log final summary metrics (best R@1, etc.)."""
|
||||
if self._wandb_run is not None:
|
||||
for k, v in summary.items():
|
||||
self._wandb_run.summary[k] = v
|
||||
|
||||
def close(self) -> None:
|
||||
"""Flush and close all backends."""
|
||||
if self._tb_writer is not None:
|
||||
self._tb_writer.flush()
|
||||
self._tb_writer.close()
|
||||
if self._wandb_run is not None:
|
||||
self._wandb_run.finish()
|
||||
1297
vendor_reference/caption_test/src/training/train_gtauav.py
Normal file
1297
vendor_reference/caption_test/src/training/train_gtauav.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user