SatFetch / prepare_gallery.py
karansharmaworkspace's picture
Upload 68 files
f343f06 verified
Raw
History Blame Contribute Delete
15.2 kB
"""
Prepare gallery: download dataset, extract embeddings, build FAISS index.
Downloads real multi-modal satellite data (optical RGB, SAR 2ch, MS 13ch)
and builds a cross-modal retrieval gallery.
Usage:
python prepare_gallery.py --samples 50
"""
import argparse
import json
import shutil
import time
from pathlib import Path
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image
from tqdm import tqdm
from torchvision import transforms
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
DATA_DIR = Path("data")
RAW_DIR = DATA_DIR / "raw"
PROCESSED_DIR = DATA_DIR / "processed"
GALLERY_DIR = DATA_DIR / "gallery"
CLASSES = [
"AnnualCrop", "Forest", "HerbaceousVegetation", "Highway",
"Industrial", "Pasture", "PermanentCrop", "Residential",
"River", "SeaLake",
]
BATCH_SIZE = 64
EMBED_DIM = 768 # CLIP ViT-L/14 output dim
def _samples_exist(dir: Path, n_per_class: int) -> bool:
"""Check if directory has n_per_class images per class."""
if not dir.exists():
return False
return all(len(list((dir / c).glob("*.*"))) >= n_per_class for c in CLASSES)
def download_optical(n_per_class: int = 50) -> Path:
"""Download optical RGB from HuggingFace."""
from datasets import load_dataset
out_dir = RAW_DIR / "eurosat"
if _samples_exist(out_dir, n_per_class):
print(f"Optical already at {out_dir}, skipping.")
return out_dir
out_dir.mkdir(parents=True, exist_ok=True)
print("Downloading optical RGB from blanchon/EuroSAT_RGB ...")
ds = load_dataset("blanchon/EuroSAT_RGB", split="train")
selected, counts = [], {c: 0 for c in range(10)}
for row in ds:
lbl = row["label"]
if counts[lbl] < n_per_class:
selected.append(row)
counts[lbl] += 1
if all(v >= n_per_class for v in counts.values()):
break
for i, row in enumerate(tqdm(selected, desc="Saving optical")):
cls_name = CLASSES[row["label"]]
cls_dir = out_dir / cls_name
cls_dir.mkdir(exist_ok=True)
row["image"].save(cls_dir / f"{cls_name}_{i}.tif")
print(f"Optical saved to {out_dir}")
return out_dir
def download_sar(n_per_class: int = 50) -> Path:
"""Download real SAR (2ch VV/VH) from HuggingFace dataset or zip."""
out_dir = RAW_DIR / "eurosat_sar"
if _samples_exist(out_dir, n_per_class):
print(f"SAR already at {out_dir}, skipping.")
return out_dir
out_dir.mkdir(parents=True, exist_ok=True)
print("Downloading SAR from wangyi111/EuroSAT-SAR ...")
try:
from datasets import load_dataset
ds = load_dataset("wangyi111/EuroSAT-SAR", split="train", streaming=True)
selected, counts = [], {c: 0 for c in range(10)}
for row in ds:
# SAR labels are class names directly
lbl_name = row.get("label", row.get("label_name", ""))
if isinstance(lbl_name, int) and lbl_name < 10:
cls_name = CLASSES[lbl_name]
elif isinstance(lbl_name, str) and lbl_name in CLASSES:
cls_name = lbl_name
else:
continue
idx = CLASSES.index(cls_name)
if counts[idx] < n_per_class:
# Convert to 2-channel grayscale (VV, VH from RGBA)
img = np.array(row["image"].convert("L"))
selected.append((img, cls_name))
counts[idx] += 1
if all(v >= n_per_class for v in counts.values()):
break
for i, (img_arr, cls_name) in enumerate(tqdm(selected, desc="Saving SAR")):
cls_dir = out_dir / cls_name
cls_dir.mkdir(exist_ok=True)
# Save as 2-channel TIFF (stack Luminance as VV/VH)
two_ch = np.stack([img_arr, img_arr], axis=-1).astype(np.uint8)
Image.fromarray(two_ch[:, :, 0], mode="L").save(
cls_dir / f"{cls_name}_{i}.tif")
print(f"SAR saved to {out_dir}")
except Exception as e:
print(f"SAR download failed ({e}), using local fallback.")
# Fallback: convert optical to 2-channel SAR-like
optical_dir = RAW_DIR / "eurosat"
if optical_dir.exists():
for cls_name in CLASSES:
cls_in = optical_dir / cls_name
cls_out = out_dir / cls_name
cls_out.mkdir(parents=True, exist_ok=True)
for path in list(cls_in.glob("*.*"))[:n_per_class]:
arr = np.array(Image.open(path).convert("L"))
noise = np.random.rayleigh(1.0, arr.shape).astype(np.float32)
sar = np.clip(arr * noise, 0, 255).astype(np.uint8)
Image.fromarray(sar, mode="L").save(
cls_out / f"{cls_name}_{path.stem}.tif")
print(f"SAR fallback saved to {out_dir}")
return out_dir
def download_multispectral(n_per_class: int = 50) -> Path:
"""Download real multispectral (13ch) from HuggingFace."""
out_dir = RAW_DIR / "eurosat_ms"
if _samples_exist(out_dir, n_per_class):
print(f"Multispectral already at {out_dir}, skipping.")
return out_dir
out_dir.mkdir(parents=True, exist_ok=True)
print("Downloading MS from giswqs/EuroSAT_MS ...")
try:
from datasets import load_dataset
ds = load_dataset("giswqs/EuroSAT_MS", split="train", streaming=True)
selected, counts = [], {c: 0 for c in range(10)}
for row in ds:
lbl = row["label"]
if counts[lbl] < n_per_class:
# Image is a list of 13 arrays (one per band)
bands = [np.array(b) for b in row["image"]]
selected.append((bands, lbl))
counts[lbl] += 1
if all(v >= n_per_class for v in counts.values()):
break
for bands, lbl in tqdm(selected, desc="Saving MS"):
cls_name = CLASSES[lbl]
cls_dir = out_dir / cls_name
cls_dir.mkdir(exist_ok=True)
# Stack bands into single array, save as multi-channel TIFF
arr = np.stack(bands, axis=0) # (13, 64, 64)
import tifffile
tifffile.imwrite(
cls_dir / f"{cls_name}_{len(list(cls_dir.glob('*.*')))}.tif",
arr.astype(np.uint16))
print(f"MS saved to {out_dir}")
except Exception as e:
print(f"MS download failed ({e}), using local fallback.")
optical_dir = RAW_DIR / "eurosat"
if optical_dir.exists():
for cls_name in CLASSES:
cls_in = optical_dir / cls_name
cls_out = out_dir / cls_name
cls_out.mkdir(parents=True, exist_ok=True)
for path in list(cls_in.glob("*.*"))[:n_per_class]:
shutil.copy2(path, cls_out / path.name)
print(f"MS fallback saved to {out_dir}")
return out_dir
def load_satclip():
"""Load SatCLIP encoder."""
from src.features.satclip_encoder import SatCLIPEncoder
print("Loading SatCLIP encoder...")
encoder = SatCLIPEncoder()
print(f"SatCLIP loaded on {encoder.device}")
return encoder
def _load_multichannel_image(path: Path, modality: str) -> torch.Tensor:
"""
Load an image handling different channel counts.
Returns tensor of shape (C, H, W) normalized to [0, 1].
"""
# Try tifffile first for multi-channel TIFFs
try:
import tifffile
arr = tifffile.imread(str(path))
if arr.ndim == 3 and arr.shape[-1] in [2, 3, 4, 13]:
# Channels-last format
arr = np.transpose(arr, (2, 0, 1))
elif arr.ndim == 2:
arr = arr[np.newaxis, :, :]
# Normalize uint to [0, 1]
if arr.dtype in [np.uint8, np.uint16]:
arr = arr.astype(np.float32) / np.float32(np.iinfo(arr.dtype).max)
else:
arr = arr.astype(np.float32)
arr = (arr - arr.min()) / (arr.max() - arr.min() + 1e-8)
tensor = torch.from_numpy(arr).float()
except Exception:
# Fallback to PIL (handles RGB)
img = Image.open(path).convert("RGB")
tensor = transforms.ToTensor()(img)
# Resize to 224x224
if tensor.shape[1] != 224 or tensor.shape[2] != 224:
tensor = F.interpolate(tensor.unsqueeze(0), size=(224, 224),
mode="bilinear", align_corners=False).squeeze(0)
# Enforce strict channel counts per modality to prevent torch.stack failures
c = tensor.shape[0]
if modality == "optical":
if c == 1:
tensor = tensor.repeat(3, 1, 1)
elif c > 3:
tensor = tensor[:3]
elif c == 2:
tensor = torch.cat([tensor, tensor[:1]], dim=0)
elif modality == "sar":
if c == 1:
tensor = tensor.repeat(2, 1, 1)
elif c > 2:
tensor = tensor[:2]
elif modality == "multispectral":
if c < 13:
pad = torch.zeros(13 - c, tensor.shape[1], tensor.shape[2])
tensor = torch.cat([tensor, pad], dim=0)
elif c > 13:
tensor = tensor[:13]
return tensor
def _pad_to_13ch(tensor: torch.Tensor, modality: str) -> torch.Tensor:
"""Pad tensor to 13 channels for SatCLIP."""
n_channels = tensor.shape[1]
if n_channels >= 13:
return tensor[:, :13]
# Repeat channels if single-channel (SAR fallback)
if n_channels == 1:
tensor = tensor.repeat(1, 3, 1, 1)
n_channels = 3
pad_channels = 13 - n_channels
padding = torch.zeros(
tensor.shape[0], pad_channels, tensor.shape[2], tensor.shape[3])
return torch.cat([tensor, padding], dim=1)
def _make_rgb_preview(path: Path, modality: str, size=(128, 128)) -> Image.Image:
"""Create an RGB preview image from any modality file."""
try:
import tifffile
arr = tifffile.imread(str(path))
if arr.ndim == 3 and arr.shape[-1] >= 3:
preview = arr[:, :, :3]
elif arr.ndim == 3 and arr.shape[0] >= 3:
preview = np.transpose(arr[:3], (1, 2, 0))
elif arr.ndim == 2:
preview = np.stack([arr] * 3, axis=-1)
else:
preview = np.stack([arr[:, :, 0]] * 3, axis=-1)
# Normalize for display
if preview.dtype == np.uint16:
preview = (preview / 65535.0 * 255).astype(np.uint8)
elif preview.dtype == np.uint8:
pass
else:
preview = (np.clip(preview, 0, 1) * 255).astype(np.uint8)
# Special handling for SAR: grayscale with colormap feel
if modality == "sar":
preview = preview # Keep as is
return Image.fromarray(preview).resize(size, Image.LANCZOS)
except Exception:
# Fallback to PIL
return Image.open(path).convert("RGB").resize(size, Image.LANCZOS)
@torch.no_grad()
def extract_embeddings_satclip(images, encoder, modality="optical"):
"""Extract L2-normalized embeddings from image tensors using SatCLIP."""
all_feats = []
for i in range(0, len(images), BATCH_SIZE):
batch = images[i: i + BATCH_SIZE]
tensors = torch.stack(batch)
# Pad to 13 channels if needed
if tensors.shape[1] < 13:
tensors = _pad_to_13ch(tensors, modality)
feats = encoder.encode(tensors, normalize=True)
all_feats.append(feats.cpu())
return torch.cat(all_feats, dim=0)
def build_gallery(n_per_class: int = 50):
"""Full pipeline: download, embed, build index."""
t0 = time.time()
# 1. Download data for all three modalities
optical_dir = download_optical(n_per_class)
sar_dir = download_sar(n_per_class)
ms_dir = download_multispectral(n_per_class)
# 2. Collect all images with proper multi-channel loading
modalities = {
"optical": optical_dir,
"sar": sar_dir,
"multispectral": ms_dir,
}
all_images = [] # list of (image_tensor, modality, class_name, path)
for mod, base_dir in modalities.items():
for cls_dir in sorted(base_dir.iterdir()):
if not cls_dir.is_dir():
continue
paths = sorted(cls_dir.glob("*.*"))[:n_per_class]
for img_path in paths:
tensor = _load_multichannel_image(img_path, mod)
all_images.append((tensor, mod, cls_dir.name, img_path))
print(f"\nTotal gallery images: {len(all_images)}")
for mod in modalities:
count = sum(1 for _, m, _, _ in all_images if m == mod)
print(f" {mod}: {count}")
# 3. Build gallery preview images and extract embeddings
print("\nBuilding gallery previews ...")
GALLERY_DIR.mkdir(parents=True, exist_ok=True)
for i, (_, mod, cls, path) in enumerate(tqdm(all_images, desc="Previews")):
preview = _make_rgb_preview(path, mod)
preview.save(GALLERY_DIR / f"{i:05d}_{mod}_{cls}.png")
# 4. Extract SatCLIP embeddings
print("\nExtracting SatCLIP embeddings ...")
encoder = load_satclip()
embeddings_by_mod = {}
for mod in ["optical", "sar", "multispectral"]:
mod_tensors = [img for img, m, _, _ in all_images if m == mod]
if mod_tensors:
print(f" Extracting {mod} ({len(mod_tensors)} images)...")
embeddings_by_mod[mod] = extract_embeddings_satclip(
mod_tensors, encoder, mod)
embeddings = torch.cat(list(embeddings_by_mod.values()), dim=0)
print(f"Embeddings shape: {embeddings.shape}")
# 5. Build FAISS index
print("\nBuilding FAISS index ...")
import faiss
embed_dim = embeddings.shape[1]
index = faiss.IndexFlatIP(embed_dim)
index.add(embeddings.numpy().astype(np.float32))
print(f"FAISS index size: {index.ntotal}")
# 6. Save everything
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
faiss.write_index(index, str(PROCESSED_DIR / "gallery.index"))
torch.save(embeddings, PROCESSED_DIR / "gallery_embeddings.pt")
metadata = []
for i, (_, mod, cls, path) in enumerate(all_images):
metadata.append({
"index": i,
"modality": mod,
"class": cls,
"gallery_path": str(GALLERY_DIR / f"{i:05d}_{mod}_{cls}.png"),
"original_path": str(path),
})
with open(PROCESSED_DIR / "gallery_metadata.json", "w") as f:
json.dump(metadata, f, indent=2)
elapsed = time.time() - t0
print(f"\nDone in {elapsed:.1f}s")
print(f"Index: {PROCESSED_DIR / 'gallery.index'}")
print(f"Embeddings:{PROCESSED_DIR / 'gallery_embeddings.pt'}")
print(f"Metadata: {PROCESSED_DIR / 'gallery_metadata.json'}")
print(f"Gallery: {GALLERY_DIR}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Build multi-modal satellite image gallery")
parser.add_argument("--samples", type=int, default=50,
help="Images per class per modality")
args = parser.parse_args()
build_gallery(n_per_class=args.samples)