Files
World-UAV-ds/dataloader_v2.py
Pikaliov 4ff36ce188 Initial import: World-UAV prepro
Add dataloaders (v1/v2), analysis scripts, and documentation for working with UAV-GeoLoc (World-UAV).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 12:44:49 +03:00

769 lines
28 KiB
Python

"""
PyTorch DataLoader for UAV-GeoLoc dataset (Cross-View Geo-Localization).
Dataset structure (discovered empirically):
- 372 scenes: 171 Country + 200 Terrain + 1 Rot
- 927K images: 652K query (drone, 512x512 JPEG) + 275K DB (satellite, PNG)
- Satellite crops: stride = crop_size / 2 (50% overlap), 11 unique sizes (100-1000 px)
- Query variants: 3 heights (100/125/150m) x 8 azimuths (0-315, step 45) = 24 per scene
- Camera: 30 deg vertical FOV, top-down, 76 frames per trajectory
Supports:
- Triplet training with random / semi-positive-aware negative mining
- Pair training for contrastive learning
- Evaluation with separate query / DB sets
- Camera metadata (height, azimuth, GPS) per query
- GPS-based localization error computation
- Satellite tiling utility for new data
"""
import json
import logging
import math
import os
import re
import random
from pathlib import Path
from typing import Optional
import numpy as np
import torch
from PIL import Image
from torch.utils.data import Dataset, DataLoader
import torchvision.transforms as T
# ── Index file parsing ──────────────────────────────────────────────────────
def _parse_query_line(line: str):
"""Parse a query index line. Handles paths with spaces.
Format: <query_path> <label_int> <pos_db_1> [pos_db_2 ...]
DB paths always contain /DB/img/crop_.
"""
line = line.strip()
if not line:
return None
db_pattern = re.compile(r'\S*DB/img/crop_\S+')
db_matches = list(db_pattern.finditer(line))
if not db_matches:
return None
before_db = line[:db_matches[0].start()].rstrip()
label_match = re.search(r'\s(\d+)\s*$', before_db)
if not label_match:
return None
label = int(label_match.group(1))
query_path = before_db[:label_match.start()].strip()
positives = [m.group() for m in db_matches]
return query_path, label, positives
def _parse_height_rot(query_path: str):
"""Extract height and rotation from query path.
e.g. '.../height125_rot270/footage/...' -> (125, 270)
Handles typos like 'eight150', '125_rot315', 'ght100'.
"""
m = re.search(r'(?:h(?:eigh?t?)?)?(\d{3})_rot(\d+)', query_path)
if m:
return int(m.group(1)), int(m.group(2))
return None, None
# ── GPS utilities ───────────────────────────────────────────────────────────
def load_db_positions(db_pos_path: str) -> dict:
"""Load db_postion.txt -> {crop_filename: (lon, lat, res_x, res_y)}."""
positions = {}
if not os.path.isfile(db_pos_path):
return positions
with open(db_pos_path) as f:
for line in f:
parts = line.strip().split()
if len(parts) >= 3:
name = parts[0]
lon, lat = float(parts[1]), float(parts[2])
res_x = float(parts[3]) if len(parts) > 3 else 0.0
res_y = float(parts[4]) if len(parts) > 4 else 0.0
positions[name] = (lon, lat, res_x, res_y)
return positions
def haversine_m(lon1, lat1, lon2, lat2):
"""Haversine distance in meters between two GPS points."""
R = 6_371_000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a))
# ── Satellite tiling utility ────────────────────────────────────────────────
def tile_satellite_image(
image: np.ndarray,
crop_size: int = 200,
stride: Optional[int] = None,
) -> list:
"""Tile a satellite image following the UAV-GeoLoc convention.
Args:
image: HxWx3 numpy array (the merge.tif).
crop_size: Size of each square crop in pixels.
stride: Step between crops. Default = crop_size // 2 (50% overlap).
Returns:
List of (crop_array, x_idx, y_idx, pixel_x, pixel_y) tuples.
crop_X_Y.png naming: X = col index, Y = row index.
"""
if stride is None:
stride = crop_size // 2
h, w = image.shape[:2]
crops = []
for x_idx, px in enumerate(range(0, w - crop_size + 1, stride)):
for y_idx, py in enumerate(range(0, h - crop_size + 1, stride)):
crop = image[py:py + crop_size, px:px + crop_size]
crops.append((crop, x_idx, y_idx, px, py))
return crops
# ── Default transforms ──────────────────────────────────────────────────────
def _default_train_query_transform(img_size=224):
return T.Compose([
T.Resize((img_size, img_size)),
T.RandomHorizontalFlip(),
T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1),
T.ToTensor(),
T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
def _default_train_db_transform(img_size=224):
return T.Compose([
T.Resize((img_size, img_size)),
T.RandomHorizontalFlip(),
T.ToTensor(),
T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
def _default_eval_transform(img_size=224):
return T.Compose([
T.Resize((img_size, img_size)),
T.ToTensor(),
T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
# ── Training dataset: triplets ──────────────────────────────────────────────
class UAVGeoLocTrain(Dataset):
"""Training dataset returning (query, positive, negative) triplets.
Supports semi-positive-aware negative mining: negatives are guaranteed
to NOT be in the positive or semi-positive set for the query's scene.
"""
def __init__(
self,
root: str,
query_file: str = "Index/train_query.txt",
db_file: str = "Index/train_db.txt",
img_size: int = 224,
transform_query=None,
transform_db=None,
):
self.root = root
self.queries = []
with open(os.path.join(root, query_file)) as f:
for line in f:
parsed = _parse_query_line(line)
if parsed is None:
continue
query_path, label, positives = parsed
height, rot = _parse_height_rot(query_path)
self.queries.append({
"path": query_path,
"label": label,
"positives": positives,
"height": height,
"rotation": rot,
})
self.db_paths = []
with open(os.path.join(root, db_file)) as f:
for line in f:
p = line.strip()
if p:
self.db_paths.append(p)
# Build label -> all positive+semi-positive DB paths for clean negative mining
self._label_positives = {}
for q in self.queries:
lbl = q["label"]
if lbl not in self._label_positives:
self._label_positives[lbl] = set()
self._label_positives[lbl].update(q["positives"])
self.transform_query = transform_query or _default_train_query_transform(img_size)
self.transform_db = transform_db or _default_train_db_transform(img_size)
def __len__(self):
return len(self.queries)
def _load(self, rel_path):
return Image.open(os.path.join(self.root, rel_path)).convert("RGB")
def __getitem__(self, idx):
q = self.queries[idx]
query_img = self.transform_query(self._load(q["path"]))
pos_img = self.transform_db(self._load(random.choice(q["positives"])))
# Negative: random DB image not in this scene's positive set
pos_set = self._label_positives.get(q["label"], set())
while True:
neg_path = random.choice(self.db_paths)
if neg_path not in pos_set:
break
neg_img = self.transform_db(self._load(neg_path))
return {
"query": query_img,
"positive": pos_img,
"negative": neg_img,
"label": q["label"],
"height": q["height"] or 0,
"rotation": q["rotation"] or 0,
}
# ── Training dataset: pairs ─────────────────────────────────────────────────
class UAVGeoLocPair(Dataset):
"""Training dataset returning (query, positive) pairs for contrastive learning."""
def __init__(
self,
root: str,
query_file: str = "Index/train_query.txt",
img_size: int = 224,
transform_query=None,
transform_db=None,
):
self.root = root
self.queries = []
with open(os.path.join(root, query_file)) as f:
for line in f:
parsed = _parse_query_line(line)
if parsed is None:
continue
query_path, label, positives = parsed
height, rot = _parse_height_rot(query_path)
self.queries.append({
"path": query_path,
"label": label,
"positives": positives,
"height": height,
"rotation": rot,
})
self.transform_query = transform_query or _default_train_query_transform(img_size)
self.transform_db = transform_db or _default_train_db_transform(img_size)
def __len__(self):
return len(self.queries)
def __getitem__(self, idx):
q = self.queries[idx]
query_img = self.transform_query(
Image.open(os.path.join(self.root, q["path"])).convert("RGB")
)
pos_img = self.transform_db(
Image.open(os.path.join(self.root, random.choice(q["positives"]))).convert("RGB")
)
return {
"query": query_img,
"positive": pos_img,
"label": q["label"],
"height": q["height"] or 0,
"rotation": q["rotation"] or 0,
}
# ── Evaluation dataset ──────────────────────────────────────────────────────
class UAVGeoLocEval(Dataset):
"""Evaluation dataset for retrieval. Returns single images with metadata.
mode="query": loads UAV query images with labels, positives, height, rotation.
mode="db": loads satellite DB images.
"""
def __init__(
self,
root: str,
index_file: str,
mode: str = "query",
img_size: int = 224,
transform=None,
):
self.root = root
self.mode = mode
self.images = []
self.labels = []
self.positives = []
self.heights = []
self.rotations = []
with open(os.path.join(root, index_file)) as f:
for line in f:
line = line.strip()
if not line:
continue
if mode == "db":
self.images.append(line)
else:
parsed = _parse_query_line(line)
if parsed is None:
continue
query_path, label, positives = parsed
self.images.append(query_path)
self.labels.append(label)
self.positives.append(positives)
h, r = _parse_height_rot(query_path)
self.heights.append(h or 0)
self.rotations.append(r or 0)
self.transform = transform or _default_eval_transform(img_size)
if mode == "db":
self.path_to_idx = {p: i for i, p in enumerate(self.images)}
def __len__(self):
return len(self.images)
def __getitem__(self, idx):
path = self.images[idx]
img = Image.open(os.path.join(self.root, path)).convert("RGB")
img = self.transform(img)
out = {"image": img, "path": path, "index": idx}
if self.mode == "query":
out["label"] = self.labels[idx]
out["positives"] = self.positives[idx]
out["height"] = self.heights[idx]
out["rotation"] = self.rotations[idx]
return out
# ── Scene-based dataset (direct from directory, no index files) ─────────────
class UAVGeoLocScene(Dataset):
"""Load data directly from a scene directory. Useful for custom splits
or scenes not covered by Index files (e.g., Rot subset).
Returns (query_img, db_positive_img, metadata_dict) pairs.
Args:
scene_dir: Path to scene (e.g., .../Country/Australia/Adelaide/AdelaideCBD)
heights: List of heights to include. Default: [100, 125, 150].
rotations: List of rotations to include. Default: all 8.
frames: List of frame indices or None for all.
"""
def __init__(
self,
scene_dir: str,
heights: Optional[list] = None,
rotations: Optional[list] = None,
frames: Optional[list] = None,
img_size: int = 224,
transform_query=None,
transform_db=None,
):
self.scene_dir = scene_dir
heights = heights or [100, 125, 150]
rotations = rotations or [0, 45, 90, 135, 180, 225, 270, 315]
# Check for incomplete scene (missing DB crops or annotations)
pos_path = os.path.join(scene_dir, "positive.json")
db_img_dir = os.path.join(scene_dir, "DB", "img")
missing = []
if not os.path.isfile(pos_path):
missing.append("positive.json")
if not os.path.isdir(db_img_dir):
missing.append("DB/img/")
if missing:
scene_name = os.path.basename(scene_dir)
raise FileNotFoundError(
f"Incomplete scene '{scene_name}': missing {', '.join(missing)}. "
f"17 known incomplete scenes (Edinburgh, London, Manchester, "
f"Birmingham/JewelleryQuarter, Chicago/__MACOSX) lack DB crops "
f"and annotations — they cannot be used for training or evaluation."
)
# Load positive.json
with open(pos_path) as f:
self.positive_map = json.load(f)
# Load semi_positive.json if available
semi_path = os.path.join(scene_dir, "semi_positive.json")
self.semi_positive_map = {}
if os.path.isfile(semi_path):
with open(semi_path) as f:
self.semi_positive_map = json.load(f)
# Load DB GPS positions
db_dir = os.path.join(scene_dir, "DB")
self.db_positions = load_db_positions(os.path.join(db_dir, "db_postion.txt"))
# Enumerate valid (variant, frame) pairs
self.samples = []
query_dir = os.path.join(scene_dir, "query")
for h in heights:
for r in rotations:
variant_name = f"height{h}_rot{r}"
footage_dir = os.path.join(query_dir, variant_name, "footage")
if not os.path.isdir(footage_dir):
continue
available_frames = sorted([
f for f in os.listdir(footage_dir)
if f.endswith((".jpeg", ".jpg"))
])
for frame_file in available_frames:
# Extract frame index (e.g. "height100_rot0_38.jpeg" -> "38")
m = re.search(r'_(\d+)\.jpe?g$', frame_file)
if m is None:
continue
frame_idx = m.group(1)
if frames is not None and int(frame_idx) not in frames:
continue
# Get positive DB crop(s)
pos_crops = self.positive_map.get(frame_idx, [])
if not pos_crops:
continue
self.samples.append({
"query_path": os.path.join(footage_dir, frame_file),
"frame_idx": frame_idx,
"height": h,
"rotation": r,
"positives": pos_crops,
"semi_positives": self.semi_positive_map.get(frame_idx, []),
})
self.db_img_dir = os.path.join(db_dir, "img")
self.transform_query = transform_query or _default_eval_transform(img_size)
self.transform_db = transform_db or _default_eval_transform(img_size)
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
s = self.samples[idx]
query_img = self.transform_query(
Image.open(s["query_path"]).convert("RGB")
)
pos_crop_name = random.choice(s["positives"])
pos_img = self.transform_db(
Image.open(os.path.join(self.db_img_dir, pos_crop_name)).convert("RGB")
)
# GPS of positive crop centroid
gps = self.db_positions.get(pos_crop_name, (0.0, 0.0, 0.0, 0.0))
return {
"query": query_img,
"positive": pos_img,
"height": s["height"],
"rotation": s["rotation"],
"frame_idx": int(s["frame_idx"]),
"positive_name": pos_crop_name,
"positive_lon": gps[0],
"positive_lat": gps[1],
"semi_positives": s["semi_positives"],
}
# ── Collate functions ───────────────────────────────────────────────────────
def eval_collate_fn(batch):
"""Collate for eval datasets (handles variable-length positives)."""
images = torch.stack([item["image"] for item in batch])
indices = torch.tensor([item["index"] for item in batch])
paths = [item["path"] for item in batch]
out = {"image": images, "index": indices, "path": paths}
if "label" in batch[0]:
out["label"] = torch.tensor([item["label"] for item in batch])
out["positives"] = [item["positives"] for item in batch]
out["height"] = torch.tensor([item["height"] for item in batch])
out["rotation"] = torch.tensor([item["rotation"] for item in batch])
return out
def scene_collate_fn(batch):
"""Collate for UAVGeoLocScene (handles variable-length semi_positives)."""
return {
"query": torch.stack([b["query"] for b in batch]),
"positive": torch.stack([b["positive"] for b in batch]),
"height": torch.tensor([b["height"] for b in batch]),
"rotation": torch.tensor([b["rotation"] for b in batch]),
"frame_idx": torch.tensor([b["frame_idx"] for b in batch]),
"positive_name": [b["positive_name"] for b in batch],
"positive_lon": torch.tensor([b["positive_lon"] for b in batch], dtype=torch.float64),
"positive_lat": torch.tensor([b["positive_lat"] for b in batch], dtype=torch.float64),
"semi_positives": [b["semi_positives"] for b in batch],
}
# ── Localization error evaluation ───────────────────────────────────────────
def compute_localization_error(
query_dataset: UAVGeoLocEval,
db_dataset: UAVGeoLocEval,
predictions: np.ndarray,
db_positions_cache: Optional[dict] = None,
) -> dict:
"""Compute localization error in meters given retrieval predictions.
Args:
query_dataset: UAVGeoLocEval in "query" mode.
db_dataset: UAVGeoLocEval in "db" mode.
predictions: (N_query,) array of predicted DB indices.
db_positions_cache: Pre-loaded {db_path: (lon, lat, ...)} dict.
If None, loads from db_postion.txt files on the fly.
Returns:
dict with 'mean_error_m', 'median_error_m', 'errors' (per-query list).
"""
if db_positions_cache is None:
db_positions_cache = {}
# Discover all unique scene DB dirs from db paths
scene_dirs = set()
for p in db_dataset.images:
# e.g. "Terrain/Mountain/Andes/DB/img/crop_0_0.png" -> "Terrain/Mountain/Andes/DB"
db_dir = str(Path(p).parent.parent)
scene_dirs.add(db_dir)
for sd in scene_dirs:
pos_file = os.path.join(db_dataset.root, sd, "db_postion.txt")
positions = load_db_positions(pos_file)
for crop_name, coords in positions.items():
full_path = os.path.join(sd, "img", crop_name)
db_positions_cache[full_path] = coords
errors = []
for i, pred_idx in enumerate(predictions):
# Get GT positive crops for this query
gt_positives = query_dataset.positives[i]
# Get predicted DB path
pred_path = db_dataset.images[int(pred_idx)]
# GPS of prediction
pred_gps = db_positions_cache.get(pred_path)
if pred_gps is None:
continue
# GPS of nearest GT positive
min_dist = float("inf")
for gt_path in gt_positives:
gt_gps = db_positions_cache.get(gt_path)
if gt_gps is None:
continue
dist = haversine_m(pred_gps[0], pred_gps[1], gt_gps[0], gt_gps[1])
min_dist = min(min_dist, dist)
if min_dist < float("inf"):
errors.append(min_dist)
errors_arr = np.array(errors)
return {
"mean_error_m": float(np.mean(errors_arr)) if len(errors_arr) else 0.0,
"median_error_m": float(np.median(errors_arr)) if len(errors_arr) else 0.0,
"errors": errors,
"num_evaluated": len(errors),
}
# ── Convenience builder ─────────────────────────────────────────────────────
def build_dataloaders(
root: str,
split: str = "terrain",
batch_size: int = 32,
img_size: int = 224,
num_workers: int = 4,
mode: str = "triplet",
):
"""Build train/val/test dataloaders.
Args:
root: Path to UAV-GeoLoc dataset root.
split: "terrain" (default), "country", or "all".
batch_size: Batch size for all loaders.
img_size: Resize images to (img_size, img_size).
num_workers: DataLoader workers.
mode: "triplet" for (query, pos, neg) or "pair" for (query, pos).
Returns:
dict with keys: "train", "val_query", "val_db", "test_query", "test_db"
"""
suffix = {"terrain": "", "country": "_country", "all": "_all"}[split]
if mode == "triplet":
train_ds = UAVGeoLocTrain(
root,
query_file=f"Index/train_query{suffix}.txt",
db_file=f"Index/train_db{suffix}.txt",
img_size=img_size,
)
else:
train_ds = UAVGeoLocPair(
root,
query_file=f"Index/train_query{suffix}.txt",
img_size=img_size,
)
train_loader = DataLoader(
train_ds,
batch_size=batch_size,
shuffle=True,
num_workers=num_workers,
pin_memory=True,
drop_last=True,
)
loaders = {"train": train_loader}
for phase in ["val", "test"]:
q_file = f"Index/{phase}_query{suffix}.txt"
d_file = f"Index/{phase}_db{suffix}.txt"
# Fall back to unsuffixed if suffixed file doesn't exist
if not os.path.isfile(os.path.join(root, q_file)):
q_file = f"Index/{phase}_query.txt"
if not os.path.isfile(os.path.join(root, d_file)):
d_file = f"Index/{phase}_db.txt"
q_ds = UAVGeoLocEval(root, q_file, mode="query", img_size=img_size)
d_ds = UAVGeoLocEval(root, d_file, mode="db", img_size=img_size)
loaders[f"{phase}_query"] = DataLoader(
q_ds, batch_size=batch_size, shuffle=False,
num_workers=num_workers, pin_memory=True, collate_fn=eval_collate_fn,
)
loaders[f"{phase}_db"] = DataLoader(
d_ds, batch_size=batch_size, shuffle=False,
num_workers=num_workers, pin_memory=True, collate_fn=eval_collate_fn,
)
return loaders
def build_rot_loader(
root: str,
batch_size: int = 32,
img_size: int = 224,
num_workers: int = 4,
heights: Optional[list] = None,
rotations: Optional[list] = None,
):
"""Build a DataLoader for the Rot evaluation subset.
The Rot subset has 88 query variants (72 at h100 every 5deg + 16 at h125/h150)
over a single scene (SouthernSuburbs), useful for rotation robustness evaluation.
Returns:
DataLoader yielding scene_collate_fn batches.
"""
scene_dir = os.path.join(root, "Rot", "SouthernSuburbs")
# Rot has fine-grained rotations at height100
if rotations is None and heights is None:
# Include all: h100 has 72 rotations (0-355 step 5), h125/h150 have 8 each
heights_rot = [(100, r) for r in range(0, 360, 5)]
heights_rot += [(h, r) for h in [125, 150] for r in range(0, 360, 45)]
all_heights = list(set(h for h, _ in heights_rot))
all_rotations = list(set(r for _, r in heights_rot))
else:
all_heights = heights or [100, 125, 150]
all_rotations = rotations or list(range(0, 360, 5))
ds = UAVGeoLocScene(
scene_dir,
heights=all_heights,
rotations=all_rotations,
img_size=img_size,
)
return DataLoader(
ds, batch_size=batch_size, shuffle=False,
num_workers=num_workers, pin_memory=True, collate_fn=scene_collate_fn,
)
# ── Quick test ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
ROOT = "/mnt/data1tb/cvgl_datasets/UAV-GeoLoc"
print("=" * 60)
print("Building dataloaders (terrain split, img_size=224)...")
loaders = build_dataloaders(ROOT, split="terrain", batch_size=4, num_workers=0)
for name, loader in loaders.items():
print(f" {name}: {len(loader.dataset)} samples")
batch = next(iter(loaders["train"]))
print(f"\nTrain batch:")
print(f" query: {batch['query'].shape}")
print(f" positive: {batch['positive'].shape}")
print(f" negative: {batch['negative'].shape}")
print(f" labels: {batch['label']}")
print(f" heights: {batch['height']}")
print(f" rotations:{batch['rotation']}")
batch = next(iter(loaders["val_query"]))
print(f"\nVal query batch:")
print(f" image: {batch['image'].shape}")
print(f" heights: {batch['height']}")
print(f" rotations:{batch['rotation']}")
print("\n" + "=" * 60)
print("Scene-based loader (AdelaideCBD, h100 only)...")
scene_ds = UAVGeoLocScene(
os.path.join(ROOT, "Country/Australia/Adelaide/AdelaideCBD"),
heights=[100],
rotations=[0, 90, 180, 270],
)
print(f" Samples: {len(scene_ds)}")
s = scene_ds[0]
print(f" query: {s['query'].shape}, height={s['height']}, rot={s['rotation']}")
print(f" positive: {s['positive_name']}, GPS=({s['positive_lon']:.4f}, {s['positive_lat']:.4f})")
print("\n" + "=" * 60)
print("Rot evaluation loader...")
rot_loader = build_rot_loader(ROOT, batch_size=4, num_workers=0, heights=[100], rotations=[0, 45, 90])
print(f" Samples: {len(rot_loader.dataset)}")
batch = next(iter(rot_loader))
print(f" query: {batch['query'].shape}")
print(f" rotations: {batch['rotation']}")
print("\n" + "=" * 60)
print("Tiling utility test...")
dummy = np.random.randint(0, 255, (500, 600, 3), dtype=np.uint8)
crops = tile_satellite_image(dummy, crop_size=200, stride=100)
print(f" Input: 600x500, crop=200, stride=100")
print(f" Crops generated: {len(crops)}")
print(f" Grid: {max(c[1] for c in crops)+1} x {max(c[2] for c in crops)+1}")
print("\nAll tests passed.")