- Satellite crop generation (512x512, stride 256, resize 256x256) - Route 09 tile stitching (4 tiles -> 44800x33280) - GPS matching drone->crop via vectorized haversine - Index files in UAV-GeoLoc format (train/test query + DB) - positive.json / semi_positive.json / db_postion.txt per route - Route 07 excluded (satellite too narrow) - Fixed: full gallery in DB files, db_postion.txt format, frame_id keys - Fixed: file handle leaks in image processing loops Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
560 lines
18 KiB
Python
560 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Prepare UAV-VisLoc dataset for retrieval training.
|
|
|
|
Pipeline:
|
|
1. Resize drone images -> 256x256
|
|
2. Stitch satellite tiles for route 09
|
|
3. Crop satellite maps -> 512x512 patches with stride 256, resize -> 256x256
|
|
4. Compute GPS for each crop center
|
|
5. Match drone -> crops via GPS (positive, semi-positive)
|
|
6. Generate metadata: positive.json, semi_positive.json, db_postion.txt
|
|
7. Generate Index files (train_query.txt, train_db.txt, etc.)
|
|
|
|
Usage:
|
|
python scripts/prepare_dataset.py \
|
|
--src /path/to/UAV_VisLoc_dataset \
|
|
--dst /path/to/UAV_VisLoc_processed \
|
|
--crop-size 512 \
|
|
--stride 256 \
|
|
--target-size 256
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import math
|
|
import os
|
|
import warnings
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
warnings.filterwarnings("ignore", category=Image.DecompressionBombWarning)
|
|
Image.MAX_IMAGE_PIXELS = None
|
|
|
|
# Route 07 excluded: satellite map 3000x170 — too narrow for 512x512 crops
|
|
EXCLUDED_ROUTES = {"07"}
|
|
|
|
# Route 09 has satellite split into 4 tiles
|
|
SPLIT_TILE_ROUTE = "09"
|
|
TILE_LAYOUT_09 = {
|
|
# (col, row): filename
|
|
(0, 0): "satellite09_01-01.tif",
|
|
(1, 0): "satellite09_01-02.tif",
|
|
(0, 1): "satellite09_02-01.tif",
|
|
(1, 1): "satellite09_02-02.tif",
|
|
}
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(description="Prepare UAV-VisLoc for retrieval.")
|
|
parser.add_argument("--src", required=True, help="Path to raw UAV_VisLoc_dataset")
|
|
parser.add_argument("--dst", required=True, help="Path to output processed dataset")
|
|
parser.add_argument("--crop-size", type=int, default=512, help="Satellite crop size in px")
|
|
parser.add_argument("--stride", type=int, default=256, help="Crop stride in px")
|
|
parser.add_argument("--target-size", type=int, default=256, help="Final resize for both drone and crops")
|
|
parser.add_argument("--routes", nargs="*", default=None, help="Process only these routes (e.g. 01 02)")
|
|
return parser.parse_args()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Read metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def read_satellite_bbox(src: Path) -> dict:
|
|
"""Read satellite GPS bounding boxes from CSV."""
|
|
bbox = {}
|
|
csv_path = src / "satellite_ coordinates_range.csv"
|
|
with open(csv_path) as f:
|
|
reader = csv.DictReader(f)
|
|
for row in reader:
|
|
name = row["mapname"]
|
|
route = name.replace("satellite", "").replace(".tif", "")
|
|
bbox[route] = {
|
|
"lt_lat": float(row["LT_lat_map"]),
|
|
"lt_lon": float(row["LT_lon_map"]),
|
|
"rb_lat": float(row["RB_lat_map"]),
|
|
"rb_lon": float(row["RB_lon_map"]),
|
|
}
|
|
return bbox
|
|
|
|
|
|
def read_drone_metadata(src: Path, route: str) -> list[dict]:
|
|
"""Read drone GPS + pose from per-route CSV."""
|
|
csv_path = src / route / f"{route}.csv"
|
|
entries = []
|
|
with open(csv_path) as f:
|
|
reader = csv.DictReader(f)
|
|
for row in reader:
|
|
entries.append({
|
|
"filename": row["filename"],
|
|
"lat": float(row["lat"]),
|
|
"lon": float(row["lon"]),
|
|
"height": float(row["height"]),
|
|
})
|
|
return entries
|
|
|
|
|
|
def read_split(src: Path, split: str) -> set[str]:
|
|
"""Read train/test split CSV, return set of drone filenames."""
|
|
csv_path = src / f"visloc_{split}.csv"
|
|
filenames = set()
|
|
with open(csv_path) as f:
|
|
reader = csv.DictReader(f, delimiter="\t")
|
|
for row in reader:
|
|
fn = row["filename"]
|
|
basename = os.path.basename(fn)
|
|
filenames.add(basename)
|
|
return filenames
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Resize drone images
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def resize_drone_images(src: Path, dst: Path, route: str, target_size: int) -> int:
|
|
"""Resize all drone images for a route to target_size x target_size."""
|
|
drone_src = src / route / "drone"
|
|
drone_dst = dst / route / "drone"
|
|
drone_dst.mkdir(parents=True, exist_ok=True)
|
|
|
|
count = 0
|
|
for img_name in sorted(os.listdir(drone_src)):
|
|
if not img_name.upper().endswith(".JPG"):
|
|
continue
|
|
src_path = drone_src / img_name
|
|
dst_path = drone_dst / img_name
|
|
|
|
if dst_path.exists():
|
|
count += 1
|
|
continue
|
|
|
|
with Image.open(src_path) as img:
|
|
img_resized = img.resize((target_size, target_size), Image.LANCZOS)
|
|
img_resized.save(dst_path, "JPEG", quality=95)
|
|
count += 1
|
|
|
|
return count
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Stitch satellite tiles (route 09)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def stitch_route09(src: Path) -> Image.Image:
|
|
"""Stitch 4 satellite tiles for route 09 into a single image."""
|
|
tiles = {}
|
|
for (col, row), fname in TILE_LAYOUT_09.items():
|
|
tile_path = src / SPLIT_TILE_ROUTE / fname
|
|
tiles[(col, row)] = Image.open(tile_path)
|
|
|
|
# Compute full image dimensions
|
|
# Row 0: tiles (0,0) and (1,0) side by side
|
|
# Row 1: tiles (0,1) and (1,1) side by side
|
|
w0 = tiles[(0, 0)].width + tiles[(1, 0)].width
|
|
w1 = tiles[(0, 1)].width + tiles[(1, 1)].width
|
|
full_w = max(w0, w1)
|
|
|
|
h0 = max(tiles[(0, 0)].height, tiles[(1, 0)].height)
|
|
h1 = max(tiles[(0, 1)].height, tiles[(1, 1)].height)
|
|
full_h = h0 + h1
|
|
|
|
merged = Image.new("RGB", (full_w, full_h))
|
|
merged.paste(tiles[(0, 0)], (0, 0))
|
|
merged.paste(tiles[(1, 0)], (tiles[(0, 0)].width, 0))
|
|
merged.paste(tiles[(0, 1)], (0, h0))
|
|
merged.paste(tiles[(1, 1)], (tiles[(0, 1)].width, h0))
|
|
|
|
# Close tiles to free memory (~4.3 GB).
|
|
for tile in tiles.values():
|
|
tile.close()
|
|
|
|
return merged
|
|
|
|
|
|
def load_satellite(src: Path, route: str) -> Image.Image:
|
|
"""Load satellite map for a route (handles route 09 stitching)."""
|
|
if route == SPLIT_TILE_ROUTE:
|
|
return stitch_route09(src)
|
|
sat_path = src / route / f"satellite{route}.tif"
|
|
return Image.open(sat_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Crop satellite map
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def crop_satellite(
|
|
sat_img: Image.Image,
|
|
dst: Path,
|
|
route: str,
|
|
crop_size: int,
|
|
stride: int,
|
|
target_size: int,
|
|
) -> list[dict]:
|
|
"""Crop satellite image into patches and save resized versions.
|
|
|
|
Returns list of crop metadata: [{"name": "crop_X_Y.png", "x": X, "y": Y, "px_x": ..., "px_y": ...}]
|
|
"""
|
|
crop_dir = dst / route / "DB" / "img"
|
|
crop_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
w, h = sat_img.size
|
|
cols = max(0, (w - crop_size) // stride + 1)
|
|
rows = max(0, (h - crop_size) // stride + 1)
|
|
|
|
crops_meta = []
|
|
|
|
for cx in range(cols):
|
|
for cy in range(rows):
|
|
px_x = cx * stride
|
|
px_y = cy * stride
|
|
|
|
crop_name = f"crop_{cx}_{cy}.png"
|
|
crop_path = crop_dir / crop_name
|
|
|
|
if not crop_path.exists():
|
|
box = (px_x, px_y, px_x + crop_size, px_y + crop_size)
|
|
patch = sat_img.crop(box)
|
|
patch_resized = patch.resize((target_size, target_size), Image.LANCZOS)
|
|
patch_resized.save(crop_path, "PNG")
|
|
patch.close()
|
|
patch_resized.close()
|
|
|
|
crops_meta.append({
|
|
"name": crop_name,
|
|
"x": cx,
|
|
"y": cy,
|
|
"px_x": px_x,
|
|
"px_y": px_y,
|
|
})
|
|
|
|
return crops_meta
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Compute GPS for crops + match drone -> crops
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def compute_crop_gps(crops_meta: list[dict], bbox: dict, sat_w: int, sat_h: int, crop_size: int) -> list[dict]:
|
|
"""Add GPS center coordinates to each crop metadata entry."""
|
|
lt_lat = bbox["lt_lat"]
|
|
lt_lon = bbox["lt_lon"]
|
|
rb_lat = bbox["rb_lat"]
|
|
rb_lon = bbox["rb_lon"]
|
|
|
|
for crop in crops_meta:
|
|
center_px_x = crop["px_x"] + crop_size / 2
|
|
center_px_y = crop["px_y"] + crop_size / 2
|
|
|
|
crop["lon"] = lt_lon + (center_px_x / sat_w) * (rb_lon - lt_lon)
|
|
crop["lat"] = lt_lat + (center_px_y / sat_h) * (rb_lat - lt_lat)
|
|
|
|
return crops_meta
|
|
|
|
|
|
def _frame_id(filename: str) -> str:
|
|
"""Extract frame ID from drone filename: '01_0001.JPG' -> '0001'."""
|
|
stem = os.path.splitext(filename)[0] # '01_0001'
|
|
return stem.split("_", 1)[1] # '0001'
|
|
|
|
|
|
def match_drone_to_crops(
|
|
drone_entries: list[dict],
|
|
crops_meta: list[dict],
|
|
) -> tuple[dict, dict]:
|
|
"""Match each drone image to its positive and semi-positive crops.
|
|
|
|
positive: crop with minimum GPS distance to drone center.
|
|
semi-positive: all crops within ±1 grid step from the positive crop.
|
|
|
|
Returns:
|
|
positive_map: {frame_id: [crop_name]}
|
|
semi_positive_map: {frame_id: [crop_name, ...]}
|
|
"""
|
|
# Build arrays for vectorized distance computation
|
|
crop_lats = np.array([c["lat"] for c in crops_meta])
|
|
crop_lons = np.array([c["lon"] for c in crops_meta])
|
|
|
|
# Grid lookup: (x, y) -> crop_name for O(1) neighbor search
|
|
grid = {}
|
|
for crop in crops_meta:
|
|
grid[(crop["x"], crop["y"])] = crop["name"]
|
|
|
|
positive_map = {}
|
|
semi_positive_map = {}
|
|
|
|
for drone in drone_entries:
|
|
d_lat, d_lon = drone["lat"], drone["lon"]
|
|
fid = _frame_id(drone["filename"])
|
|
|
|
# Compute distances to all crops (vectorized haversine)
|
|
dlat = np.radians(crop_lats - d_lat)
|
|
dlon = np.radians(crop_lons - d_lon)
|
|
a = np.sin(dlat / 2) ** 2 + np.cos(math.radians(d_lat)) * np.cos(np.radians(crop_lats)) * np.sin(dlon / 2) ** 2
|
|
dists = 6_371_000 * 2 * np.arcsin(np.sqrt(a))
|
|
|
|
# Positive: closest crop
|
|
best_idx = int(np.argmin(dists))
|
|
best_crop = crops_meta[best_idx]
|
|
positive_map[fid] = [best_crop["name"]]
|
|
|
|
# Semi-positives: ±1 neighbors in grid via dict lookup
|
|
bx, by = best_crop["x"], best_crop["y"]
|
|
semi = []
|
|
for dx in range(-1, 2):
|
|
for dy in range(-1, 2):
|
|
if dx == 0 and dy == 0:
|
|
continue
|
|
neighbor = grid.get((bx + dx, by + dy))
|
|
if neighbor is not None:
|
|
semi.append(neighbor)
|
|
|
|
semi_positive_map[fid] = semi
|
|
|
|
return positive_map, semi_positive_map
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Write metadata files
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def write_positive_json(dst: Path, route: str, positive_map: dict):
|
|
out_path = dst / route / "positive.json"
|
|
with open(out_path, "w") as f:
|
|
json.dump(positive_map, f, indent=2)
|
|
|
|
|
|
def write_semi_positive_json(dst: Path, route: str, semi_positive_map: dict):
|
|
out_path = dst / route / "semi_positive.json"
|
|
with open(out_path, "w") as f:
|
|
json.dump(semi_positive_map, f, indent=2)
|
|
|
|
|
|
def write_db_position(dst: Path, route: str, crops_meta: list[dict],
|
|
sat_w: int, sat_h: int, bbox: dict):
|
|
"""Write db_postion.txt with GPS coordinates and scale for each crop.
|
|
|
|
Format matches UAV-GeoLoc: name\tlon\tlat\tscale_lon\tscale_lat (tab-separated).
|
|
scale_lon/scale_lat = degrees per pixel in the satellite map.
|
|
Note: filename uses original UAV-GeoLoc spelling 'postion' (sic).
|
|
"""
|
|
scale_lon = (bbox["rb_lon"] - bbox["lt_lon"]) / sat_w
|
|
scale_lat = (bbox["rb_lat"] - bbox["lt_lat"]) / sat_h
|
|
|
|
out_path = dst / route / "DB" / "db_postion.txt"
|
|
with open(out_path, "w") as f:
|
|
for crop in crops_meta:
|
|
f.write(f"{crop['name']}\t{crop['lon']:.8f}\t{crop['lat']:.8f}"
|
|
f"\t{scale_lon:.2e}\t{scale_lat:.2e}\n")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Generate Index files
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def generate_index_files(
|
|
dst: Path,
|
|
all_routes: list[str],
|
|
route_data: dict,
|
|
train_files: set[str],
|
|
test_files: set[str],
|
|
):
|
|
"""Generate train/test query and DB index files in UAV-GeoLoc format.
|
|
|
|
train_query.txt format:
|
|
route/drone/filename label positive_crop1 positive_crop2 ...
|
|
train_db.txt format:
|
|
route/DB/img/crop_name
|
|
"""
|
|
index_dir = dst / "Index"
|
|
index_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
train_query_lines = []
|
|
test_query_lines = []
|
|
all_db_set = set()
|
|
|
|
for route in all_routes:
|
|
data = route_data[route]
|
|
positive_map = data["positive_map"]
|
|
semi_positive_map = data["semi_positive_map"]
|
|
crops_meta = data["crops_meta"]
|
|
|
|
# All DB crops for this route go into the gallery
|
|
for crop in crops_meta:
|
|
db_path = f"{route}/DB/img/{crop['name']}"
|
|
all_db_set.add(db_path)
|
|
|
|
for drone_entry in data["drone_entries"]:
|
|
fname = drone_entry["filename"]
|
|
fid = _frame_id(fname)
|
|
drone_path = f"{route}/drone/{fname}"
|
|
|
|
positives = positive_map.get(fid, [])
|
|
semi_pos = semi_positive_map.get(fid, [])
|
|
all_pos = positives + semi_pos
|
|
|
|
pos_paths = [f"{route}/DB/img/{p}" for p in all_pos]
|
|
line = f"{drone_path} 0 {' '.join(pos_paths)}"
|
|
|
|
if fname in train_files:
|
|
train_query_lines.append(line)
|
|
elif fname in test_files:
|
|
test_query_lines.append(line)
|
|
|
|
# DB gallery is the same for train and test (split is by query, not by route)
|
|
sorted_db = sorted(all_db_set)
|
|
|
|
# Write files
|
|
_write_lines(index_dir / "train_query.txt", train_query_lines)
|
|
_write_lines(index_dir / "test_query.txt", test_query_lines)
|
|
_write_lines(index_dir / "train_db.txt", sorted_db)
|
|
_write_lines(index_dir / "test_db.txt", sorted_db)
|
|
_write_lines(index_dir / "all_db.txt", sorted_db)
|
|
|
|
# Route lists
|
|
_write_lines(index_dir / "train.txt", all_routes)
|
|
_write_lines(index_dir / "test.txt", all_routes)
|
|
|
|
return {
|
|
"train_queries": len(train_query_lines),
|
|
"test_queries": len(test_query_lines),
|
|
"total_db": len(all_db_set),
|
|
}
|
|
|
|
|
|
def _write_lines(path: Path, lines: list[str]):
|
|
with open(path, "w") as f:
|
|
for line in lines:
|
|
f.write(line + "\n")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def process_route(
|
|
src: Path,
|
|
dst: Path,
|
|
route: str,
|
|
bbox: dict,
|
|
crop_size: int,
|
|
stride: int,
|
|
target_size: int,
|
|
) -> dict:
|
|
"""Process a single route: resize drone, crop satellite, match pairs."""
|
|
print(f"\n{'='*60}")
|
|
print(f"Route {route}")
|
|
print(f"{'='*60}")
|
|
|
|
# 1. Read drone metadata
|
|
drone_entries = read_drone_metadata(src, route)
|
|
print(f" Drone images: {len(drone_entries)}")
|
|
|
|
# 2. Resize drone images
|
|
n_drone = resize_drone_images(src, dst, route, target_size)
|
|
print(f" Drone resized: {n_drone}")
|
|
|
|
# 3. Load and crop satellite
|
|
print(f" Loading satellite map...")
|
|
sat_img = load_satellite(src, route)
|
|
sat_w, sat_h = sat_img.size
|
|
print(f" Satellite size: {sat_w}x{sat_h}")
|
|
|
|
crops_meta = crop_satellite(sat_img, dst, route, crop_size, stride, target_size)
|
|
print(f" Satellite crops: {len(crops_meta)}")
|
|
|
|
# Free memory
|
|
del sat_img
|
|
|
|
# 4. Compute GPS for crops
|
|
route_bbox = bbox[route]
|
|
crops_meta = compute_crop_gps(crops_meta, route_bbox, sat_w, sat_h, crop_size)
|
|
|
|
# 5. Match drone -> crops
|
|
positive_map, semi_positive_map = match_drone_to_crops(drone_entries, crops_meta)
|
|
print(f" Positives matched: {len(positive_map)}")
|
|
|
|
# Stats
|
|
avg_semi = sum(len(v) for v in semi_positive_map.values()) / max(1, len(semi_positive_map))
|
|
print(f" Avg semi-positives per drone: {avg_semi:.1f}")
|
|
|
|
# 6. Write metadata
|
|
write_positive_json(dst, route, positive_map)
|
|
write_semi_positive_json(dst, route, semi_positive_map)
|
|
write_db_position(dst, route, crops_meta, sat_w, sat_h, route_bbox)
|
|
print(f" Metadata written.")
|
|
|
|
return {
|
|
"drone_entries": drone_entries,
|
|
"crops_meta": crops_meta,
|
|
"positive_map": positive_map,
|
|
"semi_positive_map": semi_positive_map,
|
|
}
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
src = Path(args.src)
|
|
dst = Path(args.dst)
|
|
|
|
print(f"Source: {src}")
|
|
print(f"Destination: {dst}")
|
|
print(f"Crop: {args.crop_size}x{args.crop_size}, stride: {args.stride}")
|
|
print(f"Target size: {args.target_size}x{args.target_size}")
|
|
print(f"Excluded routes: {EXCLUDED_ROUTES}")
|
|
|
|
dst.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Read satellite bounding boxes
|
|
bbox = read_satellite_bbox(src)
|
|
|
|
# Determine routes to process
|
|
all_routes = sorted([
|
|
d for d in os.listdir(src)
|
|
if os.path.isdir(src / d) and d.isdigit() and d not in EXCLUDED_ROUTES
|
|
])
|
|
if args.routes:
|
|
all_routes = [r for r in all_routes if r in args.routes]
|
|
|
|
print(f"Routes to process: {all_routes}")
|
|
|
|
# Read train/test splits
|
|
train_files = read_split(src, "train")
|
|
test_files = read_split(src, "test")
|
|
print(f"Train split: {len(train_files)} files")
|
|
print(f"Test split: {len(test_files)} files")
|
|
|
|
# Process each route
|
|
route_data = {}
|
|
total_drone = 0
|
|
total_crops = 0
|
|
|
|
for route in all_routes:
|
|
data = process_route(src, dst, route, bbox, args.crop_size, args.stride, args.target_size)
|
|
route_data[route] = data
|
|
total_drone += len(data["drone_entries"])
|
|
total_crops += len(data["crops_meta"])
|
|
|
|
# Generate Index files
|
|
print(f"\n{'='*60}")
|
|
print("Generating Index files...")
|
|
idx_stats = generate_index_files(dst, all_routes, route_data, train_files, test_files)
|
|
print(f" Train queries: {idx_stats['train_queries']}")
|
|
print(f" Test queries: {idx_stats['test_queries']}")
|
|
print(f" DB gallery (all crops): {idx_stats['total_db']}")
|
|
|
|
# Summary
|
|
print(f"\n{'='*60}")
|
|
print("SUMMARY")
|
|
print(f"{'='*60}")
|
|
print(f" Routes processed: {len(all_routes)}")
|
|
print(f" Total drone images (resized to {args.target_size}): {total_drone}")
|
|
print(f" Total satellite crops ({args.crop_size}→{args.target_size}): {total_crops}")
|
|
print(f" Output: {dst}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|