Rewrite: GatedFusion architecture + UAV-GeoLoc dataset

Architecture v2:
- Query branch: drone + text -> GatedFusion -> proj -> query_emb
- Gallery branch: satellite -> proj -> gallery_emb
- Single InfoNCE loss (asymmetric 0.6/0.4)
- GatedFusion: learnable gated addition (sigma(alpha)*img + (1-sigma(alpha))*text)
- Baseline mode: gate=1.0 (text ignored)

Dataset:
- UAV-GeoLoc loader with template captions from path metadata
- 27 terrain types with predefined features
- Random positive crop sampling per epoch

Configs: balanced (gate=0.7), baseline (no text), text_heavy (gate=0.3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pikaliov
2026-04-17 17:13:00 +03:00
parent 2ce4017ebd
commit abb3337f8d
12 changed files with 1077 additions and 781 deletions

208
CLAUDE.md Normal file
View File

@@ -0,0 +1,208 @@
# Caption Quality Test for Cross-View Geo-Localization
## Архитектура системы (v2, 2026-04-17)
```
QUERY BRANCH (drone + caption):
drone_img --> GeoRSCLIP ViT-B/32 (frozen) --> drone_feat [B,512]
caption --> GeoRSCLIP Text (partial unfreeze) --> text_feat [B,512]
|
GatedFusion: q = sigma(alpha)*drone + (1-sigma(alpha))*text
|
proj_query (Linear 512->512) --> L2-norm --> query [B,512]
GALLERY BRANCH (satellite only):
sat_img --> GeoRSCLIP ViT-B/32 (frozen) --> sat_feat [B,512]
|
proj_gallery (Linear 512->512) --> L2-norm --> gallery [B,512]
LOSS: InfoNCE(query, gallery) — symmetric, asymmetric weights (0.6 q->g, 0.4 g->q)
BASELINE: gate = 1.0 (text ignored)
```
### Trainable parameters: ~1.2M из ~151M (proj_query + proj_gallery + fusion alpha + text last_block)
## Ключевые файлы
| Файл | Назначение |
|------|-----------|
| `src/models/dual_encoder.py` | GeoRSCLIP + GatedFusion + projection heads |
| `src/losses/multi_infonce.py` | InfoNCE с cosine temperature schedule |
| `src/datasets/visloc_with_captions.py` | UAV-GeoLoc loader + template captions из path metadata |
| `src/training/train.py` | Training loop, логирование loss/gate/tau |
| `src/eval/evaluate.py` | R@K metrics, delta_r_at_1 |
| `scripts/compare_runs.py` | Markdown/JSON сравнение baseline vs caption runs |
| `scripts/generate_captions.py` | Offline caption generation (template/VLM/hybrid) |
## Backbone: GeoRSCLIP ViT-B/32
- **Checkpoint:** `checkpoints/RS5M_ViT-B-32.pt` (скачать с github.com/om-ai-lab/RS5M)
- **Image encoder:** ViT-B/32, 224x224, 512-dim, ~86M params — frozen
- **Text encoder:** CLIP text transformer, 77 tokens, 512-dim — partial unfreeze (last_block + text_projection)
- **Throughput:** ~4000 img/s на RTX 4090 (AMP, batch 128)
- **Выбран вместо SigLIP 2** (ViT-SO400M, 384px, ~400M): в 7-10x быстрее, domain-specific (обучен на 5M RS-изображений), больше batch = больше негативов в InfoNCE
## GatedFusion
- `query = sigma(alpha) * drone_feat + (1 - sigma(alpha)) * text_feat`
- `alpha` — один learnable scalar в logit-space
- `init_gate = 0.7` → начальный вес image = 70%, text = 30%
- `baseline_mode = True` → gate = 1.0, text полностью игнорируется
- Gate value логируется каждую эпоху для интерпретации вклада текста
## Датасет: UAV-GeoLoc
- **Путь:** `/mnt/data1tb/cvgl_datasets/UAV-GeoLoc/`
- **Train:** 206,108 queries, 94,709 DB crops (140 scenes, Terrain split)
- **Val:** 62,368 queries, 26,597 DB crops (40 scenes)
- **Test:** 33,472 queries, 11,684 DB crops (20 scenes)
- **Структура:** `Terrain/{type}/{scene}/query/height{N}_rot{M}/footage/{file}.jpeg`
- **Index:** `Index/train_query.txt``query_path 0 pos_crop1 pos_crop2 ...`
### Template captions (из path metadata)
Формат: `"Aerial view at {height}m facing {heading} over {terrain} terrain near {scene}. Plan-view features: {features}."`
Пример: `"Aerial view at 100m facing northwest over volcanic terrain near KilaueaVolcano. Plan-view features: lava flows, crater edges, volcanic rock."`
Metadata извлекается из пути:
- `Terrain/Volcano/KilaueaVolcano/query/height100_rot315/...` → terrain=Volcano, scene=KilaueaVolcano, height=100, heading=northwest
- 27 terrain типов с predefined features (Volcano, Mountain, Hill, Desert, Plain, ...)
- Country subset: features = "buildings, roads, urban blocks, rooftops, intersections"
## Конфигурации (gin)
| Конфиг | Gate init | Описание |
|--------|-----------|----------|
| `conf/balanced.gin` | 0.7 (30% text) | **Primary test** |
| `conf/baseline_no_text.gin` | 1.0 (no text) | Reference baseline |
| `conf/text_heavy.gin` | 0.3 (70% text) | Stress test |
Общие параметры: 10 epochs, batch 128, lr=1e-4, AMP, cosine LR schedule, eval every 2 epochs.
## Запуск
```bash
# 1. Baseline (no text)
python -m src.training.train --config conf/baseline_no_text.gin
# 2. With captions (primary test)
python -m src.training.train --config conf/balanced.gin
# 3. Text-heavy (stress test)
python -m src.training.train --config conf/text_heavy.gin
# 4. Compare
python -m scripts.compare_runs \
--baseline_report out/caption_test/baseline_no_text/eval_report.json \
--full_report out/caption_test/balanced/eval_report.json \
--output out/caption_test/comparison.md
```
## Метрики и Decision rule
**Primary metric:** Delta R@1 (query -> gallery)
| Delta R@1 | Verdict |
|-----------|---------|
| >= +3% | PASS — captions informative, proceed to production |
| +1% to +3% | MARGINAL — add VLM refinement, re-run |
| 0 to +1% | WEAK — redesign caption pipeline |
| < 0 | HARMFUL — critical bug |
**Logged per epoch:** loss, temperature (tau), gate value (sigma(alpha)), lr
**Eval metrics:** R@1, R@5, R@10 для query->gallery и gallery->query
## Бюджет времени (RTX 4090, 24 GB)
| Фаза | Время |
|------|-------|
| Один training run (10 epochs, 206K queries, batch 128) | ~15-30 мин |
| Full test (3 варианта) | ~1-1.5 ч |
| Evaluation per run | ~2-5 мин |
## Связанные проекты
### Text Annotation Pipeline
- **Путь:** `/home/servml/Документы/pikaliov_obsidian/(Полякова ВЕ_Система для генерации текстовых описаний для БПЛА)/2_work/2_text_annotation/code/`
- **VLM:** Qwen3-VL-8B AWQ (1.68 s/img)
- **Scoring:** SigLIP 2 (drone P3) + CLIP-RSICD (satellite P1+P2)
- **Формат описаний:** 3 абзаца (P1 Inventory + P2 Spatial Map + P3 Fingerprint)
- **Метрики:** FDR, FNR, NumAcc, LLaVA-Critic C1-C6
### UAV-VisLoc Prepare
- **Путь:** `/home/servml/Документы/code/Yaroslav/UAV-VisLoc-prepare/scripts/prepare_dataset.py`
- **Статус:** код готов, ещё не запускался
- **Задача:** нарезка satellite кропов 512x512, stride 256 для UAV-VisLoc dataset
- **Подробности:** см. ниже
---
## Датасеты (справочник)
### UAV-VisLoc
- **Путь:** `/home/servml/Документы/datasets/UAV_VisLoc_dataset/`
- **Структура:** 11 маршрутов (папки `01`-`11`), каждая содержит:
- `drone/` — drone-снимки (`XX_NNNN.JPG`)
- `satelliteXX.tif` — спутниковая карта
- `XX.csv` — GPS-метаданные drone (num, filename, date, lat, lon, height, Omega, Kappa, Phi1, Phi2)
- **Исключение:** маршрут `09` — спутник разбит на 4 тайла (`satellite09_01-01.tif` и т.д.)
- **Satellite coordinates:** `satellite_ coordinates_range.csv` — bbox каждой карты (LT_lat_map, LT_lon_map, RB_lat_map, RB_lon_map)
- **Splits:** `visloc_train.csv`, `visloc_test.csv` — списки drone-снимков (TSV, full absolute paths)
- **Размеры:** Drone 3976x2652 / 3000x2000 (6774 снимков), Satellite от 3000x170 до 43421x38408
- **GSD спутника:** ~0.30 м/px (единый zoom level Google Earth для всех карт). GSD по долготе варьируется 0.23-0.27 м/px из-за косинусного эффекта широты (40°N vs 25°N), но это не разная высота съёмки. Кроп 512x512 покрывает ~154x154 м везде.
### UAV-GeoLoc
- **Путь:** `/mnt/data1tb/cvgl_datasets/UAV-GeoLoc/`
- **Подмножества:** Country (171 scene), Terrain (200 scenes), Rot (1 scene)
- **Формат пар:** `positive.json`, `semi_positive.json`, `db_postion.txt`
- **Index:** `train_query.txt``query_path label pos_crop1 pos_crop2 ...`
- **Drone:** синтетика Google Earth Studio 3D, 512x512, FOV 30deg, heights 100/125/150m
- **Satellite:** кропы из merge.tif, доминирующий размер 200x200
## Скрипт подготовки UAV-VisLoc
- **Путь:** `/home/servml/Документы/code/Yaroslav/UAV-VisLoc-prepare/scripts/prepare_dataset.py`
- **Статус:** код готов, ещё не запускался
### Запуск
```bash
python scripts/prepare_dataset.py \
--src /home/servml/Документы/datasets/UAV_VisLoc_dataset \
--dst /home/servml/Документы/datasets/UAV_VisLoc_processed \
--crop-size 512 --stride 256 --target-size 256
```
### Pipeline
1. Resize drone -> 256x256 (JPEG, quality=95)
2. Stitch satellite tiles для маршрута 09 (4 тайла -> 44800x33280)
3. Нарезка satellite -> кропы 512x512, stride 256, resize -> 256x256 (PNG)
4. GPS для каждого кропа из bbox карты + позиция в grid
5. Match drone->crop через vectorized haversine (positive = ближайший, semi-positive = +/-1 в grid)
6. Metadata: `positive.json`, `semi_positive.json`, `db_postion.txt` (per route)
7. Index: `train_query.txt`, `test_query.txt`, `train_db.txt`, `test_db.txt`, `all_db.txt`
### Форматы выходных файлов (совместимость с UAV-GeoLoc)
| Файл | Формат |
|------|--------|
| `positive.json` | `{frame_id: [crop_name]}`, ключ = frame ID без route prefix (`"0001"`) |
| `semi_positive.json` | `{frame_id: [crop1, crop2, ...]}`, соседи +/-1 в grid |
| `db_postion.txt` | tab-separated: `name\tlon\tlat\tscale_lon\tscale_lat` |
| `train_query.txt` | `route/drone/file.JPG 0 route/DB/img/crop1.png ...` |
| `train_db.txt` / `test_db.txt` | все кропы всех маршрутов (gallery одинаковая, split по query) |
### Ожидаемые объёмы
- Drone: 6744 (без маршрута 07: 30 images excluded)
- Satellite кропов: ~74,807
- Память: до ~4.5 GB RAM (маршрут 09 stitched 44800x33280)
### Ревью и исправления (2026-04-17)
1. `train_db.txt`/`test_db.txt` содержали только matched кропы -> теперь все ~74K (полная gallery)
2. `db_position.txt` -> `db_postion.txt` (совместимость с UAV-GeoLoc), добавлены scale_lon/scale_lat, tab-separator
3. `positive.json` ключи были filename (`01_0001.JPG`) -> теперь frame_id (`0001`)
4. Semi-positive поиск O(n) -> O(1) через dict lookup по (x,y) grid
5. Удален мёртвый код (`haversine_m`, `defaultdict` import)
### Известные ограничения
- Нет val split (только train/test, как в оригинальном UAV-VisLoc)
- Большие спутниковые карты загружаются целиком в RAM (до 4.5 GB для route 09)

140
README.md
View File

@@ -1,33 +1,42 @@
# Caption Quality Test on UAV-VisLoc # Caption Quality Test for Cross-View Geo-Localization
Validate generated text captions by measuring retrieval R@1 lift on UAV-VisLoc. Validate whether generated text captions improve retrieval R@1 in cross-view
Uses GeoRSCLIP ViT-B/32 dual encoder with multi-term InfoNCE. geo-localization (drone-to-satellite). Uses GeoRSCLIP ViT-B/32 dual encoder
with GatedFusion on the query branch.
Full analysis: `2_hypotesis/АНАЛИЗ_caption_quality_test_VisLoc.md` ## Architecture
```
Query: drone_img + caption -> GatedFusion -> proj -> query_emb
Gallery: sat_img -> proj -> gallery_emb
Loss: InfoNCE(query, gallery)
```
Baseline: fusion gate = 1.0 (text ignored).
## Structure ## Structure
``` ```
caption_test/ caption_test/
├── conf/ ├── conf/
│ ├── balanced.gin # Primary test: λ=(1.0, 0.3, 0.3, 0.1) │ ├── balanced.gin # Primary: gate init 0.7 (30% text)
│ ├── baseline_no_text.gin # Reference: λ=(1.0, 0, 0, 0) │ ├── baseline_no_text.gin # Reference: gate = 1.0 (no text)
│ └── text_heavy.gin # Stress test: λ=(0.5, 0.5, 0.5, 0.2) │ └── text_heavy.gin # Stress: gate init 0.3 (70% text)
├── scripts/ ├── scripts/
│ ├── generate_captions.py # Offline caption generation (template/VLM/hybrid) │ ├── generate_captions.py # Offline caption generation
│ └── compare_runs.py # Δ R@1 comparison report builder │ └── compare_runs.py # Delta R@1 comparison report
├── src/ ├── src/
│ ├── datasets/ │ ├── datasets/
│ │ └── visloc_with_captions.py │ │ └── visloc_with_captions.py # UAV-GeoLoc loader + template captions
│ ├── models/ │ ├── models/
│ │ └── dual_encoder.py # GeoRSCLIP wrapper, projection heads │ │ └── dual_encoder.py # GeoRSCLIP + GatedFusion + projection heads
│ ├── losses/ │ ├── losses/
│ │ └── multi_infonce.py # 4-term InfoNCE + curriculum + cosine τ │ │ └── multi_infonce.py # InfoNCE with cosine temperature
│ ├── training/ │ ├── training/
│ │ └── train.py # Main loop │ │ └── train.py # Main training loop
│ └── eval/ │ └── eval/
│ └── evaluate.py # R@K metrics, Δ R@1 helper │ └── evaluate.py # R@K metrics, Delta R@1
└── data/ # (user-provided) VisLoc pairs + captions └── checkpoints/ # RS5M_ViT-B-32.pt (user-provided)
``` ```
## Prerequisites ## Prerequisites
@@ -40,99 +49,66 @@ Pillow
numpy numpy
``` ```
GeoRSCLIP ViT-B/32 checkpoint: download `RS5M_ViT-B-32.pt` from GeoRSCLIP checkpoint: download `RS5M_ViT-B-32.pt` from
`github.com/om-ai-lab/RS5M` and place under `checkpoints/`. `github.com/om-ai-lab/RS5M` and place under `checkpoints/`.
## Workflow ## Workflow
### 1. Generate captions ### 1. Train baseline (no text)
```bash ```bash
python -m scripts.generate_captions \
--image_root data/visloc/images \
--pairs_csv data/visloc/pairs.csv \
--output data/visloc_train.json \
--strategy hybrid \
--vlm_refine_ratio 0.1
```
Replace `_placeholder_vlm_caption` in `scripts/generate_captions.py` with real
Qwen2.5-VL or InternVL2 inference before running on production data.
### 2. Train three variants (in parallel or sequentially)
```bash
# Baseline (no captions)
python -m src.training.train --config conf/baseline_no_text.gin python -m src.training.train --config conf/baseline_no_text.gin
```
# Balanced (primary, with captions) ### 2. Train with captions
```bash
python -m src.training.train --config conf/balanced.gin python -m src.training.train --config conf/balanced.gin
# Text-heavy (stress test)
python -m src.training.train --config conf/text_heavy.gin
``` ```
### 3. Evaluate each on test split ### 3. Compare and get verdict
```python
from src.eval.evaluate import run_evaluation_from_checkpoint
run_evaluation_from_checkpoint(
checkpoint_path="out/caption_test/balanced/ckpt_epoch029.pt",
test_manifest="data/visloc_test.json",
image_root="data/visloc/images",
output_path="out/caption_test/balanced/eval_report.json",
)
```
### 4. Compare and get verdict
```bash ```bash
python -m scripts.compare_runs \ python -m scripts.compare_runs \
--baseline_report out/caption_test/baseline_no_text/eval_report.json \ --baseline_report out/caption_test/baseline_no_text/eval_report.json \
--full_report out/caption_test/balanced/eval_report.json \ --full_report out/caption_test/balanced/eval_report.json \
--output out/caption_test/comparison.md --output out/caption_test/comparison.md
``` ```
## Decision rule (from `compare_runs.py`) ## Decision rule
| Δ R@1 (drone→sat) | Verdict | | Delta R@1 (query->gallery) | Verdict |
|---|---| |---|---|
| +3% | PASS captions informative, proceed to World-UAV | | >= +3% | PASS -- captions informative, proceed to production |
| +1% to +3% | ⚠️ MARGINAL add VLM refinement, re-run | | +1% to +3% | MARGINAL -- add VLM refinement, re-run |
| 0 to +1% | WEAK redesign caption pipeline | | 0 to +1% | WEAK -- redesign caption pipeline |
| < 0 | ❌❌ HARMFUL critical bug | | < 0 | HARMFUL -- critical bug |
## Expected runtime (RTX 4090, 24 GB) ## Expected runtime (RTX 4090, 24 GB)
| Phase | Time | | Phase | Time |
|---|---| |---|---|
| Caption generation (6K pairs, hybrid) | ~1 h | | Single training run (10 epochs, batch 128, 206K queries) | ~15-30 min |
| Single training run (30 epochs, batch 128) | ~23 h | | Full test (baseline + balanced + text_heavy) | ~1-1.5 h |
| Full test (3 variants × 3 seeds = 9 runs) | ~30 h | | Evaluation | ~2-5 min per run |
| Evaluation + comparison | ~30 min |
## Notes on code style ## Dataset
Follows NADEZHDA code style: UAV-GeoLoc Terrain split (from `/mnt/data1tb/cvgl_datasets/UAV-GeoLoc/`):
- `from __future__ import annotations` everywhere. - Train: 206,108 queries, 94,709 DB crops (140 scenes)
- Type hints on all signatures. - Val: 62,368 queries, 26,597 DB crops (40 scenes)
- Google-style docstrings. - Test: 33,472 queries, 11,684 DB crops (20 scenes)
- `@gin.configurable` on top-level classes.
- Atomic checkpoint saves (`_atomic_save` helper).
- No emojis in code, English-only code comments.
## Relation to NADEZHDA main pipeline Template captions generated automatically from path metadata:
```
"Aerial view at 100m facing northwest over volcanic terrain near KilaueaVolcano.
Plan-view features: lava flows, crater edges, volcanic rock."
```
This is an **isolated experimental track**: completely separate from the main ## Code style
Student/Teacher training under `code_nadezhda/`. Once captions pass the
Δ R@1 ≥ +3% gate here, the hybrid caption generation strategy is applied to
World-UAV 927K, and those captions feed into E1 Teacher training with
Multi-FiLM conditioning (see `АНАЛИЗ_fusion_для_NADEZHDA.md`).
## Files referenced - `from __future__ import annotations` everywhere
- Type hints on all signatures
- `2_hypotesis/АНАЛИЗ_caption_quality_test_VisLoc.md` — full experimental design - Google-style docstrings
- `2_hypotesis/АНАЛИЗ_text_encoder_для_NADEZHDA.md` — why GeoRSCLIP - `@gin.configurable` on top-level classes
- `2_hypotesis/АНАЛИЗ_fusion_для_NADEZHDA.md` — where captions land in Teacher - No emojis in code, English-only comments
- `2_hypotesis/ROADMAP_E0_E9_unified.md` — phase E_caption (parallel to E0)

View File

@@ -0,0 +1,378 @@
# АНАЛИЗ ДАТАСЕТА: UAV-VisLoc
**Дата анализа:** 2026-04-17
**Метод:** Эмпирический анализ данных на диске + статья arXiv:2405.11936 + GitHub-репозиторий авторов
**Путь к данным:** `/home/servml/Документы/datasets/UAV_VisLoc_dataset/`
---
## 1. МЕТАДАННЫЕ
| Поле | Значение |
|------|----------|
| Полное название | UAV-VisLoc: A Large-scale Dataset for UAV Visual Localization |
| Авторы | Wenjia Xu, Yaxuan Yao, Jiaqi Cao, Zhiwei Wei, Chunbo Liu, Jiuniu Wang, Mugen Peng (BUPT + CAS + CityU HK) |
| Год, Venue | 2024, arXiv:2405.11936 [cs.CV] |
| Код | https://github.com/IntelliSensing/UAV-VisLoc (только README + ссылки) |
| Данные | Google Drive / Baidu Net Disk (16.4 GB) |
| Общий объём на диске | ~16.4 GB |
---
## 2. ОБЩАЯ СТАТИСТИКА
### 2.1. Сводка
| Параметр | Значение |
|----------|----------|
| Drone-изображений | **6 774** |
| Спутниковых карт | **11** (+ маршрут 09 разбит на 4 тайла) |
| Маршрутов (flights) | 11 |
| Регионов (Китай) | 7 провинций/районов |
| Типов БПЛА | 2 (multi-rotor + fixed-wing) |
| Сезоны съёмки | 2 (лето, осень) |
| Временной охват | 20162023 |
### 2.2. Разбиение train / test
| Split | Изображений | Доля |
|-------|-------------|------|
| Train | 5 080 | 75.0% |
| Test | 1 694 | 25.0% |
| **Итого** | **6 774** | 100% |
Разбиение — **случайное по изображениям** внутри каждого маршрута (не по маршрутам!):
| Маршрут | Всего | Train | Test | Train% |
|---------|-------|-------|------|--------|
| 01 | 817 | 620 | 197 | 75.9% |
| 02 | 1 071 | 829 | 242 | 77.4% |
| 03 | 768 | 566 | 202 | 73.7% |
| 04 | 738 | 543 | 195 | 73.6% |
| 05 | 473 | 345 | 128 | 72.9% |
| 06 | 344 | 261 | 83 | 75.9% |
| 07 | 30 | 20 | 10 | 66.7% |
| 08 | 1 033 | 796 | 237 | 77.1% |
| 09 | 766 | 551 | 215 | 71.9% |
| 10 | 144 | 99 | 45 | 68.8% |
| 11 | 590 | 450 | 140 | 76.3% |
---
## 3. МАРШРУТЫ (FLIGHTS)
### 3.1. Детализация по маршрутам
| Маршрут | Регион | Тип БПЛА | Drone (px) | Высота (м) | Heading (Phi) | Спутник (px) | Дата drone | Дата sat |
|---------|--------|----------|------------|------------|---------------|-------------|------------|----------|
| 01 | Changjiang-20 | multi-rotor | 3976x2652 | ~405 | 165° | 9774x26762 | 2018-09 | 2023-11 |
| 02 | Changjiang-23 | multi-rotor | 3976x2652 | ~405 | 5° | 11482x34291 | 2018-09 | 2022-09 |
| 03 | Taizhou-1 | multi-rotor | 3976x2652 | ~466 | -40° | 35092x24308 | 2018-10 | 2021-04 |
| 04 | Taizhou-6 | multi-rotor | 3976x2652 | ~542 | 170° | 18093x38408 | 2018-10 | 2023-03 |
| 05 | Yunnan | fixed-wing | 3000x2000 | ~2313 | 100° | 9394x6144 | 2016-06 | 2022-03 |
| 06 | Zhuxi | multi-rotor | 3976x2652 | ~840 | — | 8082x9780 | — | — |
| 07 | Donghuayuan | fixed-wing | 3000x2000 | ~688 | -1.5° | 3000x170 | 2018-07 | 2023-06 |
| 08 | Huzhou-3 | multi-rotor | 3976x2652 | ~551 | 100° | 43421x16294 | 2019-06 | 2023-07 |
| 09 | Huzhou-6 | multi-rotor | 3976x2652 | ~546 | -50° | 44800x33280* | 2019-06 | 2024-01 |
| 10 | Huailai | fixed-wing | 3000x2000 | ~772 | 170° | 6593x5077 | 2018-09 | 2023-06 |
| 11 | Shandan | multi-rotor | 3976x2652 | ~2572 | 90° | 29592x16582 | 2023-10 | 2021-03 |
\* Маршрут 09: спутник разбит на 4 тайла (satellite09_01-01.tif, 01-02, 02-01, 02-02). Суммарный размер: 44800x33280 px.
### 3.2. Географический охват
| Регион | Маршруты | Провинция | Ландшафт |
|--------|----------|-----------|----------|
| Changjiang | 01, 02 | Цзянси | Города, деревни, фермы, реки (долина Янцзы) |
| Taizhou | 03, 04 | Цзянсу | Города, фермы, каналы, реки |
| Yunnan | 05 | Юньнань | Горы, леса, холмы (высокогорье) |
| Zhuxi | 06 | Хубэй | Горы, леса, река |
| Donghuayuan | 07 | Хэбэй | Равнина (очень узкий маршрут) |
| Huzhou | 08, 09 | Чжэцзян | Города, озеро Тайху, фермы |
| Huailai | 10 | Хэбэй | Равнина, холмы |
| Shandan | 11 | Ганьсу | Пустыня, степь (коридор Хэси) |
**Координатный охват:**
- Широта: от 24.65°N (Юньнань) до 40.36°N (Хэбэй) — разброс ~15.7°
- Долгота: от 101.01°E (Ганьсу) до 120.25°E (Чжэцзян) — разброс ~19.2°
- Всё в пределах Китая, но с разнообразным ландшафтом
### 3.3. Типы БПЛА
| Тип | Маршруты | Разрешение | Высота полёта | Кол-во изображений |
|-----|----------|-----------|---------------|-------------------|
| Multi-rotor | 01, 02, 03, 04, 06, 08, 09, 11 | 3976x2652 | 4052572 м | 6 127 (90.4%) |
| Fixed-wing | 05, 07, 10 | 3000x2000 | 6882313 м | 647 (9.6%) |
---
## 4. ИСТОЧНИКИ ИЗОБРАЖЕНИЙ
### 4.1. Дроновые виды (query)
| Параметр | Значение |
|----------|----------|
| Платформа | **Реальные БПЛА** (не синтетика!) |
| Тип съёмки | RGB, ground-down view (камера вертикально вниз) |
| Разрешение кадров | 3976x2652 (multi-rotor) / 3000x2000 (fixed-wing) |
| GSD (drone) | 0.10.2 м/пиксель (из README); расчётное: 1597 см/px в зависимости от высоты |
| Высоты полёта | от 405 м до 2572 м |
| Heading angle | Phi1 (высокая уверенность), Phi2 (низкая уверенность) |
| Pose данные | Omega (pitch), Kappa (roll), Phi1/Phi2 (yaw) |
| Формат | JPEG |
### 4.2. Спутниковые карты (gallery / DB)
| Параметр | Значение |
|----------|----------|
| Платформа | Google Earth |
| Формат | GeoTIFF (.tif) |
| GSD (спутник) | **0.3 м/пиксель** (из статьи) |
| Размеры карт | от 3000x170 до 43421x38408 px |
| Кропы/патчи | **Отсутствуют** — авторы предоставляют только целые карты |
### 4.3. Временной разрыв drone/satellite
| Маршрут | Drone | Satellite | Разрыв |
|---------|-------|-----------|--------|
| 01 | 2018-09 | 2023-11 | **5 лет** |
| 03 | 2018-10 | 2021-04 | 2.5 года |
| 08 | 2019-06 | 2023-07 | 4 года |
| 11 | 2023-10 | 2021-03 | **-2.5 года** (спутник старше!) |
Временной разрыв создаёт дополнительную сложность (изменения застройки, сезонные различия).
---
## 5. ПАРАМЕТРЫ СЪЁМКИ ДРОНОВ
### 5.1. Высоты полёта
| Диапазон высот | Маршруты | Тип | Кол-во изображений |
|----------------|----------|-----|-------------------|
| 400410 м | 01, 02 | multi-rotor | 1 888 |
| 460550 м | 03, 04, 08, 09 | multi-rotor | 3 305 |
| 688840 м | 06, 07, 10 | mixed | 518 |
| 23002575 м | 05, 11 | mixed | 1 063 |
### 5.2. Наземное покрытие drone-кадра (оценка, FOV ~84°)
| Высота | Footprint (multi-rotor 3976x2652) | Footprint (fixed-wing 3000x2000) |
|--------|-----------------------------------|----------------------------------|
| ~405 м | ~608 x 405 м | — |
| ~466 м | ~699 x 466 м | — |
| ~551 м | ~826 x 551 м | — |
| ~688 м | — | ~1031 x 688 м |
| ~840 м | ~1260 x 840 м | — |
| ~2313 м | — | ~3469 x 2313 м |
| ~2572 м | ~3858 x 2572 м | — |
### 5.3. Расстояние между кадрами
| Маршрут | Avg spacing | Высота | Overlap (оценка) |
|---------|-------------|--------|------------------|
| 01 | 80.7 м | 405 м | ~87% (по ширине footprint) |
| 05 | 62.7 м | 2313 м | ~98% |
| 07 | 23.6 м | 688 м | ~98% |
| 08 | 99.7 м | 551 м | ~88% |
| 11 | 142.9 м | 2572 м | ~96% |
Высокий overlap (8798%) типичен для аэрофотосъёмки.
---
## 6. ПРОСТРАНСТВЕННАЯ НАРЕЗКА СПУТНИКОВЫХ ПАТЧЕЙ
### 6.1. Текущее состояние
**В оригинальном датасете кропы/патчи НЕ предоставлены.** Авторы дают только целые спутниковые карты. Задача определена как "найти координаты на большой карте", а не как retrieval по патчам.
### 6.2. Планируемая нарезка (по аналогии с UAV-GeoLoc)
Для совместимости с pipeline на основе UAV-GeoLoc, планируется нарезка спутниковых карт на патчи.
**Параметры нарезки:**
| Параметр | Значение | Обоснование |
|----------|----------|-------------|
| Размер кропа | **512x512 px** | ~154x154 м на земле (при GSD 0.3 м/px) |
| Stride | **256 px** | 50% overlap (как в UAV-GeoLoc) |
| Именование | `crop_X_Y.png` | X по ширине (col), Y по высоте (row) |
| Позиция в карте | `sat[Y*stride : Y*stride+crop, X*stride : X*stride+crop]` | — |
| Финальный размер (модель) | **256x256 px** | Resize для входа в модель |
### 6.3. Ожидаемое количество кропов
| Маршрут | Размер карты | Кропов (cols x rows) | Итого | Примечание |
|---------|-------------|---------------------|-------|------------|
| 01 | 9774x26762 | 37 x 103 | 3 811 | OK |
| 02 | 11482x34291 | 43 x 132 | 5 676 | OK |
| 03 | 35092x24308 | 136 x 93 | 12 648 | OK |
| 04 | 18093x38408 | 69 x 149 | 10 281 | OK |
| 05 | 9394x6144 | 35 x 23 | 805 | OK |
| 06 | 8082x9780 | 30 x 37 | 1 110 | OK |
| 07 | 3000x170 | — | — | **Исключён** (высота 170 px < 512) |
| 08 | 43421x16294 | 168 x 62 | 10 416 | OK |
| 09 | 44800x33280 | 174 x 129 | 22 446 | Нужна сшивка 4 тайлов |
| 10 | 6593x5077 | 24 x 18 | 432 | OK |
| 11 | 29592x16582 | 114 x 63 | 7 182 | OK |
| **Итого** | | | **~74 807** | Без маршрута 07 |
### 6.4. Наземное покрытие одного кропа
При GSD спутника = 0.3 м/px:
- **Кроп 512x512 px** покрывает **~154 x 154 м** на земле
- Это вписывается в footprint drone-кадра на любой высоте (405+ м)
- Stride 256 px = 76.8 м на земле
### 6.5. Проблемные маршруты
| Маршрут | Проблема | Решение |
|---------|----------|---------|
| 07 | Спутник 3000x170 px — слишком узкий | Исключить или кропы 170x170 |
| 09 | Спутник разбит на 4 тайла | Сшить в одну карту перед нарезкой |
---
## 7. АННОТАЦИИ И МЕТАДАННЫЕ
### 7.1. Файлы аннотаций
| Файл | Содержание | Формат |
|------|-----------|--------|
| `XX.csv` | GPS + pose каждого drone-кадра | CSV: num, filename, date, lat, lon, height, Omega, Kappa, Phi1, Phi2 |
| `satellite_ coordinates_range.csv` | GPS-bbox каждой спутниковой карты | CSV: mapname, LT_lat, LT_lon, RB_lat, RB_lon, region |
| `visloc_train.csv` | Train split | TSV: filename, height, Omega, Kappa, Phi1, Phi2 |
| `visloc_test.csv` | Test split | TSV: filename, height, Omega, Kappa, Phi1, Phi2 |
### 7.2. Типы аннотаций
| Тип аннотации | Наличие | Комментарий |
|---------------|---------|-------------|
| GPS-координаты (drone) | **Да** | Из бортового GNSS, точность ~1-3 м |
| GPS-bbox (satellite) | **Да** | Углы спутниковой карты |
| Высота полёта | **Да** | В метрах |
| Heading angle (yaw) | **Да** | Phi1 (надёжный) и Phi2 (менее надёжный) |
| Pitch / Roll | **Да** | Omega (pitch), Kappa (roll) |
| Дата съёмки | **Да** | Для drone-кадров |
| Positive/Semi-positive pairs | **Нет** | Отсутствуют — нужно генерировать |
| Кропы спутника (DB) | **Нет** | Отсутствуют — нужно нарезать |
| Depth maps | Нет | — |
| Segmentation masks | Нет | — |
| Bounding boxes | Нет | — |
| Семантические метки | Нет | Только implicit через регион |
---
## 8. СРАВНЕНИЕ С UAV-GeoLoc
| Параметр | UAV-VisLoc | UAV-GeoLoc |
|----------|-----------|-----------|
| Drone-изображения | **Реальные** БПЛА | **Синтетика** (Google Earth Studio 3D) |
| Кол-во drone | 6 774 | 652 744 |
| Кол-во спутниковых кропов | 0 (нужно генерировать) | 274 683 |
| Размер drone | 3976x2652 / 3000x2000 | 512x512 |
| GSD drone | 0.10.97 м/px (зависит от высоты) | ~0.5 м/px (синтетика) |
| GSD satellite | 0.3 м/px (Google Earth) | Варьируется |
| Высоты полёта | 4052572 м (реальные) | 100, 125, 150 м (синтетика) |
| Heading angles | Произвольные (реальный полёт) | Дискретные: 8 x 45° |
| Регионы | 7 провинций Китая | 11 стран, 6 континентов |
| Сцен | 11 маршрутов | 372 сцены |
| Train/test split | По изображениям (~75/25) | По сценам (140/40/20) |
| Positive pairs | Нет (нужно по GPS) | Есть (positive.json) |
| Semi-positive pairs | Нет | Есть (semi_positive.json) |
| Temporal gap | 25 лет | Нет (одновременно) |
| Лицензия | Не указана | CC BY-NC 4.0 |
### Ключевые отличия:
1. **Реальные vs синтетические** — UAV-VisLoc содержит реальные фотографии, что даёт реалистичные артефакты (освещение, шум, blur), но меньше контроля
2. **Масштаб** — UAV-GeoLoc на 2 порядка больше по количеству изображений
3. **Пары не предоставлены** — для UAV-VisLoc нужно самостоятельно сопоставить drone GPS с координатами кропов
4. **Вариативность высот** — гораздо шире (4002600 м vs 100150 м)
5. **Temporal gap** — drone и satellite сняты в разные годы, что усложняет matching
---
## 9. ПЛАН ГЕНЕРАЦИИ КРОПОВ И ПАР
### 9.1. Pipeline
```
satellite.tif + satellite_coordinates_range.csv
[1] Нарезка кропов (512x512, stride 256)
[2] Вычисление GPS центра каждого кропа
│ (из bbox карты + позиция кропа в grid)
[3] Для каждого drone-кадра:
│ — Найти кроп с минимальным GPS-расстоянием → positive
│ — Найти кропы в радиусе R → semi-positives
[4] Генерация positive.json, semi_positive.json, db_postion.txt
[5] Генерация Index файлов (train_query.txt, train_db.txt, ...)
[6] Resize drone → 256x256, кропов → 256x256
```
### 9.2. Matching drone → crop
GPS центр кропа `crop_X_Y.png` вычисляется как:
```
crop_center_lon = LT_lon + (X * stride + crop_size/2) * (RB_lon - LT_lon) / sat_width
crop_center_lat = LT_lat + (Y * stride + crop_size/2) * (RB_lat - LT_lat) / sat_height
```
Positive match: кроп с минимальным евклидовым расстоянием до GPS drone-кадра.
Semi-positive: все кропы в радиусе 1 stride (256 px = ~77 м) от positive.
---
## 10. ПОЛНАЯ СТРУКТУРА ДАННЫХ
```
UAV_VisLoc_dataset/ # ~16.4 GB
├── satellite_ coordinates_range.csv # GPS-bbox всех 11 карт
├── visloc_train.csv # 5080 train drone images (TSV)
├── visloc_test.csv # 1694 test drone images (TSV)
├── README_dataset.txt # Описание датасета
├── 01/ # Changjiang-20, multi-rotor, 817 imgs
│ ├── drone/
│ │ ├── 01_0001.JPG # 3976x2652
│ │ ├── 01_0002.JPG
│ │ └── ...
│ ├── satellite01.tif # 9774x26762
│ └── 01.csv # num,filename,date,lat,lon,height,...
├── 02/ ... 06/ # Аналогичная структура
├── 07/ # Donghuayuan, fixed-wing, 30 imgs
│ ├── drone/
│ │ └── 07_XXXX.JPG # 3000x2000
│ ├── satellite07.tif # 3000x170 (!)
│ └── 07.csv
├── 08/ # Huzhou-3, multi-rotor, 1033 imgs
├── 09/ # Huzhou-6, multi-rotor, 766 imgs
│ ├── drone/
│ ├── satellite09_01-01.tif # 25600x25600 ─┐
│ ├── satellite09_01-02.tif # 19200x25600 │ Суммарно:
│ ├── satellite09_02-01.tif # 25600x7680 │ 44800x33280
│ ├── satellite09_02-02.tif # 19200x7680 ─┘
│ └── 09.csv
├── 10/ # Huailai, fixed-wing, 144 imgs
└── 11/ # Shandan, multi-rotor, 590 imgs
├── drone/
│ └── 11_XXXX.JPG # 3976x2652
├── satellite11.tif # 29592x16582
└── 11.csv
```

View File

@@ -1,5 +1,5 @@
# Balanced configuration — primary test setup. # Balanced: GatedFusion with text captions enabled.
# L = 1.0 * L_img_img + 0.3 * L_sat_cap + 0.3 * L_drone_cap + 0.1 * L_cap_cap # query = sigma(alpha) * drone + (1-sigma(alpha)) * text -> InfoNCE vs satellite
import src.datasets.visloc_with_captions import src.datasets.visloc_with_captions
import src.losses.multi_infonce import src.losses.multi_infonce
@@ -12,43 +12,37 @@ DualEncoderCaptionTest.pretrained_path = "checkpoints/RS5M_ViT-B-32.pt"
DualEncoderCaptionTest.unfreeze_mode = "last_block" DualEncoderCaptionTest.unfreeze_mode = "last_block"
DualEncoderCaptionTest.embed_dim = 512 DualEncoderCaptionTest.embed_dim = 512
DualEncoderCaptionTest.use_mlp_heads = False DualEncoderCaptionTest.use_mlp_heads = False
DualEncoderCaptionTest.shared_image_head = True DualEncoderCaptionTest.baseline_mode = False
DualEncoderCaptionTest.init_gate = 0.7
DualEncoderCaptionTest.device = "cuda" DualEncoderCaptionTest.device = "cuda"
ProjectionHead.in_dim = 512 # ---- Fusion ----
ProjectionHead.out_dim = 512 GatedFusion.init_gate = 0.7
ProjectionHead.use_mlp = False GatedFusion.baseline_mode = False
# ---- Loss ---- # ---- Loss ----
MultiTermInfoNCE.temperature_init = 0.1 InfoNCELoss.temperature_init = 0.1
MultiTermInfoNCE.temperature_final = 0.01 InfoNCELoss.temperature_final = 0.01
MultiTermInfoNCE.label_smoothing = 0.1 InfoNCELoss.label_smoothing = 0.1
MultiTermInfoNCE.asym_drone_to_sat = 0.6 InfoNCELoss.weight_q2g = 0.6
MultiTermInfoNCE.asym_sat_to_drone = 0.4 InfoNCELoss.weight_g2q = 0.4
MultiTermInfoNCE.warmup_epochs = 3
MultiTermInfoNCE.text_ramp_epochs = 10
MultiTermInfoNCE.lambda_ii = 1.0
MultiTermInfoNCE.lambda_sc_max = 0.3
MultiTermInfoNCE.lambda_dc_max = 0.3
MultiTermInfoNCE.lambda_cc_max = 0.1
# ---- Dataset ---- # ---- Dataset ----
VisLocCaptionDataset.caption_strategy = "hybrid" GeoLocCaptionDataset.drop_caption_prob = 0.0
VisLocCaptionDataset.drop_caption_prob = 0.0 GeoLocCaptionDataset.seed = 42
VisLocCaptionDataset.seed = 42
# ---- Training ---- # ---- Training ----
TrainConfig.train_manifest = "data/visloc_train.json" TrainConfig.train_query_file = "/mnt/data1tb/cvgl_datasets/UAV-GeoLoc/Index/train_query.txt"
TrainConfig.val_manifest = "data/visloc_val.json" TrainConfig.val_query_file = "/mnt/data1tb/cvgl_datasets/UAV-GeoLoc/Index/val_query.txt"
TrainConfig.image_root = "data/visloc/images" TrainConfig.data_root = "/mnt/data1tb/cvgl_datasets/UAV-GeoLoc"
TrainConfig.output_dir = "out/caption_test/balanced" TrainConfig.output_dir = "out/caption_test/balanced"
TrainConfig.epochs = 30 TrainConfig.epochs = 10
TrainConfig.batch_size = 128 TrainConfig.batch_size = 128
TrainConfig.num_workers = 4 TrainConfig.num_workers = 4
TrainConfig.learning_rate = 1e-4 TrainConfig.learning_rate = 1e-4
TrainConfig.weight_decay = 1e-4 TrainConfig.weight_decay = 1e-4
TrainConfig.grad_clip = 1.0 TrainConfig.grad_clip = 1.0
TrainConfig.use_amp = True TrainConfig.use_amp = True
TrainConfig.eval_every = 1 TrainConfig.eval_every = 2
TrainConfig.seed = 42 TrainConfig.seed = 42
TrainConfig.device = "cuda" TrainConfig.device = "cuda"

View File

@@ -1,11 +1,10 @@
# Baseline: image-image only, no captions. Reference R@1 for delta computation. # Baseline: no text fusion (gate forced to 1.0).
# L = 1.0 * L_img_img # query = drone_only -> InfoNCE vs satellite
# Reference R@1 for delta computation.
include 'balanced.gin' include 'balanced.gin'
# Disable all caption loss terms. DualEncoderCaptionTest.baseline_mode = True
MultiTermInfoNCE.lambda_sc_max = 0.0 GatedFusion.baseline_mode = True
MultiTermInfoNCE.lambda_dc_max = 0.0
MultiTermInfoNCE.lambda_cc_max = 0.0
TrainConfig.output_dir = "out/caption_test/baseline_no_text" TrainConfig.output_dir = "out/caption_test/baseline_no_text"

View File

@@ -1,11 +1,9 @@
# Text-heavy configuration — stress test of caption contribution. # Text-heavy: gate initialized low (more text weight).
# L = 0.5 * L_img_img + 0.5 * L_sat_cap + 0.5 * L_drone_cap + 0.2 * L_cap_cap # query = sigma(0.3) * drone + 0.7 * text
include 'balanced.gin' include 'balanced.gin'
MultiTermInfoNCE.lambda_ii = 0.5 DualEncoderCaptionTest.init_gate = 0.3
MultiTermInfoNCE.lambda_sc_max = 0.5 GatedFusion.init_gate = 0.3
MultiTermInfoNCE.lambda_dc_max = 0.5
MultiTermInfoNCE.lambda_cc_max = 0.2
TrainConfig.output_dir = "out/caption_test/text_heavy" TrainConfig.output_dir = "out/caption_test/text_heavy"

View File

@@ -1,9 +1,6 @@
from __future__ import annotations from __future__ import annotations
"""Compare baseline vs full-caption runs and compute Delta R@1 report. """Compare baseline (no text) vs caption-fused runs. Compute Delta R@1 report.
Reads eval reports produced by src.eval.evaluate.run_evaluation_from_checkpoint
and produces a markdown + JSON summary.
Usage: Usage:
python -m scripts.compare_runs \ python -m scripts.compare_runs \
@@ -18,10 +15,8 @@ from pathlib import Path
_DIRECTIONS = ( _DIRECTIONS = (
"drone_to_sat", "query_to_gallery",
"sat_to_drone", "gallery_to_query",
"text_to_sat",
"text_to_drone",
) )
_KS = (1, 5, 10) _KS = (1, 5, 10)
@@ -33,73 +28,70 @@ def _load_metrics(report_path: Path) -> dict[str, float]:
def _format_row(name: str, baseline: dict[str, float], full: dict[str, float]) -> str: def _format_row(name: str, baseline: dict[str, float], full: dict[str, float]) -> str:
"""Render one markdown row for a direction across R@1, R@5, R@10."""
cells = [name] cells = [name]
for k in _KS: for k in _KS:
key = f"r@{k}_{name}" key = f"r@{k}_{name}"
b = baseline.get(key, float("nan")) b = baseline.get(key, float("nan"))
f_ = full.get(key, float("nan")) f_ = full.get(key, float("nan"))
delta = f_ - b if (b == b and f_ == f_) else float("nan") # NaN-safe delta = f_ - b if (b == b and f_ == f_) else float("nan")
cells.append(f"{b:.4f} {f_:.4f} (Δ {delta:+.4f})") cells.append(f"{b:.4f} -> {f_:.4f} (D {delta:+.4f})")
return "| " + " | ".join(cells) + " |" return "| " + " | ".join(cells) + " |"
def _interpret_delta(delta: float) -> str: def _interpret_delta(delta: float) -> str:
"""Human-readable caption-quality verdict."""
if delta >= 0.03: if delta >= 0.03:
return "PASS captions informative (Δ R@1 +3%)" return "PASS -- captions informative (D R@1 >= +3%)"
if delta >= 0.01: if delta >= 0.01:
return "⚠️ MARGINAL consider VLM refinement (+1% ≤ Δ < +3%)" return "MARGINAL -- consider VLM refinement (+1% <= D < +3%)"
if delta >= 0: if delta >= 0:
return "WEAK captions add little signal (< +1%)" return "WEAK -- captions add little signal (< +1%)"
return "❌❌ HARMFUL captions confuse model (Δ < 0)" return "HARMFUL -- captions confuse model (D < 0)"
def build_comparison_markdown( def build_comparison_markdown(
baseline: dict[str, float], baseline: dict[str, float],
full: dict[str, float], full: dict[str, float],
) -> str: ) -> str:
"""Compose markdown comparison report."""
lines: list[str] = ["# Caption Quality Test: Comparison Report", ""] lines: list[str] = ["# Caption Quality Test: Comparison Report", ""]
# Headline Δ R@1 on primary direction. primary = "query_to_gallery"
primary = "drone_to_sat"
primary_key = f"r@1_{primary}" primary_key = f"r@1_{primary}"
primary_delta = full.get(primary_key, 0.0) - baseline.get(primary_key, 0.0) primary_delta = full.get(primary_key, 0.0) - baseline.get(primary_key, 0.0)
verdict = _interpret_delta(primary_delta) verdict = _interpret_delta(primary_delta)
lines.append(f"## Primary metric: Δ R@1 ({primary}) = {primary_delta:+.4f}") lines.append(f"## Primary metric: D R@1 (query->gallery) = {primary_delta:+.4f}")
lines.append("") lines.append("")
lines.append(f"**Verdict:** {verdict}") lines.append(f"**Verdict:** {verdict}")
lines.append("") lines.append("")
# Full table. # Gate value.
lines.append("## All directions × K") gate = full.get("gate", None)
if gate is not None:
lines.append(f"**Fusion gate:** {gate:.4f} (1.0 = text ignored, 0.0 = image ignored)")
lines.append("")
lines.append("## All directions x K")
lines.append("") lines.append("")
header = "| Direction | R@1 base full | R@5 base full | R@10 base full |" header = "| Direction | R@1 base -> full | R@5 base -> full | R@10 base -> full |"
sep = "|---|---|---|---|" sep = "|---|---|---|---|"
lines.extend([header, sep]) lines.extend([header, sep])
for direction in _DIRECTIONS: for direction in _DIRECTIONS:
row = _format_row(direction, baseline, full) lines.append(_format_row(direction, baseline, full))
lines.append(row)
lines.append("") lines.append("")
# Decision rule recap.
lines.append("## Decision rule") lines.append("## Decision rule")
lines.append("") lines.append("")
lines.append("- Δ R@1 +3% captions pass, proceed to World-UAV generation") lines.append("- D R@1 >= +3% -> captions pass, proceed to production")
lines.append("- +1% ≤ Δ R@1 < +3% add VLM refinement, re-run") lines.append("- +1% <= D R@1 < +3% -> add VLM refinement, re-run")
lines.append("- Δ R@1 < +1% redesign caption pipeline") lines.append("- D R@1 < +1% -> redesign caption pipeline")
lines.append("- Δ R@1 < 0 critical bug, investigate caption/image alignment") lines.append("- D R@1 < 0 -> critical bug, investigate")
lines.append("") lines.append("")
return "\n".join(lines) return "\n".join(lines)
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Compare baseline vs caption runs.")
description="Compare baseline vs full-caption runs."
)
parser.add_argument("--baseline_report", type=Path, required=True) parser.add_argument("--baseline_report", type=Path, required=True)
parser.add_argument("--full_report", type=Path, required=True) parser.add_argument("--full_report", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True) parser.add_argument("--output", type=Path, required=True)
@@ -114,7 +106,6 @@ def main() -> None:
with args.output.open("w", encoding="utf-8") as f: with args.output.open("w", encoding="utf-8") as f:
f.write(md) f.write(md)
# Also write machine-readable summary.
summary = { summary = {
"baseline_metrics": baseline, "baseline_metrics": baseline,
"full_metrics": full, "full_metrics": full,

View File

@@ -1,26 +1,20 @@
from __future__ import annotations from __future__ import annotations
"""UAV-VisLoc dataset loader augmented with generated captions. """UAV-GeoLoc dataset loader with template captions for CVGL caption test.
Expects a manifest JSON of the form: Reads UAV-GeoLoc Index format (train_query.txt / train_db.txt) and generates
[ template captions from path metadata (terrain type, scene, altitude, heading).
{
"pair_id": "v001_0042",
"drone_path": "drone/v001_0042.jpg",
"sat_path": "satellite/v001_0042.png",
"caption_drone": "low-altitude photo of residential ...",
"caption_sat": "aerial view of urban area ...",
"gps": [lat, lon]
},
...
]
Captions are produced offline by scripts/generate_captions.py using one of train_query.txt format:
three strategies: template, VLM, or hybrid (see АНАЛИЗ_caption_quality_test). Terrain/Volcano/KilaueaVolcano/query/height100_rot315/footage/file.jpeg 0 .../DB/img/crop_X_Y.png ...
Template caption (P3-style fingerprint for cross-view matching):
"Aerial view at 100m facing northwest over volcanic terrain near KilaueaVolcano.
Plan-view features: dark lava flows, crater edges, sparse vegetation patches."
""" """
import json
import random import random
import re
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
@@ -29,130 +23,191 @@ import torch
from PIL import Image from PIL import Image
from torch.utils.data import Dataset from torch.utils.data import Dataset
# Compass lookup: heading_deg -> direction name.
_COMPASS = ["north", "northeast", "east", "southeast",
"south", "southwest", "west", "northwest"]
# Simple terrain-to-features mapping for template captions.
_TERRAIN_FEATURES: dict[str, str] = {
"Volcano": "lava flows, crater edges, volcanic rock",
"Mountain": "ridgelines, steep slopes, rocky terrain",
"Hill": "rolling hills, gentle slopes, scattered trees",
"Desert": "sand dunes, arid ground, sparse scrub",
"Plain": "flat open fields, agricultural plots, dirt roads",
"Plateau": "flat elevated terrain, cliffs, mesa edges",
"Basin": "lowland depression, dry lake bed, sediment patterns",
"Delta": "river channels, sediment fans, wetland patches",
"Gorge": "deep canyon, exposed rock layers, narrow valley",
"Island": "shoreline, coastal vegetation, water boundary",
"Wetland": "marshes, water channels, aquatic vegetation",
"Glacier": "ice formations, crevasses, glacial moraine",
"Forest": "dense canopy, tree shadows, clearings",
"Farm": "crop fields, irrigation lines, farm buildings",
"Prairie": "grassland, open meadow, fence lines",
"Finca": "rural estate, orchards, scattered structures",
"Calcification": "mineral deposits, white crusts, barren soil",
"StoneForest": "karst pillars, eroded limestone, sparse vegetation",
"Oasis": "palm clusters, water pool, surrounding desert",
"Flowers": "colorful ground cover, floral patterns, open field",
"Terrace": "terraced hillside, stepped fields, retaining walls",
"Snow": "snow cover, tracks, exposed rock patches",
"Pasture": "grazing land, fenced paddocks, grass",
"Danxia": "red sandstone, layered cliffs, erosion patterns",
"Hylare": "sparse woodland, rocky outcrops",
"Karst": "sinkholes, limestone towers, caves",
"Fall": "autumn foliage, colored canopy, leaf litter",
}
# Country/city features.
_COUNTRY_FEATURES: str = "buildings, roads, urban blocks, rooftops, intersections"
def _parse_query_path(query_path: str) -> dict[str, str]:
"""Extract metadata from UAV-GeoLoc query path.
Example: Terrain/Volcano/KilaueaVolcano/query/height100_rot315/footage/file.jpeg
Returns: {category, terrain_type, scene, height_m, heading_deg, heading_dir}
"""
parts = query_path.split("/")
category = parts[0] if parts else "Unknown"
if category == "Terrain":
terrain_type = parts[1] if len(parts) > 1 else "Unknown"
scene = parts[2] if len(parts) > 2 else "Unknown"
elif category == "Country":
terrain_type = "Urban"
scene = "/".join(parts[1:3]) if len(parts) > 2 else "Unknown"
else:
terrain_type = "Unknown"
scene = parts[1] if len(parts) > 1 else "Unknown"
# Parse height and rotation from trajectory folder name.
height_m = "100"
heading_deg = "0"
for part in parts:
m = re.match(r"height(\d+)_rot(\d+)", part)
if m:
height_m = m.group(1)
heading_deg = m.group(2)
break
heading_idx = round(int(heading_deg) / 45) % 8
heading_dir = _COMPASS[heading_idx]
return {
"category": category,
"terrain_type": terrain_type,
"scene": scene,
"height_m": height_m,
"heading_deg": heading_deg,
"heading_dir": heading_dir,
}
def _make_template_caption(meta: dict[str, str]) -> str:
"""Generate a template caption from parsed metadata."""
terrain = meta["terrain_type"]
features = _TERRAIN_FEATURES.get(terrain, _COUNTRY_FEATURES)
return (
f"Aerial view at {meta['height_m']}m facing {meta['heading_dir']} "
f"over {terrain.lower()} terrain near {meta['scene']}. "
f"Plan-view features: {features}."
)
@gin.configurable @gin.configurable
class VisLocCaptionDataset(Dataset): class GeoLocCaptionDataset(Dataset):
"""UAV-VisLoc pairs with generated captions. """UAV-GeoLoc pairs with template captions.
Reads train_query.txt, randomly samples one positive crop per query.
Args: Args:
manifest_path: Path to JSON manifest with pair entries. query_file: Path to train_query.txt (or test_query.txt).
image_root: Directory prefix joined with manifest relative paths. data_root: Root directory of UAV-GeoLoc dataset.
image_transform: Callable applied to PIL images (e.g., GeoRSCLIP preprocess). image_transform: Callable applied to PIL images.
caption_strategy: Which caption field to use ('template', 'vlm', 'hybrid'). drop_caption_prob: Probability of dropping caption (for ablation).
The corresponding field must exist in the manifest seed: Random seed.
(e.g., 'caption_sat_vlm', or the generic 'caption_sat').
drop_caption_prob: Random probability of replacing a caption with ''.
Useful for dropout ablations during training.
seed: Random seed for reproducibility.
""" """
def __init__( def __init__(
self, self,
manifest_path: str, query_file: str,
image_root: str, data_root: str,
image_transform: Callable[[Image.Image], torch.Tensor], image_transform: Callable[[Image.Image], torch.Tensor],
caption_strategy: str = "hybrid",
drop_caption_prob: float = 0.0, drop_caption_prob: float = 0.0,
seed: int = 0, seed: int = 0,
) -> None: ) -> None:
self.manifest_path = Path(manifest_path) self.data_root = Path(data_root)
self.image_root = Path(image_root)
self.image_transform = image_transform self.image_transform = image_transform
self.caption_strategy = caption_strategy
self.drop_caption_prob = drop_caption_prob self.drop_caption_prob = drop_caption_prob
self._rng = random.Random(seed) self._rng = random.Random(seed)
with self.manifest_path.open("r", encoding="utf-8") as f: self.entries: list[dict[str, Any]] = []
self.entries: list[dict[str, Any]] = json.load(f) self._load_query_file(Path(query_file))
self._validate_entries() def _load_query_file(self, query_file: Path) -> None:
"""Parse train_query.txt into list of entries."""
with open(query_file) as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split()
query_path = parts[0]
# parts[1] is label (always 0), parts[2:] are positive crop paths.
positive_crops = parts[2:]
if not positive_crops:
continue
def _validate_entries(self) -> None: meta = _parse_query_path(query_path)
"""Ensure all entries have required fields for the chosen strategy.""" caption = _make_template_caption(meta)
required = {"drone_path", "sat_path"}
caption_sat_key = self._caption_key("sat")
caption_drone_key = self._caption_key("drone")
required |= {caption_sat_key, caption_drone_key}
for i, entry in enumerate(self.entries): self.entries.append({
missing = required - entry.keys() "query_path": query_path,
if missing: "positive_crops": positive_crops,
raise KeyError( "caption": caption,
f"Entry {i} (pair_id={entry.get('pair_id', '?')}) missing fields: " "meta": meta,
f"{sorted(missing)}" })
)
def _caption_key(self, view: str) -> str:
"""Resolve caption field name from strategy + view."""
if self.caption_strategy == "hybrid":
return f"caption_{view}"
return f"caption_{view}_{self.caption_strategy}"
def _load_image(self, relative_path: str) -> torch.Tensor: def _load_image(self, relative_path: str) -> torch.Tensor:
"""Load image and apply preprocessing.""" path = self.data_root / relative_path
path = self.image_root / relative_path
with Image.open(path) as img: with Image.open(path) as img:
rgb = img.convert("RGB") rgb = img.convert("RGB")
return self.image_transform(rgb) return self.image_transform(rgb)
def _maybe_drop(self, caption: str) -> str:
"""Stochastically drop caption to empty string for robustness training."""
if self.drop_caption_prob > 0 and self._rng.random() < self.drop_caption_prob:
return ""
return caption
def __len__(self) -> int: def __len__(self) -> int:
return len(self.entries) return len(self.entries)
def __getitem__(self, idx: int) -> dict[str, Any]: def __getitem__(self, idx: int) -> dict[str, Any]:
"""Return one pair with images and captions.
Args:
idx: Index into the manifest.
Returns:
Dict with:
- 'drone_img': [3, H, W] tensor
- 'sat_img': [3, H, W] tensor
- 'caption_drone': str (possibly empty)
- 'caption_sat': str (possibly empty)
- 'pair_id': str for logging
"""
entry = self.entries[idx] entry = self.entries[idx]
drone_img = self._load_image(entry["drone_path"]) drone_img = self._load_image(entry["query_path"])
sat_img = self._load_image(entry["sat_path"])
caption_drone = self._maybe_drop(entry[self._caption_key("drone")]) # Randomly sample one positive crop.
caption_sat = self._maybe_drop(entry[self._caption_key("sat")]) crop_path = self._rng.choice(entry["positive_crops"])
sat_img = self._load_image(crop_path)
caption = entry["caption"]
if self.drop_caption_prob > 0 and self._rng.random() < self.drop_caption_prob:
caption = ""
return { return {
"drone_img": drone_img, "drone_img": drone_img,
"sat_img": sat_img, "sat_img": sat_img,
"caption_drone": caption_drone, "caption_drone": caption,
"caption_sat": caption_sat, "pair_id": entry["query_path"],
"pair_id": entry.get("pair_id", f"idx_{idx}"),
} }
def collate_caption_batch( def collate_caption_batch(
batch: list[dict[str, Any]], batch: list[dict[str, Any]],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Collate VisLocCaptionDataset items into a batched dict. """Collate into batched dict. Captions stay as string lists."""
Images are stacked; captions remain Python lists so the tokenizer can
process them inside the model.forward().
Args:
batch: List of samples from VisLocCaptionDataset.__getitem__.
Returns:
Batched dict with stacked image tensors and caption lists.
"""
return { return {
"drone_img": torch.stack([b["drone_img"] for b in batch], dim=0), "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), "sat_img": torch.stack([b["sat_img"] for b in batch], dim=0),
"caption_drone": [b["caption_drone"] for b in batch], "caption_drone": [b["caption_drone"] for b in batch],
"caption_sat": [b["caption_sat"] for b in batch],
"pair_ids": [b["pair_id"] for b in batch], "pair_ids": [b["pair_id"] for b in batch],
} }

View File

@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
"""Evaluation utilities for caption quality test. """Evaluation for caption quality test.
Implements retrieval metrics across four directions and a Recall@K for query(drone+text) -> gallery(satellite).
`delta_r_at_1` helper that compares caption-aware vs. image-only runs. delta_r_at_1 compares caption-aware vs baseline runs.
""" """
import json import json
@@ -23,19 +23,10 @@ def _recall_at_k(
similarity: torch.Tensor, similarity: torch.Tensor,
k_values: tuple[int, ...] = (1, 5, 10), k_values: tuple[int, ...] = (1, 5, 10),
) -> dict[int, float]: ) -> dict[int, float]:
"""Compute Recall@K assuming positives on the diagonal. """Recall@K assuming positives on the diagonal."""
Args:
similarity: Pairwise similarity matrix [N_query, N_gallery].
k_values: Tuple of K values to compute.
Returns:
Dict mapping K -> recall in [0, 1].
"""
n_query = similarity.size(0) n_query = similarity.size(0)
targets = torch.arange(n_query, device=similarity.device) targets = torch.arange(n_query, device=similarity.device)
sorted_idx = similarity.argsort(dim=1, descending=True) sorted_idx = similarity.argsort(dim=1, descending=True)
result: dict[int, float] = {} result: dict[int, float] = {}
for k in k_values: for k in k_values:
top_k = sorted_idx[:, :k] top_k = sorted_idx[:, :k]
@@ -49,51 +40,29 @@ def _encode_dataset(
model: DualEncoderCaptionTest, model: DualEncoderCaptionTest,
loader: DataLoader, loader: DataLoader,
device: str, device: str,
include_captions: bool,
) -> dict[str, torch.Tensor]: ) -> dict[str, torch.Tensor]:
"""Encode every sample in the loader into the shared embedding space. """Encode all samples into query and gallery embeddings."""
Args:
model: Trained dual encoder.
loader: DataLoader yielding collated batches.
device: Target device string.
include_captions: If False, caption embeddings are skipped.
Returns:
Dict with keys 'drone', 'sat', 'cap_drone', 'cap_sat' -> [N, D].
"""
model.eval() model.eval()
all_drone: list[torch.Tensor] = [] all_query: list[torch.Tensor] = []
all_sat: list[torch.Tensor] = [] all_gallery: list[torch.Tensor] = []
all_cap_drone: list[torch.Tensor] = []
all_cap_sat: list[torch.Tensor] = []
for batch in loader: for batch in loader:
drone_img = batch["drone_img"].to(device, non_blocking=True) drone_img = batch["drone_img"].to(device, non_blocking=True)
sat_img = batch["sat_img"].to(device, non_blocking=True) sat_img = batch["sat_img"].to(device, non_blocking=True)
captions_drone = batch["caption_drone"] if include_captions else None caption_drone = batch["caption_drone"]
captions_sat = batch["caption_sat"] if include_captions else None
embeddings = model( embeddings = model(
drone_img=drone_img, drone_img=drone_img,
sat_img=sat_img, sat_img=sat_img,
caption_drone=captions_drone, caption_drone=caption_drone,
caption_sat=captions_sat,
) )
all_drone.append(embeddings["drone"].cpu()) all_query.append(embeddings["query"].cpu())
all_sat.append(embeddings["sat"].cpu()) all_gallery.append(embeddings["gallery"].cpu())
if include_captions:
all_cap_drone.append(embeddings["cap_drone"].cpu())
all_cap_sat.append(embeddings["cap_sat"].cpu())
out = { return {
"drone": torch.cat(all_drone, dim=0), "query": torch.cat(all_query, dim=0),
"sat": torch.cat(all_sat, dim=0), "gallery": torch.cat(all_gallery, dim=0),
} }
if include_captions:
out["cap_drone"] = torch.cat(all_cap_drone, dim=0)
out["cap_sat"] = torch.cat(all_cap_sat, dim=0)
return out
def evaluate_retrieval( def evaluate_retrieval(
@@ -101,51 +70,25 @@ def evaluate_retrieval(
loader: DataLoader, loader: DataLoader,
device: str, device: str,
k_values: tuple[int, ...] = (1, 5, 10), k_values: tuple[int, ...] = (1, 5, 10),
include_captions: bool = True,
) -> dict[str, float]: ) -> dict[str, float]:
"""Compute retrieval metrics across four directions. """Compute R@K for query->gallery and gallery->query.
Directions reported (when captions included):
drone -> sat, sat -> drone, text -> sat, text -> drone.
Args:
model: Trained DualEncoderCaptionTest.
loader: DataLoader over evaluation split.
device: torch device string.
k_values: Recall@K cutoffs.
include_captions: If False, only image-image directions computed.
Returns: Returns:
Flat dict with keys like 'r@1_drone_to_sat', 'r@5_text_to_sat', etc. Flat dict: r@1_query_to_gallery, r@5_query_to_gallery, etc.
""" """
feats = _encode_dataset( feats = _encode_dataset(model=model, loader=loader, device=device)
model=model,
loader=loader,
device=device,
include_captions=include_captions,
)
metrics: dict[str, float] = {} metrics: dict[str, float] = {}
sim_d2s = feats["drone"] @ feats["sat"].t() sim_q2g = feats["query"] @ feats["gallery"].t()
sim_s2d = sim_d2s.t()
for k, val in _recall_at_k(sim_d2s, k_values).items(): for k, val in _recall_at_k(sim_q2g, k_values).items():
metrics[f"r@{k}_drone_to_sat"] = val metrics[f"r@{k}_query_to_gallery"] = val
for k, val in _recall_at_k(sim_s2d, k_values).items(): for k, val in _recall_at_k(sim_q2g.t(), k_values).items():
metrics[f"r@{k}_sat_to_drone"] = val metrics[f"r@{k}_gallery_to_query"] = val
if include_captions and "cap_sat" in feats and "cap_drone" in feats: # Gate value for diagnostics.
sim_t2s = feats["cap_sat"] @ feats["sat"].t() metrics["gate"] = model.fusion.gate_value
sim_t2d = feats["cap_drone"] @ feats["drone"].t()
sim_tcd2tcs = feats["cap_drone"] @ feats["cap_sat"].t()
for k, val in _recall_at_k(sim_t2s, k_values).items():
metrics[f"r@{k}_text_to_sat"] = val
for k, val in _recall_at_k(sim_t2d, k_values).items():
metrics[f"r@{k}_text_to_drone"] = val
for k, val in _recall_at_k(sim_tcd2tcs, k_values).items():
metrics[f"r@{k}_capdrone_to_capsat"] = val
return metrics return metrics
@@ -153,64 +96,36 @@ def evaluate_retrieval(
def delta_r_at_1( def delta_r_at_1(
full_metrics: dict[str, float], full_metrics: dict[str, float],
baseline_metrics: dict[str, float], baseline_metrics: dict[str, float],
direction: str = "drone_to_sat",
) -> float: ) -> float:
"""Compute caption-quality proxy: R@1 gain from adding captions. """R@1 gain from adding captions: full - baseline."""
key = "r@1_query_to_gallery"
Args:
full_metrics: Metrics from training WITH caption losses.
baseline_metrics: Metrics from training WITHOUT caption losses.
direction: Retrieval direction to compare.
Returns:
Δ R@1 in [1, +1] range (positive = captions help).
"""
key = f"r@1_{direction}"
if key not in full_metrics or key not in baseline_metrics:
raise KeyError(
f"Missing '{key}' in one of the metric dicts. "
f"Available full={list(full_metrics)}, baseline={list(baseline_metrics)}"
)
return full_metrics[key] - baseline_metrics[key] return full_metrics[key] - baseline_metrics[key]
@gin.configurable @gin.configurable
def run_evaluation_from_checkpoint( def run_evaluation_from_checkpoint(
checkpoint_path: str, checkpoint_path: str,
test_manifest: str, test_query_file: str,
image_root: str, data_root: str,
output_path: str = "eval_report.json", output_path: str = "eval_report.json",
batch_size: int = 128, batch_size: int = 128,
num_workers: int = 4, num_workers: int = 4,
device: str = "cuda", device: str = "cuda",
) -> dict[str, float]: ) -> dict[str, float]:
"""Standalone evaluation entry point (gin-configurable). """Standalone evaluation from checkpoint."""
Args:
checkpoint_path: Path to .pt checkpoint from training.
test_manifest: Path to test manifest JSON.
image_root: Directory prefix for images.
output_path: Where to write the JSON report.
batch_size: Batch size for encoding.
num_workers: DataLoader workers.
device: torch device.
Returns:
Dict of retrieval metrics.
"""
from src.datasets.visloc_with_captions import ( from src.datasets.visloc_with_captions import (
VisLocCaptionDataset, GeoLocCaptionDataset,
collate_caption_batch, collate_caption_batch,
) )
model = DualEncoderCaptionTest().to(device) model = DualEncoderCaptionTest().to(device)
ckpt = torch.load(checkpoint_path, map_location=device) ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
model.load_state_dict(ckpt["model_state"]) model.load_state_dict(ckpt["model_state"])
model.eval() model.eval()
test_ds = VisLocCaptionDataset( test_ds = GeoLocCaptionDataset(
manifest_path=test_manifest, query_file=test_query_file,
image_root=image_root, data_root=data_root,
image_transform=model.preprocess, image_transform=model.preprocess,
) )
test_loader = DataLoader( test_loader = DataLoader(
@@ -222,15 +137,11 @@ def run_evaluation_from_checkpoint(
pin_memory=True, pin_memory=True,
) )
metrics = evaluate_retrieval( metrics = evaluate_retrieval(model=model, loader=test_loader, device=device)
model=model,
loader=test_loader,
device=device,
)
report = { report = {
"checkpoint": checkpoint_path, "checkpoint": checkpoint_path,
"test_manifest": test_manifest, "test_query_file": test_query_file,
"metrics": metrics, "metrics": metrics,
} }
out = Path(output_path) out = Path(output_path)

View File

@@ -1,14 +1,10 @@
from __future__ import annotations from __future__ import annotations
"""Multi-term InfoNCE loss for caption quality validation. """InfoNCE loss for cross-view geo-localization with optional text fusion.
Four InfoNCE terms over projected embeddings: Single symmetric InfoNCE between query (drone+text fused) and gallery (satellite).
L = lambda_ii * L_img_img Asymmetric weighting: query->gallery weighted higher (real use-case direction).
+ lambda_sc * L_sat_cap Cosine temperature schedule for sharper distribution over training.
+ lambda_dc * L_drone_cap
+ lambda_cc * L_cap_cap
where L_img_img is the classical symmetric CVGL contrastive loss
with asymmetric weights (0.6 drone->sat + 0.4 sat->drone).
""" """
import math import math
@@ -27,26 +23,12 @@ def _symmetric_info_nce(
weight_a2b: float = 0.5, weight_a2b: float = 0.5,
weight_b2a: float = 0.5, weight_b2a: float = 0.5,
) -> torch.Tensor: ) -> torch.Tensor:
"""Compute weighted symmetric InfoNCE between two L2-normalized embeddings. """Weighted symmetric InfoNCE. Positives on the diagonal."""
Args:
emb_a: First embedding set [B, D].
emb_b: Second embedding set [B, D]. Positive pairs are on the diagonal.
temperature: Softmax temperature (smaller = sharper distribution).
label_smoothing: Cross-entropy label smoothing epsilon.
weight_a2b: Weight for A-query direction.
weight_b2a: Weight for B-query direction.
Returns:
Scalar weighted loss.
"""
batch_size = emb_a.size(0) batch_size = emb_a.size(0)
logits = emb_a @ emb_b.t() / temperature logits = emb_a @ emb_b.t() / temperature
targets = torch.arange(batch_size, device=emb_a.device) targets = torch.arange(batch_size, device=emb_a.device)
loss_a2b = F.cross_entropy(logits, targets, label_smoothing=label_smoothing) loss_a2b = F.cross_entropy(logits, targets, label_smoothing=label_smoothing)
loss_b2a = F.cross_entropy(logits.t(), 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 return weight_a2b * loss_a2b + weight_b2a * loss_b2a
@@ -56,85 +38,23 @@ def cosine_temperature(
tau_init: float = 0.1, tau_init: float = 0.1,
tau_final: float = 0.01, tau_final: float = 0.01,
) -> float: ) -> float:
"""Cosine-decay schedule for InfoNCE temperature. """Cosine-decay schedule for InfoNCE temperature."""
Args:
epoch: Current training epoch (0-indexed).
total_epochs: Total number of epochs.
tau_init: Initial temperature.
tau_final: Final temperature.
Returns:
Temperature value for this epoch.
"""
total_epochs = max(total_epochs, 1) total_epochs = max(total_epochs, 1)
progress = min(max(epoch / total_epochs, 0.0), 1.0) progress = min(max(epoch / total_epochs, 0.0), 1.0)
cosine = 0.5 * (1.0 + math.cos(math.pi * progress)) cosine = 0.5 * (1.0 + math.cos(math.pi * progress))
return tau_final + (tau_init - tau_final) * cosine return tau_final + (tau_init - tau_final) * cosine
def curriculum_lambdas(
epoch: int,
warmup_epochs: int = 3,
text_ramp_epochs: int = 10,
lambda_ii: float = 1.0,
lambda_sc_max: float = 0.3,
lambda_dc_max: float = 0.3,
lambda_cc_max: float = 0.1,
) -> dict[str, float]:
"""Compute per-epoch loss weights under the curriculum schedule.
- Epochs 0..warmup_epochs: image-image only.
- Epochs warmup..text_ramp_epochs: linearly ramp sat-cap and drone-cap.
- Epochs >= text_ramp_epochs: full loss including caption-caption term.
Args:
epoch: Current epoch (0-indexed).
warmup_epochs: Number of warmup epochs (no text losses).
text_ramp_epochs: Epoch when text losses reach max.
lambda_ii: Constant weight for image-image loss.
lambda_sc_max: Max weight for satellite-caption loss.
lambda_dc_max: Max weight for drone-caption loss.
lambda_cc_max: Max weight for caption-caption loss.
Returns:
Dict with keys 'img_img', 'sat_cap', 'drone_cap', 'cap_cap'.
"""
if epoch < warmup_epochs:
ramp = 0.0
elif epoch >= text_ramp_epochs:
ramp = 1.0
else:
denom = max(text_ramp_epochs - warmup_epochs, 1)
ramp = (epoch - warmup_epochs) / denom
return {
"img_img": lambda_ii,
"sat_cap": lambda_sc_max * ramp,
"drone_cap": lambda_dc_max * ramp,
"cap_cap": lambda_cc_max * ramp,
}
@gin.configurable @gin.configurable
class MultiTermInfoNCE(nn.Module): class InfoNCELoss(nn.Module):
"""Multi-term InfoNCE loss with curriculum and cosine temperature. """Symmetric InfoNCE with cosine temperature schedule.
Produces total loss and per-component diagnostics. All inputs must be
L2-normalized embeddings of the same dimension.
Args: Args:
temperature_init: Initial temperature (epoch 0). temperature_init: Temperature at epoch 0.
temperature_final: Final temperature after cosine decay. temperature_final: Temperature after cosine decay.
label_smoothing: Cross-entropy label smoothing epsilon. label_smoothing: Cross-entropy label smoothing.
asym_drone_to_sat: Weight for drone->sat InfoNCE direction. weight_q2g: Weight for query->gallery direction.
asym_sat_to_drone: Weight for sat->drone InfoNCE direction. weight_g2q: Weight for gallery->query direction.
warmup_epochs: Epochs with image-image loss only.
text_ramp_epochs: Epoch at which text losses reach max.
lambda_ii: Constant weight for image-image loss.
lambda_sc_max: Max weight for sat-caption loss.
lambda_dc_max: Max weight for drone-caption loss.
lambda_cc_max: Max weight for caption-caption loss.
""" """
def __init__( def __init__(
@@ -142,27 +62,15 @@ class MultiTermInfoNCE(nn.Module):
temperature_init: float = 0.1, temperature_init: float = 0.1,
temperature_final: float = 0.01, temperature_final: float = 0.01,
label_smoothing: float = 0.1, label_smoothing: float = 0.1,
asym_drone_to_sat: float = 0.6, weight_q2g: float = 0.6,
asym_sat_to_drone: float = 0.4, weight_g2q: float = 0.4,
warmup_epochs: int = 3,
text_ramp_epochs: int = 10,
lambda_ii: float = 1.0,
lambda_sc_max: float = 0.3,
lambda_dc_max: float = 0.3,
lambda_cc_max: float = 0.1,
) -> None: ) -> None:
super().__init__() super().__init__()
self.temperature_init = temperature_init self.temperature_init = temperature_init
self.temperature_final = temperature_final self.temperature_final = temperature_final
self.label_smoothing = label_smoothing self.label_smoothing = label_smoothing
self.asym_drone_to_sat = asym_drone_to_sat self.weight_q2g = weight_q2g
self.asym_sat_to_drone = asym_sat_to_drone self.weight_g2q = weight_g2q
self.warmup_epochs = warmup_epochs
self.text_ramp_epochs = text_ramp_epochs
self.lambda_ii = lambda_ii
self.lambda_sc_max = lambda_sc_max
self.lambda_dc_max = lambda_dc_max
self.lambda_cc_max = lambda_cc_max
def forward( def forward(
self, self,
@@ -170,17 +78,16 @@ class MultiTermInfoNCE(nn.Module):
epoch: int, epoch: int,
total_epochs: int, total_epochs: int,
) -> dict[str, torch.Tensor]: ) -> dict[str, torch.Tensor]:
"""Compute multi-term loss. """Compute InfoNCE loss.
Args: Args:
embeddings: Dict with keys 'drone', 'sat', and optionally embeddings: Dict with 'query' and 'gallery' [B, D] L2-normalized,
'cap_drone', 'cap_sat'. Each [B, D] L2-normalized. plus 'gate' (float) from fusion module.
epoch: Current epoch (0-indexed). epoch: Current epoch (0-indexed).
total_epochs: Total epochs for temperature schedule. total_epochs: Total epochs for temperature schedule.
Returns: Returns:
Dict with scalar tensors: 'total', 'img_img', 'sat_cap', Dict with 'total', 'temperature', 'gate'.
'drone_cap', 'cap_cap', plus 'temperature' and 'lambdas'.
""" """
tau = cosine_temperature( tau = cosine_temperature(
epoch=epoch, epoch=epoch,
@@ -188,75 +95,20 @@ class MultiTermInfoNCE(nn.Module):
tau_init=self.temperature_init, tau_init=self.temperature_init,
tau_final=self.temperature_final, tau_final=self.temperature_final,
) )
lambdas = curriculum_lambdas(
epoch=epoch,
warmup_epochs=self.warmup_epochs,
text_ramp_epochs=self.text_ramp_epochs,
lambda_ii=self.lambda_ii,
lambda_sc_max=self.lambda_sc_max,
lambda_dc_max=self.lambda_dc_max,
lambda_cc_max=self.lambda_cc_max,
)
drone = embeddings["drone"] loss = _symmetric_info_nce(
sat = embeddings["sat"] emb_a=embeddings["query"],
emb_b=embeddings["gallery"],
# Image-image symmetric InfoNCE with asymmetric weights.
loss_ii = _symmetric_info_nce(
emb_a=drone,
emb_b=sat,
temperature=tau, temperature=tau,
label_smoothing=self.label_smoothing, label_smoothing=self.label_smoothing,
weight_a2b=self.asym_drone_to_sat, weight_a2b=self.weight_q2g,
weight_b2a=self.asym_sat_to_drone, weight_b2a=self.weight_g2q,
) )
loss_sc = torch.zeros_like(loss_ii) gate = embeddings.get("gate", 1.0)
loss_dc = torch.zeros_like(loss_ii)
loss_cc = torch.zeros_like(loss_ii)
if "cap_sat" in embeddings and lambdas["sat_cap"] > 0:
loss_sc = _symmetric_info_nce(
emb_a=sat,
emb_b=embeddings["cap_sat"],
temperature=tau,
label_smoothing=self.label_smoothing,
)
if "cap_drone" in embeddings and lambdas["drone_cap"] > 0:
loss_dc = _symmetric_info_nce(
emb_a=drone,
emb_b=embeddings["cap_drone"],
temperature=tau,
label_smoothing=self.label_smoothing,
)
if (
"cap_drone" in embeddings
and "cap_sat" in embeddings
and lambdas["cap_cap"] > 0
):
loss_cc = _symmetric_info_nce(
emb_a=embeddings["cap_drone"],
emb_b=embeddings["cap_sat"],
temperature=tau,
label_smoothing=self.label_smoothing,
)
total = (
lambdas["img_img"] * loss_ii
+ lambdas["sat_cap"] * loss_sc
+ lambdas["drone_cap"] * loss_dc
+ lambdas["cap_cap"] * loss_cc
)
return { return {
"total": total, "total": loss,
"img_img": loss_ii.detach(), "temperature": torch.tensor(tau, device=loss.device),
"sat_cap": loss_sc.detach(), "gate": torch.tensor(gate, device=loss.device),
"drone_cap": loss_dc.detach(),
"cap_cap": loss_cc.detach(),
"temperature": torch.tensor(tau, device=total.device),
"lambda_ii": torch.tensor(lambdas["img_img"], device=total.device),
"lambda_sc": torch.tensor(lambdas["sat_cap"], device=total.device),
"lambda_dc": torch.tensor(lambdas["drone_cap"], device=total.device),
"lambda_cc": torch.tensor(lambdas["cap_cap"], device=total.device),
} }

View File

@@ -1,10 +1,16 @@
from __future__ import annotations from __future__ import annotations
"""Dual encoder for caption quality test on UAV-VisLoc. """Dual encoder for caption quality test on cross-view geo-localization.
GeoRSCLIP ViT-B/32 backbone (image + text towers, shared 512-dim space). GeoRSCLIP ViT-B/32 backbone (image + text towers, shared 512-dim space).
Image encoder is frozen, text encoder has partial unfreeze (last block + projection). Image encoder frozen. Text encoder with partial unfreeze.
Separate trainable projection heads for drone/sat/text branches.
Architecture:
Query branch: GeoRSCLIP_img(drone) + GeoRSCLIP_text(caption) -> GatedFusion -> proj -> query_emb
Gallery branch: GeoRSCLIP_img(sat) -> proj -> gallery_emb
Loss: InfoNCE(query_emb, gallery_emb)
Baseline mode: fusion gate forced to 1.0 (text ignored).
""" """
from typing import Literal from typing import Literal
@@ -16,16 +22,8 @@ import torch.nn as nn
import torch.nn.functional as F import torch.nn.functional as F
@gin.configurable
class ProjectionHead(nn.Module): class ProjectionHead(nn.Module):
"""Single-layer L2-normalized projection head. """MLP projection head with L2 normalization."""
Args:
in_dim: Input embedding dimension.
out_dim: Output embedding dimension (512 for GeoRSCLIP space).
use_mlp: If True, use 2-layer MLP with GELU, else Linear.
hidden_dim: Hidden dim when use_mlp=True (defaults to 2*in_dim).
"""
def __init__( def __init__(
self, self,
@@ -46,33 +44,56 @@ class ProjectionHead(nn.Module):
self.proj = nn.Linear(in_dim, out_dim) self.proj = nn.Linear(in_dim, out_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor: def forward(self, x: torch.Tensor) -> torch.Tensor:
"""Project features and L2-normalize. return F.normalize(self.proj(x), dim=-1)
Args:
x: Input features [B, in_dim].
Returns: @gin.configurable
Normalized embeddings [B, out_dim]. class GatedFusion(nn.Module):
""" """Learnable gated fusion of image and text embeddings.
x = self.proj(x)
return F.normalize(x, dim=-1) q = sigma(alpha) * img + (1 - sigma(alpha)) * text
alpha is a single learnable scalar, initialized so that gate ~ init_gate.
When baseline_mode=True, gate is clamped to 1.0 (text contribution = 0).
"""
def __init__(self, init_gate: float = 0.7, 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)
self.baseline_mode = baseline_mode
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
gate = torch.sigmoid(self.alpha)
return gate * img_feat + (1.0 - gate) * text_feat
@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()
@gin.configurable @gin.configurable
class DualEncoderCaptionTest(nn.Module): class DualEncoderCaptionTest(nn.Module):
"""GeoRSCLIP dual encoder for caption quality validation on UAV-VisLoc. """GeoRSCLIP dual encoder with gated text fusion on query branch.
Shared image encoder for drone and satellite views. Text encoder with
partial unfreeze. Three separate trainable projection heads map raw
GeoRSCLIP embeddings into the shared 512-dim retrieval space.
Args: Args:
variant: open_clip model variant name (e.g., 'ViT-B-32'). variant: open_clip model variant name.
pretrained_path: Path to GeoRSCLIP checkpoint (RS5M_ViT-B-32.pt). pretrained_path: Path to GeoRSCLIP checkpoint.
unfreeze_mode: Which text encoder layers to unfreeze. unfreeze_mode: Text encoder unfreeze strategy.
embed_dim: Output retrieval dimension (default 512). embed_dim: Output retrieval embedding dimension.
use_mlp_heads: If True, projection heads are 2-layer MLPs. use_mlp_heads: Use 2-layer MLP projection heads.
shared_image_head: If True, drone and sat use single projection head. baseline_mode: If True, fusion gate = 1.0 (no text).
init_gate: Initial gate value (image weight).
device: torch device. device: torch device.
""" """
@@ -80,19 +101,19 @@ class DualEncoderCaptionTest(nn.Module):
self, self,
variant: str = "ViT-B-32", variant: str = "ViT-B-32",
pretrained_path: str = "RS5M_ViT-B-32.pt", pretrained_path: str = "RS5M_ViT-B-32.pt",
unfreeze_mode: Literal["none", "projection", "last_block", "full"] = "last_block", unfreeze_mode: Literal["none", "projection", "last_block"] = "last_block",
embed_dim: int = 512, embed_dim: int = 512,
use_mlp_heads: bool = False, use_mlp_heads: bool = False,
shared_image_head: bool = True, baseline_mode: bool = False,
init_gate: float = 0.7,
device: str = "cuda", device: str = "cuda",
) -> None: ) -> None:
super().__init__() super().__init__()
self.variant = variant
self.embed_dim = embed_dim self.embed_dim = embed_dim
self.shared_image_head = shared_image_head
self.device = device self.device = device
self.baseline_mode = baseline_mode
# Load open_clip model (GeoRSCLIP compatible with open_clip API). # Load GeoRSCLIP via open_clip.
self.model, _, self.preprocess = open_clip.create_model_and_transforms( self.model, _, self.preprocess = open_clip.create_model_and_transforms(
model_name=variant, model_name=variant,
pretrained=pretrained_path, pretrained=pretrained_path,
@@ -100,45 +121,28 @@ class DualEncoderCaptionTest(nn.Module):
) )
self.tokenizer = open_clip.get_tokenizer(variant) self.tokenizer = open_clip.get_tokenizer(variant)
# Native GeoRSCLIP embedding dim (for ViT-B/32 = 512). native_dim = self._infer_native_dim()
self._native_dim = self._infer_native_dim()
# Freeze everything by default. # Freeze everything.
for p in self.model.parameters(): for p in self.model.parameters():
p.requires_grad = False p.requires_grad = False
# Apply unfreeze strategy. # Selectively unfreeze text encoder (only if not baseline).
self._apply_unfreeze(unfreeze_mode) if not baseline_mode:
self._apply_unfreeze(unfreeze_mode)
# Projection heads (trainable). # Gated fusion on query branch.
self.proj_text = ProjectionHead( self.fusion = GatedFusion(init_gate=init_gate, baseline_mode=baseline_mode)
in_dim=self._native_dim,
out_dim=embed_dim, # Projection heads.
use_mlp=use_mlp_heads, self.proj_query = ProjectionHead(
in_dim=native_dim, out_dim=embed_dim, use_mlp=use_mlp_heads,
)
self.proj_gallery = ProjectionHead(
in_dim=native_dim, out_dim=embed_dim, use_mlp=use_mlp_heads,
) )
if shared_image_head:
self.proj_image = ProjectionHead(
in_dim=self._native_dim,
out_dim=embed_dim,
use_mlp=use_mlp_heads,
)
self.proj_drone = None # type: ignore[assignment]
self.proj_sat = None # type: ignore[assignment]
else:
self.proj_image = None # type: ignore[assignment]
self.proj_drone = ProjectionHead(
in_dim=self._native_dim,
out_dim=embed_dim,
use_mlp=use_mlp_heads,
)
self.proj_sat = ProjectionHead(
in_dim=self._native_dim,
out_dim=embed_dim,
use_mlp=use_mlp_heads,
)
def _infer_native_dim(self) -> int: def _infer_native_dim(self) -> int:
"""Infer native embedding dimension from model (typically 512 for ViT-B/32)."""
if hasattr(self.model, "text_projection"): if hasattr(self.model, "text_projection"):
shape = self.model.text_projection.shape shape = self.model.text_projection.shape
return int(shape[1] if shape.ndim == 2 else shape[0]) return int(shape[1] if shape.ndim == 2 else shape[0])
@@ -146,17 +150,11 @@ class DualEncoderCaptionTest(nn.Module):
def _apply_unfreeze( def _apply_unfreeze(
self, self,
unfreeze_mode: Literal["none", "projection", "last_block", "full"], unfreeze_mode: Literal["none", "projection", "last_block"],
) -> None: ) -> None:
"""Selectively enable gradients for text encoder."""
if unfreeze_mode == "none": if unfreeze_mode == "none":
return return
if unfreeze_mode == "full": # Unfreeze text_projection.
for p in self.model.parameters():
p.requires_grad = True
return
# Always unfreeze text_projection if available.
if hasattr(self.model, "text_projection"): if hasattr(self.model, "text_projection"):
tp = self.model.text_projection tp = self.model.text_projection
if isinstance(tp, nn.Parameter): if isinstance(tp, nn.Parameter):
@@ -164,38 +162,17 @@ class DualEncoderCaptionTest(nn.Module):
elif isinstance(tp, nn.Module): elif isinstance(tp, nn.Module):
for p in tp.parameters(): for p in tp.parameters():
p.requires_grad = True p.requires_grad = True
# Unfreeze last transformer block.
# Additionally unfreeze last transformer block.
if unfreeze_mode == "last_block" and hasattr(self.model, "transformer"): if unfreeze_mode == "last_block" and hasattr(self.model, "transformer"):
last_block = self.model.transformer.resblocks[-1] for p in self.model.transformer.resblocks[-1].parameters():
for p in last_block.parameters():
p.requires_grad = True p.requires_grad = True
def encode_image(self, images: torch.Tensor) -> torch.Tensor: def encode_image(self, images: torch.Tensor) -> torch.Tensor:
"""Encode images through GeoRSCLIP image encoder (no projection head).
Args:
images: Preprocessed image tensor [B, 3, H, W].
Returns:
Raw image embeddings [B, native_dim].
"""
feats = self.model.encode_image(images) feats = self.model.encode_image(images)
return F.normalize(feats, dim=-1) return F.normalize(feats, dim=-1)
def encode_text(self, texts: list[str] | torch.Tensor) -> torch.Tensor: def encode_text(self, texts: list[str]) -> torch.Tensor:
"""Encode text captions through GeoRSCLIP text encoder. tokens = self.tokenizer(list(texts)).to(self.device).long()
Args:
texts: List of strings or pre-tokenized LongTensor [B, seq_len].
Returns:
Raw text embeddings [B, native_dim].
"""
if isinstance(texts, (list, tuple)):
tokens = self.tokenizer(list(texts)).to(self.device).long()
else:
tokens = texts.to(self.device).long()
feats = self.model.encode_text(tokens) feats = self.model.encode_text(tokens)
return F.normalize(feats, dim=-1) return F.normalize(feats, dim=-1)
@@ -204,40 +181,37 @@ class DualEncoderCaptionTest(nn.Module):
drone_img: torch.Tensor, drone_img: torch.Tensor,
sat_img: torch.Tensor, sat_img: torch.Tensor,
caption_drone: list[str] | None = None, caption_drone: list[str] | None = None,
caption_sat: list[str] | None = None,
) -> dict[str, torch.Tensor]: ) -> dict[str, torch.Tensor]:
"""Forward pass producing projected embeddings for all branches. """Forward pass.
Args: Args:
drone_img: Drone RGB tensor [B, 3, H, W]. drone_img: Drone images [B, 3, H, W].
sat_img: Satellite RGB tensor [B, 3, H, W]. sat_img: Satellite images [B, 3, H, W].
caption_drone: List of drone captions, one per batch item. caption_drone: Drone captions (P3 fingerprint), one per sample.
caption_sat: List of satellite captions, one per batch item.
Returns: Returns:
Dict with keys 'drone', 'sat', 'cap_drone', 'cap_sat', each Dict with 'query' [B, embed_dim], 'gallery' [B, embed_dim],
containing [B, embed_dim] L2-normalized embeddings. and 'gate' (scalar) for logging.
Keys for missing captions are absent.
""" """
out: dict[str, torch.Tensor] = {} # Gallery branch: satellite only.
drone_feat = self.encode_image(drone_img)
sat_feat = self.encode_image(sat_img) sat_feat = self.encode_image(sat_img)
gallery = self.proj_gallery(sat_feat)
if self.shared_image_head: # Query branch: drone + optional text fusion.
out["drone"] = self.proj_image(drone_feat) drone_feat = self.encode_image(drone_img)
out["sat"] = self.proj_image(sat_feat)
else:
out["drone"] = self.proj_drone(drone_feat)
out["sat"] = self.proj_sat(sat_feat)
if caption_drone is not None: text_feat = None
out["cap_drone"] = self.proj_text(self.encode_text(caption_drone)) if caption_drone is not None and not self.baseline_mode:
if caption_sat is not None: text_feat = self.encode_text(caption_drone)
out["cap_sat"] = self.proj_text(self.encode_text(caption_sat))
return out fused = self.fusion(drone_feat, text_feat)
query = self.proj_query(fused)
return {
"query": query,
"gallery": gallery,
"gate": self.fusion.gate_value,
}
def trainable_parameters(self) -> list[nn.Parameter]: def trainable_parameters(self) -> list[nn.Parameter]:
"""Return list of trainable parameters for optimizer construction."""
return [p for p in self.parameters() if p.requires_grad] return [p for p in self.parameters() if p.requires_grad]

View File

@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
"""Training loop for caption quality validation on UAV-VisLoc. """Training loop for caption quality test on cross-view geo-localization.
Uses gin-configurable DualEncoderCaptionTest + MultiTermInfoNCE. GeoRSCLIP dual encoder with GatedFusion on query branch.
Logs per-component losses, temperature, and lambdas each step. Single InfoNCE loss: query(drone+text) vs gallery(satellite).
Saves checkpoint + eval snapshot every epoch.
""" """
import argparse import argparse
@@ -22,11 +21,11 @@ from torch.optim.lr_scheduler import CosineAnnealingLR
from torch.utils.data import DataLoader from torch.utils.data import DataLoader
from src.datasets.visloc_with_captions import ( from src.datasets.visloc_with_captions import (
VisLocCaptionDataset, GeoLocCaptionDataset,
collate_caption_batch, collate_caption_batch,
) )
from src.eval.evaluate import evaluate_retrieval from src.eval.evaluate import evaluate_retrieval
from src.losses.multi_infonce import MultiTermInfoNCE from src.losses.multi_infonce import InfoNCELoss
from src.models.dual_encoder import DualEncoderCaptionTest from src.models.dual_encoder import DualEncoderCaptionTest
LOGGER = logging.getLogger("caption_test.train") LOGGER = logging.getLogger("caption_test.train")
@@ -34,20 +33,20 @@ LOGGER = logging.getLogger("caption_test.train")
@gin.configurable @gin.configurable
class TrainConfig: class TrainConfig:
"""Top-level training configuration (gin-configurable). """Top-level training configuration.
Args: Args:
train_manifest: Path to training manifest JSON. train_query_file: Path to train_query.txt.
val_manifest: Path to validation manifest JSON. val_query_file: Path to test_query.txt (used as val).
image_root: Directory prefix for images. data_root: Root of UAV-GeoLoc dataset.
output_dir: Where to save checkpoints and logs. output_dir: Checkpoint and log output directory.
epochs: Number of training epochs. epochs: Number of training epochs.
batch_size: Mini-batch size. batch_size: Mini-batch size.
num_workers: DataLoader worker count. num_workers: DataLoader workers.
learning_rate: AdamW initial LR. learning_rate: AdamW initial LR.
weight_decay: AdamW weight decay. weight_decay: AdamW weight decay.
grad_clip: Max gradient norm for clipping (0 disables). grad_clip: Max gradient norm (0 disables).
use_amp: Enable fp16 mixed-precision training. use_amp: Enable fp16 mixed-precision.
eval_every: Run validation every N epochs. eval_every: Run validation every N epochs.
seed: Random seed. seed: Random seed.
device: torch device. device: torch device.
@@ -55,24 +54,24 @@ class TrainConfig:
def __init__( def __init__(
self, self,
train_manifest: str = "data/visloc_train.json", train_query_file: str = "Index/train_query.txt",
val_manifest: str = "data/visloc_val.json", val_query_file: str = "Index/test_query.txt",
image_root: str = "data/visloc/images", data_root: str = "/mnt/data1tb/cvgl_datasets/UAV-GeoLoc",
output_dir: str = "out/caption_test", output_dir: str = "out/caption_test",
epochs: int = 30, epochs: int = 10,
batch_size: int = 128, batch_size: int = 128,
num_workers: int = 4, num_workers: int = 4,
learning_rate: float = 1e-4, learning_rate: float = 1e-4,
weight_decay: float = 1e-4, weight_decay: float = 1e-4,
grad_clip: float = 1.0, grad_clip: float = 1.0,
use_amp: bool = True, use_amp: bool = True,
eval_every: int = 1, eval_every: int = 2,
seed: int = 42, seed: int = 42,
device: str = "cuda", device: str = "cuda",
) -> None: ) -> None:
self.train_manifest = train_manifest self.train_query_file = train_query_file
self.val_manifest = val_manifest self.val_query_file = val_query_file
self.image_root = image_root self.data_root = data_root
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.epochs = epochs self.epochs = epochs
self.batch_size = batch_size self.batch_size = batch_size
@@ -87,11 +86,8 @@ class TrainConfig:
def _set_seed(seed: int) -> None: def _set_seed(seed: int) -> None:
"""Seed Python, NumPy and PyTorch RNGs."""
import random as _random import random as _random
import numpy as _np import numpy as _np
_random.seed(seed) _random.seed(seed)
_np.random.seed(seed) _np.random.seed(seed)
torch.manual_seed(seed) torch.manual_seed(seed)
@@ -99,50 +95,14 @@ def _set_seed(seed: int) -> None:
def _atomic_save(obj: dict, path: Path) -> None: def _atomic_save(obj: dict, path: Path) -> None:
"""Write torch checkpoint atomically (temp file + rename)."""
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp") tmp_path = path.with_suffix(path.suffix + ".tmp")
torch.save(obj, tmp_path) torch.save(obj, tmp_path)
tmp_path.replace(path) tmp_path.replace(path)
def _step_loss(
model: DualEncoderCaptionTest,
loss_fn: MultiTermInfoNCE,
batch: dict,
epoch: int,
total_epochs: int,
device: str,
use_amp: bool,
) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
"""Single training forward pass returning (total_loss, diagnostics)."""
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"]
caption_sat = batch["caption_sat"]
with autocast(device_type="cuda", enabled=use_amp):
embeddings = model(
drone_img=drone_img,
sat_img=sat_img,
caption_drone=caption_drone,
caption_sat=caption_sat,
)
loss_dict = loss_fn(
embeddings=embeddings,
epoch=epoch,
total_epochs=total_epochs,
)
return loss_dict["total"], loss_dict
def train(config_path: str) -> None: def train(config_path: str) -> None:
"""Run the full training loop driven by gin configuration. """Run full training loop from gin config."""
Args:
config_path: Path to .gin config file.
"""
gin.parse_config_file(config_path) gin.parse_config_file(config_path)
cfg = TrainConfig() cfg = TrainConfig()
@@ -153,21 +113,20 @@ def train(config_path: str) -> None:
_set_seed(cfg.seed) _set_seed(cfg.seed)
cfg.output_dir.mkdir(parents=True, exist_ok=True) cfg.output_dir.mkdir(parents=True, exist_ok=True)
# Model + loss # Model + loss.
model = DualEncoderCaptionTest().to(cfg.device) model = DualEncoderCaptionTest().to(cfg.device)
loss_fn = MultiTermInfoNCE().to(cfg.device) loss_fn = InfoNCELoss().to(cfg.device)
# Datasets use the same preprocess function the model already holds.
preprocess = model.preprocess preprocess = model.preprocess
train_ds = VisLocCaptionDataset( train_ds = GeoLocCaptionDataset(
manifest_path=cfg.train_manifest, query_file=cfg.train_query_file,
image_root=cfg.image_root, data_root=cfg.data_root,
image_transform=preprocess, image_transform=preprocess,
) )
val_ds = VisLocCaptionDataset( val_ds = GeoLocCaptionDataset(
manifest_path=cfg.val_manifest, query_file=cfg.val_query_file,
image_root=cfg.image_root, data_root=cfg.data_root,
image_transform=preprocess, image_transform=preprocess,
) )
@@ -197,6 +156,14 @@ def train(config_path: str) -> None:
scheduler = CosineAnnealingLR(optimizer, T_max=cfg.epochs) scheduler = CosineAnnealingLR(optimizer, T_max=cfg.epochs)
scaler = GradScaler(enabled=cfg.use_amp) scaler = GradScaler(enabled=cfg.use_amp)
n_trainable = sum(p.numel() for p in model.trainable_parameters())
n_total = sum(p.numel() for p in model.parameters())
LOGGER.info(
"trainable=%d (%.2f%%) total=%d train=%d val=%d",
n_trainable, 100.0 * n_trainable / n_total,
n_total, len(train_ds), len(val_ds),
)
history: list[dict] = [] history: list[dict] = []
for epoch in range(cfg.epochs): for epoch in range(cfg.epochs):
@@ -208,17 +175,25 @@ def train(config_path: str) -> None:
for batch in train_loader: for batch in train_loader:
optimizer.zero_grad(set_to_none=True) optimizer.zero_grad(set_to_none=True)
total_loss, loss_dict = _step_loss( drone_img = batch["drone_img"].to(cfg.device, non_blocking=True)
model=model, sat_img = batch["sat_img"].to(cfg.device, non_blocking=True)
loss_fn=loss_fn, caption_drone = batch["caption_drone"]
batch=batch,
epoch=epoch,
total_epochs=cfg.epochs,
device=cfg.device,
use_amp=cfg.use_amp,
)
with autocast(device_type="cuda", enabled=cfg.use_amp):
embeddings = model(
drone_img=drone_img,
sat_img=sat_img,
caption_drone=caption_drone,
)
loss_dict = loss_fn(
embeddings=embeddings,
epoch=epoch,
total_epochs=cfg.epochs,
)
total_loss = loss_dict["total"]
scaler.scale(total_loss).backward() scaler.scale(total_loss).backward()
if cfg.grad_clip > 0: if cfg.grad_clip > 0:
scaler.unscale_(optimizer) scaler.unscale_(optimizer)
nn.utils.clip_grad_norm_( nn.utils.clip_grad_norm_(
@@ -228,9 +203,8 @@ def train(config_path: str) -> None:
scaler.step(optimizer) scaler.step(optimizer)
scaler.update() scaler.update()
# Accumulate diagnostics. for key, val in loss_dict.items():
for key, tensor_val in loss_dict.items(): agg[key] = agg.get(key, 0.0) + float(val.item())
agg[key] = agg.get(key, 0.0) + float(tensor_val.item())
n_batches += 1 n_batches += 1
scheduler.step() scheduler.step()
@@ -238,20 +212,15 @@ def train(config_path: str) -> None:
means = {k: v / max(n_batches, 1) for k, v in agg.items()} means = {k: v / max(n_batches, 1) for k, v in agg.items()}
LOGGER.info( LOGGER.info(
"epoch=%d time=%.1fs lr=%.2e total=%.4f img_img=%.4f " "epoch=%d time=%.1fs lr=%.2e loss=%.4f tau=%.4f gate=%.4f",
"sat_cap=%.4f drone_cap=%.4f cap_cap=%.4f tau=%.4f", epoch, elapsed,
epoch,
elapsed,
optimizer.param_groups[0]["lr"], optimizer.param_groups[0]["lr"],
means.get("total", 0.0), means.get("total", 0.0),
means.get("img_img", 0.0),
means.get("sat_cap", 0.0),
means.get("drone_cap", 0.0),
means.get("cap_cap", 0.0),
means.get("temperature", 0.0), means.get("temperature", 0.0),
means.get("gate", 1.0),
) )
epoch_record = { epoch_record: dict = {
"epoch": epoch, "epoch": epoch,
"elapsed_seconds": elapsed, "elapsed_seconds": elapsed,
"train": means, "train": means,
@@ -267,18 +236,15 @@ def train(config_path: str) -> None:
) )
epoch_record["val"] = val_metrics epoch_record["val"] = val_metrics
LOGGER.info( LOGGER.info(
"val epoch=%d R@1_d2s=%.4f R@1_s2d=%.4f " "val epoch=%d R@1_q2g=%.4f R@5_q2g=%.4f R@10_q2g=%.4f",
"R@1_t2s=%.4f R@1_t2d=%.4f",
epoch, epoch,
val_metrics.get("r@1_drone_to_sat", 0.0), val_metrics.get("r@1_query_to_gallery", 0.0),
val_metrics.get("r@1_sat_to_drone", 0.0), val_metrics.get("r@5_query_to_gallery", 0.0),
val_metrics.get("r@1_text_to_sat", 0.0), val_metrics.get("r@10_query_to_gallery", 0.0),
val_metrics.get("r@1_text_to_drone", 0.0),
) )
history.append(epoch_record) history.append(epoch_record)
# Checkpoint per epoch.
_atomic_save( _atomic_save(
obj={ obj={
"epoch": epoch, "epoch": epoch,
@@ -289,7 +255,6 @@ def train(config_path: str) -> None:
path=cfg.output_dir / f"ckpt_epoch{epoch:03d}.pt", path=cfg.output_dir / f"ckpt_epoch{epoch:03d}.pt",
) )
# Save training history.
history_path = cfg.output_dir / "history.json" history_path = cfg.output_dir / "history.json"
with history_path.open("w", encoding="utf-8") as f: with history_path.open("w", encoding="utf-8") as f:
json.dump(history, f, indent=2) json.dump(history, f, indent=2)
@@ -299,12 +264,7 @@ def train(config_path: str) -> None:
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description="Caption quality test training.") parser = argparse.ArgumentParser(description="Caption quality test training.")
parser.add_argument( parser.add_argument("--config", type=str, required=True, help="Gin config file.")
"--config",
type=str,
required=True,
help="Path to gin configuration file.",
)
args = parser.parse_args() args = parser.parse_args()
train(config_path=args.config) train(config_path=args.config)