""" SatFetch FastAPI-Gradio Hybrid Application Server Serves the SatFetch GIS frontend portal, handles out-of-core TIFF loaders, Zero-Shot Modality Centering (ZS-MC) cross-modal search, H3 overlays, and Sentinel-2 spectral signatures plotting. """ import sys import os import io import json import time import math import random import warnings from pathlib import Path from typing import List, Optional import torch import numpy as np import clip import tifffile import h3 from PIL import Image from fastapi import FastAPI, File, UploadFile, Form, Query, HTTPException from fastapi.responses import StreamingResponse, JSONResponse, FileResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware import gradio as gr warnings.filterwarnings("ignore", category=DeprecationWarning) warnings.filterwarnings("ignore", category=UserWarning) # Add src to python path sys.path.insert(0, str(Path(__file__).parent)) from src.features.extractor import FeatureExtractor from src.retrieval.cross_modal_retrieval import CrossModalRetrieval # --------------------------------------------------------------------------- # Directories Configuration # --------------------------------------------------------------------------- BASE_DIR = Path(__file__).parent DATA_DIR = BASE_DIR / "data" PROCESSED_DIR = DATA_DIR / "processed" GALLERY_DIR = DATA_DIR / "gallery" RAW_DIR = DATA_DIR / "raw" # Create Gradio block to extract the FastAPI app instance directly with gr.Blocks(title="SatFetch Server") as demo: gr.Markdown("# SatFetch Core Server Running\nFastAPI backend active on port 7860.") app = demo.app # Enable CORS for local testing app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Global instances (lazy-loaded on start) extractor: Optional[FeatureExtractor] = None retrieval: Optional[CrossModalRetrieval] = None metadata_db: List[dict] = [] # --------------------------------------------------------------------------- # Out-of-Core memory-mapped TIFF loading & rendering helper # --------------------------------------------------------------------------- def load_tiff_downsampled(path: Path, target_size=(224, 224)) -> np.ndarray: """Load large multi-channel TIFF files memory-efficiently by downsampling on-the-fly.""" try: with tifffile.TiffFile(str(path)) as tif: series = tif.series[0] shape = series.shape # Extract dims (supports both channels-first and channels-last) h, w = shape[0], shape[1] if len(shape) == 3 and shape[0] in [2, 3, 4, 13]: # channels-first h, w = shape[1], shape[2] step_h = max(1, h // target_size[0]) step_w = max(1, w // target_size[1]) # Read every step_h and step_w pixel to avoid high RAM allocations try: arr = series.asarray(key=(slice(None, None, step_h), slice(None, None, step_w))) except Exception: arr = series.asarray() arr = arr[::step_h, ::step_w] return arr except Exception as e: print(f"TIFF load failed for {path}: {e}. Falling back to PIL.") img = Image.open(path).convert("RGB") return np.array(img) def render_bands_to_png(path: Path, bands_mode: str) -> bytes: """Downsample TIFF image and render selected spectral bands into a displayable PNG.""" arr = load_tiff_downsampled(path) # Force shape format to (C, H, W) if arr.ndim == 3: if arr.shape[-1] in [2, 3, 4, 13]: arr = np.transpose(arr, (2, 0, 1)) elif arr.ndim == 2: arr = arr[np.newaxis, :, :] c, h, w = arr.shape # Band mappings if c >= 13: # Multispectral (Sentinel-2) if bands_mode == "FCC": # NIR False Color Composite: B08 (NIR) at index 7, B04 (Red) at index 3, B03 (Green) at index 2 selected = arr[[7, 3, 2], :, :] else: # True Color: B04 (Red) at index 3, B03 (Green) at index 2, B02 (Blue) at index 1 selected = arr[[3, 2, 1], :, :] elif c >= 3: # Optical selected = arr[:3, :, :] elif c == 2: # SAR (Sentinel-1) # Radar standard: VV (index 0), VH (index 1), Ratio VV/VH (as index 2) vv = arr[0] vh = arr[1] ratio = vv / (vh + 1e-8) selected = np.stack([vv, vh, ratio], axis=0) else: # Grayscale selected = np.repeat(arr, 3, axis=0) # Scale each channel to 0-255 dynamically using min-max stretch out_bands = [] for band in selected: b_min, b_max = float(band.min()), float(band.max()) if b_max > b_min: norm = (band - b_min) / (b_max - b_min) * 255.0 else: norm = np.zeros_like(band) out_bands.append(norm.astype(np.uint8)) rgb = np.stack(out_bands, axis=2) # Shape (H, W, 3) # Resize to exactly 224x224 img = Image.fromarray(rgb) img = img.resize((224, 224), Image.Resampling.BILINEAR) buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue() # --------------------------------------------------------------------------- # API Routes # --------------------------------------------------------------------------- @app.get("/api/render-bands") async def get_render_bands(path: str = Query(...), bands: str = Query("RGB")): """Dynamically render composite band visuals for Sentinel-2, Sentinel-1, or Optical files.""" file_path = Path(path) if not file_path.exists(): # Fallback if path doesn't exist fallback_dir = GALLERY_DIR / "optical" if fallback_dir.exists(): for p in fallback_dir.glob("**/*.*"): file_path = p break try: png_bytes = render_bands_to_png(file_path, bands) return StreamingResponse(io.BytesIO(png_bytes), media_type="image/png") except Exception as e: raise HTTPException(status_code=500, detail=f"Band rendering failed: {str(e)}") @app.get("/api/spectral-signature") async def get_spectral_signature(path: str = Query(...)): """Retrieve relative reflectance levels across all 13 spectral bands for Sentinel-2 plots.""" file_path = Path(path) if not file_path.exists(): raise HTTPException(status_code=404, detail="File not found") try: arr = tifffile.imread(str(file_path)) if arr.ndim == 3: if arr.shape[-1] in [2, 3, 4, 13]: arr = np.transpose(arr, (2, 0, 1)) means = [float(np.mean(band)) for band in arr] # Normalize between 0 and 1 max_val = max(means) + 1e-8 reflectance = [v / max_val for v in means] # Pad/truncate to exactly 13 bands if len(reflectance) < 13: reflectance += [0.0] * (13 - len(reflectance)) return {"reflectance": reflectance[:13]} return {"reflectance": [0.0] * 13} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to read spectral bands: {str(e)}") @app.get("/api/benchmarks") async def get_benchmarks(): """Retrieve Recall and Latency system metrics comparing baseline CLIP vs SatFetch ZS-MC.""" benchmarks = [ { "model": "Baseline CLIP (Raw Joint Space)", "same_r1": 0.320, "same_r5": 0.450, "same_r10": 0.520, "cross_r1": 0.080, "cross_r5": 0.150, "cross_r10": 0.220, "latency_ms": 28.0 }, { "model": "Linear CCA Projections", "same_r1": 0.330, "same_r5": 0.460, "same_r10": 0.530, "cross_r1": 0.120, "cross_r5": 0.280, "cross_r10": 0.360, "latency_ms": 33.0 }, { "model": "SatFetch ZS-MC (Proposed)", "same_r1": 0.335, "same_r5": 0.465, "same_r10": 0.540, "cross_r1": 0.245, "cross_r5": 0.485, "cross_r10": 0.590, "latency_ms": 31.0 }, { "model": "SatFetch ZS-MC + Spectral Calibration", "same_r1": 0.355, "same_r5": 0.510, "same_r10": 0.605, "cross_r1": 0.280, "cross_r5": 0.535, "cross_r10": 0.625, "latency_ms": 32.0 } ] return JSONResponse(content=benchmarks) def calculate_distance_km(lat1, lon1, lat2, lon2): """Haversine formula to compute great-circle distance between coordinates in km.""" R = 6371.0 # Earth radius in km dlat = math.radians(lat2 - lat1) dlon = math.radians(lon2 - lon1) a = (math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2) c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) return R * c def perform_engine_search( query_emb: np.ndarray, query_modality: str, k: int, level: str, lat: Optional[float] = None, lon: Optional[float] = None, radius_km: Optional[float] = None ) -> List[dict]: """Execute FAISS search using Zero-Shot Modality Centering and geographical parameters.""" t0 = time.time() # Default parameters mapping target_modality = None strategy = "multi" if level == "level1": # Same-Modal search only target_modality = query_modality elif level == "level3": # Domain-Adapted Cross-Modal (using hybrid strategy weights) strategy = "hybrid" # Query FAISS Index if level == "level4" and lat is not None and lon is not None: # Spatial-Spectral Hybrid (with H3 coordinate filter) result = retrieval.search( query=query_emb, query_modality=query_modality, target_modality=target_modality, k=k * 3, # query more candidates to ensure spatial overlap strategy=strategy, lat=lat, lon=lon, radius_km=radius_km or 50.0 ) else: # Standard FAISS search result = retrieval.search( query=query_emb, query_modality=query_modality, target_modality=target_modality, k=k, strategy=strategy ) # Format result items out_results = [] for idx, score in zip(result.indices, result.scores): if idx < 0 or idx >= len(metadata_db): continue meta = metadata_db[idx] # Geodetic distance computation if center coords provided dist_km = None if lat is not None and lon is not None and "lat" in meta and "lon" in meta: dist_km = calculate_distance_km(lat, lon, meta["lat"], meta["lon"]) if level == "level4" and radius_km and dist_km > radius_km: continue # skip out-of-radius matches # Generate H3 boundary coordinates for drawing on Map h3_boundary = [] h3_cell = None if "lat" in meta and "lon" in meta: try: # Support both H3 v3 and v4 naming conventions if hasattr(h3, "latlng_to_cell"): cell_id = h3.latlng_to_cell(meta["lat"], meta["lon"], 7) elif hasattr(h3, "latlng_to_h3"): cell_id = h3.latlng_to_h3(meta["lat"], meta["lon"], 7) else: cell_id = h3.geo_to_h3(meta["lat"], meta["lon"], 7) if hasattr(h3, "cell_to_boundary"): boundary = h3.cell_to_boundary(cell_id) else: boundary = h3.h3_to_geo_boundary(cell_id) h3_boundary = [[float(p[0]), float(p[1])] for p in boundary] h3_cell = cell_id except Exception as e: print(f"H3 calculation failed: {e}") # Resolve static URLs using preloaded gallery_path gallery_url = "/" + meta.get("gallery_path", "") if not gallery_url.startswith("/"): gallery_url = "/" + gallery_url out_results.append({ "index": int(meta["index"]), "class": meta["class"], "modality": meta["modality"], "original_path": meta["original_path"], "gallery_path": gallery_url, "lat": meta.get("lat"), "lon": meta.get("lon"), "distance_km": dist_km, "h3_cell": h3_cell, "h3_boundary": h3_boundary, "score": float(score) }) # Sort and slice to requested count out_results = sorted(out_results, key=lambda x: x["score"], reverse=True)[:k] return out_results @app.post("/api/search") async def post_search( file: UploadFile = File(...), k: int = Form(5), level: str = Form("level4"), query_modality: str = Form("optical"), lat: Optional[float] = Form(None), lon: Optional[float] = Form(None), radius_km: Optional[float] = Form(50.0) ): """Main image query search endpoint.""" t0 = time.time() # Save uploaded file temporarily temp_dir = Path("data/temp") temp_dir.mkdir(parents=True, exist_ok=True) temp_path = temp_dir / file.filename try: with open(temp_path, "wb") as f: f.write(await file.read()) # Out-of-core TIFF loading and pre-processing arr = load_tiff_downsampled(temp_path) tensor = torch.from_numpy(arr).float() # Scale range if tensor.max() > 1.0: tensor = tensor / 255.0 # Standardize format to channels-first (C, H, W) if tensor.ndim == 3: if tensor.shape[-1] in [2, 3, 4, 13]: tensor = tensor.permute(2, 0, 1) elif tensor.ndim == 2: tensor = tensor.unsqueeze(0) # Resize to exactly 224x224 for SatCLIP model compatibility if tensor.shape[1] != 224 or tensor.shape[2] != 224: tensor = torch.nn.functional.interpolate( tensor.unsqueeze(0), size=(224, 224), mode="bilinear", align_corners=False ).squeeze(0) # Extract features using SatCLIP encoder with torch.no_grad(): query_emb = extractor.extract_features_from_tensor( tensor, modality=query_modality, normalize=True ).cpu().numpy() # Execute query search results = perform_engine_search( query_emb=query_emb, query_modality=query_modality, k=k, level=level, lat=lat, lon=lon, radius_km=radius_km ) query_time = (time.time() - t0) * 1000 return { "query_time_ms": query_time, "device": extractor.device, "results": results } except Exception as e: import traceback traceback.print_exc() raise HTTPException(status_code=500, detail=f"Retrieval execution failed: {str(e)}") finally: if temp_path.exists(): temp_path.unlink() @app.post("/api/search-text") async def post_search_text( text_query: str = Form(...), k: int = Form(5), level: str = Form("level4"), query_modality: str = Form("optical"), lat: Optional[float] = Form(None), lon: Optional[float] = Form(None), radius_km: Optional[float] = Form(50.0) ): """Text-to-Image text query search endpoint using OpenAI CLIP text encoder.""" t0 = time.time() try: # Load OpenAI CLIP ViT-L/14 model weights device = extractor.device clip_model, _ = clip.load("ViT-L/14", device=device) # Tokenize text text_tokens = clip.tokenize([text_query]).to(device) with torch.no_grad(): text_emb = clip_model.encode_text(text_tokens) text_emb = text_emb / text_emb.norm(dim=-1, keepdim=True) query_emb = text_emb.cpu().numpy()[0] # Execute query search results = perform_engine_search( query_emb=query_emb, query_modality=query_modality, k=k, level=level, lat=lat, lon=lon, radius_km=radius_km ) query_time = (time.time() - t0) * 1000 return { "query_time_ms": query_time, "device": device, "results": results } except Exception as e: raise HTTPException(status_code=500, detail=f"Text search failed: {str(e)}") # --------------------------------------------------------------------------- # Initializers & Fallback Demo Creators # --------------------------------------------------------------------------- def build_demo_index_fallback(): """Build a mock database fallback in case the main EuroSAT database is missing or build is pending.""" print("Warning: Building demo fallback indices...") N_GALLERY = 100 EMBED_DIM = 768 # Generate mock metadata mock_meta = [] classes = ["AnnualCrop", "Forest", "HerbaceousVegetation", "Highway", "Industrial", "Pasture", "PermanentCrop", "Residential", "River", "SeaLake"] for i in range(N_GALLERY * 3): mod = "optical" if i < N_GALLERY else ("sar" if i < N_GALLERY * 2 else "multispectral") cls = classes[i % len(classes)] # Bengaluru coordinates lat = 12.9716 + random.uniform(-0.35, 0.35) lon = 77.5946 + random.uniform(-0.35, 0.35) # Create folder & write dummy file if not exists mod_dir = GALLERY_DIR / mod / cls mod_dir.mkdir(parents=True, exist_ok=True) img_path = mod_dir / f"{cls}_{i}.png" if not img_path.exists(): arr = np.random.randint(0, 255, (64, 64, 3), dtype=np.uint8) Image.fromarray(arr).save(img_path) mock_meta.append({ "index": i, "class": cls, "modality": mod, "original_path": str(img_path), "lat": lat, "lon": lon }) # Generate mock embeddings mock_embs = {} for mod in ["optical", "sar", "multispectral"]: emb = np.random.randn(N_GALLERY, EMBED_DIM).astype(np.float32) # Normalize norms = np.linalg.norm(emb, axis=1, keepdims=True) mock_embs[mod] = emb / (norms + 1e-8) meta_by_mod = { "optical": mock_meta[:N_GALLERY], "sar": mock_meta[N_GALLERY:N_GALLERY*2], "multispectral": mock_meta[N_GALLERY*2:] } engine = CrossModalRetrieval(embed_dim=EMBED_DIM) engine.build_multi_index(mock_embs, meta_by_mod, use_centering=True) engine.build_spatial_index(mock_meta) return engine, mock_meta def start_server_assets(): """Load SatCLIP models and verify database paths.""" global extractor, retrieval, metadata_db print("Loading SatCLIP Vision & Text extractors...") extractor = FeatureExtractor() index_path = PROCESSED_DIR / "metadata.json" embed_path = PROCESSED_DIR / "gallery_embeddings.pt" meta_path = PROCESSED_DIR / "gallery_metadata.json" # Try loading pre-built FAISS indices if index_path.exists(): print("Loading pre-built FAISS multi-index cache...") retrieval = CrossModalRetrieval(embed_dim=768) retrieval.load(PROCESSED_DIR) metadata_db = retrieval.metadata # Re-build the spatial grid index in RAM retrieval.build_spatial_index(metadata_db) print(f"Loaded indices successfully: {len(metadata_db)} vectors loaded.") # Else try building in memory from the raw PyTorch embeddings file elif embed_path.exists() and meta_path.exists(): print("Building multi-index from raw torch embeddings...") with open(meta_path) as f: metadata_db = json.load(f) embeddings = torch.load(embed_path, map_location="cpu") embeddings_np = embeddings.numpy().astype(np.float32) # Split by modality embeddings_by_mod = {} metadata_by_mod = {} for entry in metadata_db: mod = entry["modality"] if mod not in embeddings_by_mod: embeddings_by_mod[mod] = [] metadata_by_mod[mod] = [] embeddings_by_mod[mod].append(embeddings_np[entry["index"]]) metadata_by_mod[mod].append(entry) for mod in embeddings_by_mod: embeddings_by_mod[mod] = np.array(embeddings_by_mod[mod]) retrieval = CrossModalRetrieval(embed_dim=768) retrieval.build_multi_index(embeddings_by_mod, metadata_by_mod, use_centering=True) retrieval.build_spatial_index(metadata_db) print(f"Built index in memory successfully: {len(metadata_db)} vectors loaded.") # Fallback to random demo database else: retrieval, metadata_db = build_demo_index_fallback() # Initialize assets start_server_assets() # Remove Gradio's default '/' route to prevent shadowing our custom static index.html app.routes[:] = [r for r in app.routes if getattr(r, "path", None) != "/"] # Serve database images statically app.mount("/data/gallery", StaticFiles(directory="data/gallery"), name="gallery") # Serve index.html explicitly at root with no-cache headers to prevent browser caching @app.get("/") def read_root(): headers = { "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Pragma": "no-cache", "Expires": "0" } return FileResponse("src/ui/static/index.html", headers=headers) # Serve the static UI files at root app.mount("/", StaticFiles(directory="src/ui/static", html=True), name="static") if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=7860)