""" 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)