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