Skip to main content
Glama
visualization.py12.3 kB
""" Visualization utilities for satellite imagery analysis. Provides colormaps, map generation, and image conversion functions. """ import base64 from io import BytesIO from typing import Dict, List, Optional, Tuple, Any import numpy as np from PIL import Image from geosight.utils.geo import BoundingBox # Colormap definitions for various indices COLORMAPS = { "ndvi": { "colors": [ (-1.0, (0, 0, 128)), # Deep blue (water) (-0.2, (139, 69, 19)), # Brown (bare soil) (0.0, (210, 180, 140)), # Tan (bare ground) (0.1, (255, 255, 200)), # Light yellow (0.2, (200, 255, 150)), # Light green (0.4, (100, 200, 100)), # Medium green (0.6, (50, 150, 50)), # Dark green (0.8, (0, 100, 0)), # Forest green (1.0, (0, 50, 0)), # Deep green ], "label": "NDVI", }, "ndwi": { "colors": [ (-1.0, (139, 69, 19)), # Brown (-0.3, (210, 180, 140)), # Tan (0.0, (200, 200, 200)), # Gray (0.2, (173, 216, 230)), # Light blue (0.4, (65, 105, 225)), # Royal blue (0.6, (0, 0, 205)), # Medium blue (1.0, (0, 0, 139)), # Dark blue ], "label": "NDWI", }, "ndbi": { "colors": [ (-1.0, (34, 139, 34)), # Forest green (-0.3, (144, 238, 144)), # Light green (0.0, (255, 255, 224)), # Light yellow (0.2, (255, 165, 0)), # Orange (0.4, (255, 69, 0)), # Red-orange (0.6, (220, 20, 60)), # Crimson (1.0, (139, 0, 0)), # Dark red ], "label": "NDBI", }, "change": { "colors": [ (-1.0, (255, 0, 0)), # Red (decrease) (-0.3, (255, 165, 0)), # Orange (0.0, (255, 255, 255)), # White (no change) (0.3, (0, 255, 0)), # Green (1.0, (0, 128, 0)), # Dark green (increase) ], "label": "Change", }, } def interpolate_color( value: float, colormap: List[Tuple[float, Tuple[int, int, int]]], ) -> Tuple[int, int, int]: """ Interpolate color from a colormap based on value. Args: value: Value to map (-1 to 1 typically) colormap: List of (threshold, (r, g, b)) tuples Returns: Tuple of (r, g, b) values """ # Handle edge cases if value <= colormap[0][0]: return colormap[0][1] if value >= colormap[-1][0]: return colormap[-1][1] # Find surrounding thresholds for i in range(len(colormap) - 1): low_thresh, low_color = colormap[i] high_thresh, high_color = colormap[i + 1] if low_thresh <= value <= high_thresh: # Linear interpolation t = (value - low_thresh) / (high_thresh - low_thresh) r = int(low_color[0] + t * (high_color[0] - low_color[0])) g = int(low_color[1] + t * (high_color[1] - low_color[1])) b = int(low_color[2] + t * (high_color[2] - low_color[2])) return (r, g, b) return colormap[-1][1] def create_index_colormap( data: np.ndarray, index_type: str, vmin: Optional[float] = None, vmax: Optional[float] = None, ) -> np.ndarray: """ Apply a colormap to index data. Args: data: 2D array of index values index_type: Type of index ('ndvi', 'ndwi', 'ndbi', 'change') vmin: Minimum value for scaling vmax: Maximum value for scaling Returns: RGB image as numpy array (H, W, 3) """ if index_type not in COLORMAPS: raise ValueError(f"Unknown index type: {index_type}") colormap = COLORMAPS[index_type]["colors"] # Create output array height, width = data.shape rgb = np.zeros((height, width, 3), dtype=np.uint8) # Handle NaN values nan_mask = np.isnan(data) # Normalize if vmin/vmax provided if vmin is not None and vmax is not None: data_norm = (data - vmin) / (vmax - vmin) * 2 - 1 # Scale to -1 to 1 else: data_norm = data.copy() # Clip to valid range data_norm = np.clip(data_norm, -1, 1) # Apply colormap pixel by pixel (vectorized for performance) for i, (threshold, color) in enumerate(colormap[:-1]): next_threshold = colormap[i + 1][0] next_color = colormap[i + 1][1] mask = (data_norm >= threshold) & (data_norm < next_threshold) & ~nan_mask if np.any(mask): t = (data_norm[mask] - threshold) / (next_threshold - threshold) rgb[mask, 0] = (color[0] + t * (next_color[0] - color[0])).astype(np.uint8) rgb[mask, 1] = (color[1] + t * (next_color[1] - color[1])).astype(np.uint8) rgb[mask, 2] = (color[2] + t * (next_color[2] - color[2])).astype(np.uint8) # Handle values at or above max max_mask = (data_norm >= colormap[-1][0]) & ~nan_mask rgb[max_mask] = colormap[-1][1] # Set NaN values to gray rgb[nan_mask] = (128, 128, 128) return rgb def create_classification_map( classification: np.ndarray, class_colors: Dict[int, str], ) -> np.ndarray: """ Create a colored classification map. Args: classification: 2D array of class labels class_colors: Dictionary mapping class ID to hex color Returns: RGB image as numpy array (H, W, 3) """ height, width = classification.shape rgb = np.zeros((height, width, 3), dtype=np.uint8) for class_id, hex_color in class_colors.items(): # Convert hex to RGB hex_color = hex_color.lstrip('#') r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) mask = classification == class_id rgb[mask] = (r, g, b) return rgb def array_to_base64_png( array: np.ndarray, resize: Optional[Tuple[int, int]] = None, ) -> str: """ Convert numpy array to base64-encoded PNG. Args: array: Image array (H, W, 3) or (H, W) for grayscale resize: Optional (width, height) to resize image Returns: Base64-encoded PNG string """ # Handle grayscale if array.ndim == 2: array = np.stack([array] * 3, axis=-1) # Ensure uint8 if array.dtype != np.uint8: if array.max() <= 1.0: array = (array * 255).astype(np.uint8) else: array = array.astype(np.uint8) # Create PIL image image = Image.fromarray(array) # Resize if requested if resize: image = image.resize(resize, Image.Resampling.LANCZOS) # Save to buffer buffer = BytesIO() image.save(buffer, format="PNG", optimize=True) buffer.seek(0) # Encode to base64 return base64.b64encode(buffer.read()).decode("utf-8") def create_folium_map( center: Tuple[float, float], overlay_data: Optional[np.ndarray] = None, overlay_bounds: Optional[BoundingBox] = None, colormap: str = "ndvi", title: str = "Analysis", zoom_start: int = 12, ) -> str: """ Create an interactive Folium map with optional overlay. Note: Returns HTML string. For full functionality, install folium. Args: center: Map center (lat, lon) overlay_data: Optional data array to overlay overlay_bounds: Bounds for the overlay colormap: Colormap name for overlay title: Map title zoom_start: Initial zoom level Returns: HTML string of the map """ try: import folium from folium import plugins # Create base map m = folium.Map( location=center, zoom_start=zoom_start, tiles="OpenStreetMap", ) # Add satellite tile layer folium.TileLayer( tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", attr="Esri", name="Satellite", ).add_to(m) # Add overlay if provided if overlay_data is not None and overlay_bounds is not None: # Create colored overlay colored = create_index_colormap(overlay_data, colormap) overlay_base64 = array_to_base64_png(colored) # Add as image overlay bounds = [ [overlay_bounds.south, overlay_bounds.west], [overlay_bounds.north, overlay_bounds.east], ] folium.raster_layers.ImageOverlay( image=f"data:image/png;base64,{overlay_base64}", bounds=bounds, opacity=0.7, name=title, ).add_to(m) # Add marker at center folium.Marker( location=center, popup=title, icon=folium.Icon(color="red", icon="info-sign"), ).add_to(m) # Add layer control folium.LayerControl().add_to(m) # Add fullscreen button plugins.Fullscreen().add_to(m) return m._repr_html_() except ImportError: # Return simple HTML without folium return f""" <html> <head><title>{title}</title></head> <body> <h2>{title}</h2> <p>Center: {center[0]:.4f}, {center[1]:.4f}</p> <p>Install folium for interactive maps: pip install folium</p> </body> </html> """ def create_comparison_image( before: np.ndarray, after: np.ndarray, labels: Tuple[str, str] = ("Before", "After"), ) -> np.ndarray: """ Create a side-by-side comparison image. Args: before: First image array after: Second image array labels: Labels for the images Returns: Combined image array """ # Ensure same dimensions if before.shape != after.shape: # Resize to match target_h = min(before.shape[0], after.shape[0]) target_w = min(before.shape[1], after.shape[1]) before_img = Image.fromarray(before).resize((target_w, target_h)) after_img = Image.fromarray(after).resize((target_w, target_h)) before = np.array(before_img) after = np.array(after_img) # Add separator separator = np.ones((before.shape[0], 4, 3), dtype=np.uint8) * 255 # Combine horizontally combined = np.concatenate([before, separator, after], axis=1) return combined def create_legend_image( colormap: str, width: int = 256, height: int = 30, ) -> np.ndarray: """ Create a colorbar/legend image for a colormap. Args: colormap: Colormap name width: Legend width in pixels height: Legend height in pixels Returns: RGB image array """ if colormap not in COLORMAPS: raise ValueError(f"Unknown colormap: {colormap}") # Create gradient values = np.linspace(-1, 1, width) values = np.tile(values, (height, 1)) return create_index_colormap(values, colormap) def add_colorbar_to_image( image: np.ndarray, colormap: str, label: Optional[str] = None, position: str = "bottom", ) -> np.ndarray: """ Add a colorbar to an image. Args: image: Input RGB image colormap: Colormap name label: Optional label for colorbar position: Position ('bottom', 'right') Returns: Image with colorbar """ colorbar = create_legend_image(colormap, image.shape[1], 20) # Add padding padding = np.ones((10, image.shape[1], 3), dtype=np.uint8) * 255 if position == "bottom": return np.vstack([image, padding, colorbar]) else: # Rotate for right position colorbar = np.rot90(create_legend_image(colormap, image.shape[0], 20), k=3) padding = np.ones((image.shape[0], 10, 3), dtype=np.uint8) * 255 return np.hstack([image, padding, colorbar])

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/armaasinghn/geosight-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server