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,247 @@
from __future__ import annotations
"""GTA-UAV-LR dataset loader with L1/L2/L3 hierarchical captions for CVGL.
Reads cross-area pair JSONs and VLM-generated caption JSONs.
Produces (drone_img, sat_img, caption_l1, caption_l2, caption_l3) tuples.
Caption levels:
L1 (overview): First sentence of P1 (land cover summary).
L2 (full): Complete P1 + P2 text.
L3 (fingerprint): P3 unique signature section.
For short captions (pure water, no P markers): all levels get the same short text.
"""
import json
import logging
import random
import re
from pathlib import Path
from typing import Any, Callable
import coloredlogs
import torch
from PIL import Image
from torch.utils.data import Dataset
from tqdm import tqdm
LOGGER = logging.getLogger("caption_test.gtauav_dataset")
coloredlogs.install(level="INFO", logger=LOGGER, fmt="%(asctime)s %(name)s %(levelname)s %(message)s")
# Default paths.
_RGB_ROOT = Path("/home/servml/Документы/datasets/GTA-UAV-LR")
_CAPTION_ROOT = Path("/home/servml/Документы/datasets/GTA-UAV-LR-captions")
_EMPTY_CAPTION = ""
# Regex to split P1/P2/P3 sections.
_P_SPLIT = re.compile(r"\*\*P[123][^*]*\*\*\s*:?\s*")
def _parse_caption_levels(output: str) -> tuple[str, str, str]:
"""Split VLM caption output into L1, L2, L3 levels.
Returns:
(l1_overview, l2_full, l3_fingerprint)
"""
sections = _P_SPLIT.split(output)
# sections[0] is empty (before **P1**), sections[1]=P1, [2]=P2, [3]=P3
sections = [s.strip() for s in sections if s.strip()]
if len(sections) >= 3:
p1, p2, p3 = sections[0], sections[1], sections[2]
elif len(sections) == 2:
p1, p2, p3 = sections[0], sections[1], sections[0]
elif len(sections) == 1:
p1 = p2 = p3 = sections[0]
else:
p1 = p2 = p3 = output.strip()
# L1: first sentence of P1.
first_dot = p1.find(". ")
l1 = p1[:first_dot + 1] if first_dot > 0 else p1
# L2: full P1 + P2.
l2 = p1 + " " + p2
# L3: P3 (fingerprint / unique signature).
l3 = p3
return l1, l2, l3
def _load_caption_index(caption_root: Path) -> dict[str, dict]:
"""Build index: image_name -> caption JSON data.
Scans drone/images/ and satellite/ directories.
"""
index: dict[str, dict] = {}
for subdir in ["drone/images", "satellite"]:
cap_dir = caption_root / subdir
if not cap_dir.exists():
continue
cap_files = sorted(cap_dir.glob("*_caption.json"))
for cap_file in tqdm(cap_files, desc=f" 📄 Loading {subdir} captions", unit="file", leave=False):
with open(cap_file) as f:
data = json.load(f)
# Key by the image name (without _caption suffix).
img_name = cap_file.name.replace("_caption.json", ".png")
index[img_name] = data
return index
class GTAUAVDataset(Dataset):
"""GTA-UAV-LR dataset with hierarchical L1/L2/L3 captions.
Args:
pair_json: Path to cross-area-drone2sate-{train,test}.json.
rgb_root: Root of GTA-UAV-LR RGB images.
caption_root: Root of GTA-UAV-LR-captions.
image_transform: Callable applied to PIL images.
filter_meta: Path to seg_filter.json (exclude 90%+ bg/water).
drop_caption_prob: Probability of dropping captions (ablation).
seed: Random seed.
"""
def __init__(
self,
pair_json: str,
rgb_root: str = str(_RGB_ROOT),
caption_root: str = str(_CAPTION_ROOT),
image_transform: Callable[[Image.Image], torch.Tensor] | None = None,
filter_meta: str | None = None,
drop_caption_prob: float = 0.0,
seed: int = 0,
) -> None:
self.rgb_root = Path(rgb_root)
self.caption_root = Path(caption_root)
self.image_transform = image_transform
self.drop_caption_prob = drop_caption_prob
self._rng = random.Random(seed)
# Load exclusion set from segmentation filter.
self.excluded: set[str] = set()
if filter_meta is not None:
self._load_filter(Path(filter_meta))
# Load caption index.
LOGGER.info("📚 Loading caption index from %s", caption_root)
self.caption_index = _load_caption_index(self.caption_root)
LOGGER.info("📚 Caption index: %d entries", len(self.caption_index))
# Load pairs.
self.entries: list[dict[str, Any]] = []
self._load_pairs(Path(pair_json))
LOGGER.info("✅ Loaded %d pairs from %s", len(self.entries), pair_json)
def _load_filter(self, path: Path) -> None:
with open(path) as f:
meta = json.load(f)
# excluded list contains paths like "drone/images/xxx.png"
for exc in meta.get("excluded", []):
# Extract image name from segm path.
self.excluded.add(Path(exc).name)
LOGGER.info("🔻 Filter loaded: %d excluded images", len(self.excluded))
def _load_pairs(self, pair_json: Path) -> None:
with open(pair_json) as f:
raw_pairs = json.load(f)
for pair in raw_pairs:
drone_name = pair["drone_img_name"]
# Skip excluded images.
if drone_name in self.excluded:
continue
# Get positive/semi-positive satellite images.
pos_list = pair.get("pair_pos_sate_img_list", [])
semipos_list = pair.get("pair_pos_semipos_sate_img_list", [])
semipos_weights = pair.get("pair_pos_semipos_sate_weight_list", [])
# Use positives if available, else semi-positives.
if pos_list:
sat_candidates = pos_list
sat_weights = None
elif semipos_list:
sat_candidates = semipos_list
sat_weights = semipos_weights if semipos_weights else None
else:
continue # No match, skip.
# Get captions.
cap_data = self.caption_index.get(drone_name)
if cap_data is not None:
l1, l2, l3 = _parse_caption_levels(cap_data["output"])
else:
l1 = l2 = l3 = _EMPTY_CAPTION
self.entries.append({
"drone_name": drone_name,
"drone_dir": pair["drone_img_dir"],
"sat_dir": pair["sate_img_dir"],
"sat_candidates": sat_candidates,
"sat_weights": sat_weights,
"caption_l1": l1,
"caption_l2": l2,
"caption_l3": l3,
})
def _load_image(self, directory: str, filename: str) -> torch.Tensor:
path = self.rgb_root / directory / filename
with Image.open(path) as img:
rgb = img.convert("RGB")
if self.image_transform is not None:
return self.image_transform(rgb)
return torch.tensor(0) # placeholder if no transform
def __len__(self) -> int:
return len(self.entries)
def __getitem__(self, idx: int) -> dict[str, Any]:
entry = self.entries[idx]
drone_img = self._load_image(entry["drone_dir"], entry["drone_name"])
# Sample satellite match (weighted if semi-positive).
if entry["sat_weights"] is not None:
sat_name = self._rng.choices(
entry["sat_candidates"],
weights=entry["sat_weights"],
k=1,
)[0]
else:
sat_name = self._rng.choice(entry["sat_candidates"])
sat_img = self._load_image(entry["sat_dir"], sat_name)
# Captions with optional dropout.
if self.drop_caption_prob > 0 and self._rng.random() < self.drop_caption_prob:
l1 = l2 = l3 = _EMPTY_CAPTION
else:
l1, l2, l3 = entry["caption_l1"], entry["caption_l2"], entry["caption_l3"]
return {
"drone_img": drone_img,
"sat_img": sat_img,
"caption_l1": l1,
"caption_l2": l2,
"caption_l3": l3,
"pair_id": entry["drone_name"],
}
def collate_gtauav_batch(
batch: list[dict[str, Any]],
) -> dict[str, Any]:
"""Collate into batched dict. Captions stay as string lists."""
return {
"drone_img": torch.stack([b["drone_img"] for b in batch], dim=0),
"sat_img": torch.stack([b["sat_img"] for b in batch], dim=0),
"caption_l1": [b["caption_l1"] for b in batch],
"caption_l2": [b["caption_l2"] for b in batch],
"caption_l3": [b["caption_l3"] for b in batch],
"pair_ids": [b["pair_id"] for b in batch],
}