MineWatchAI / src /visualization.py
Ashkan Taghipour (The University of Western Australia)
Initial commit
f5648f5
"""
Visualization module for RehabWatch.
Creates maps and charts using Folium and Plotly.
"""
import numpy as np
import xarray as xr
import folium
from folium import plugins
from folium.raster_layers import ImageOverlay
import plotly.graph_objects as go
import streamlit as st
from typing import Dict, Any, List, Optional, Tuple
from matplotlib.colors import LinearSegmentedColormap
import base64
from io import BytesIO
from PIL import Image
# NDVI color palette (brown to green)
NDVI_COLORS = ['#8B4513', '#D2B48C', '#FFFF00', '#90EE90', '#228B22', '#006400']
# Change color palette (red-white-green diverging)
CHANGE_COLORS = ['#B71C1C', '#EF9A9A', '#FFFFFF', '#A5D6A7', '#1B5E20']
def array_to_colored_image(
data: np.ndarray,
colors: List[str],
vmin: float,
vmax: float
) -> np.ndarray:
"""
Convert a 2D array to a colored RGBA image.
Args:
data: 2D numpy array
colors: List of hex color strings for colormap
vmin: Minimum value for normalization
vmax: Maximum value for normalization
Returns:
RGBA numpy array (H, W, 4) with values 0-255
"""
cmap = LinearSegmentedColormap.from_list('custom', colors)
# Normalize data
normalized = (data - vmin) / (vmax - vmin)
normalized = np.clip(normalized, 0, 1)
# Handle NaN values
mask = np.isnan(data)
# Apply colormap
rgba = cmap(normalized)
rgba = (rgba * 255).astype(np.uint8)
# Set NaN pixels to transparent
rgba[mask, 3] = 0
return rgba
def create_image_overlay(
data: xr.DataArray,
colors: List[str],
vmin: float,
vmax: float,
bounds: List[List[float]]
) -> str:
"""
Create a base64-encoded PNG image for Folium overlay.
Args:
data: xarray DataArray
colors: Color palette
vmin: Min value for normalization
vmax: Max value for normalization
bounds: [[south, west], [north, east]]
Returns:
Base64 encoded PNG string
"""
# Get the 2D array
arr = data.values
if arr.ndim > 2:
arr = arr.squeeze()
# Create colored image
rgba = array_to_colored_image(arr, colors, vmin, vmax)
# Flip vertically for correct orientation
rgba = np.flipud(rgba)
# Convert to PNG
img = Image.fromarray(rgba, mode='RGBA')
buffer = BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
# Encode to base64
img_base64 = base64.b64encode(buffer.getvalue()).decode()
return f"data:image/png;base64,{img_base64}"
def create_comparison_map(
bbox: Tuple[float, float, float, float],
ndvi_before: xr.DataArray,
ndvi_after: xr.DataArray,
ndvi_change: xr.DataArray,
center_coords: Tuple[float, float],
zoom: int = 12
) -> folium.Map:
"""
Create an interactive comparison map with multiple layers.
Args:
bbox: Bounding box (min_lon, min_lat, max_lon, max_lat)
ndvi_before: NDVI xarray at start date
ndvi_after: NDVI xarray at end date
ndvi_change: NDVI change xarray
center_coords: Map center (lat, lon)
zoom: Initial zoom level
Returns:
Folium Map object with all layers
"""
# Create base map
m = folium.Map(
location=center_coords,
zoom_start=zoom,
tiles=None
)
# Add satellite basemap
folium.TileLayer(
tiles='https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attr='Esri',
name='Satellite Imagery',
overlay=False
).add_to(m)
# Add OpenStreetMap as alternative
folium.TileLayer(
tiles='openstreetmap',
name='OpenStreetMap',
overlay=False
).add_to(m)
# Calculate bounds for image overlay
min_lon, min_lat, max_lon, max_lat = bbox
bounds = [[min_lat, min_lon], [max_lat, max_lon]]
# Add NDVI Before layer
try:
ndvi_before_img = create_image_overlay(
ndvi_before, NDVI_COLORS, -0.1, 0.8, bounds
)
ImageOverlay(
image=ndvi_before_img,
bounds=bounds,
opacity=0.7,
name='NDVI Before',
show=False
).add_to(m)
except Exception as e:
print(f"Error adding NDVI Before layer: {e}")
# Add NDVI After layer
try:
ndvi_after_img = create_image_overlay(
ndvi_after, NDVI_COLORS, -0.1, 0.8, bounds
)
ImageOverlay(
image=ndvi_after_img,
bounds=bounds,
opacity=0.7,
name='NDVI After',
show=False
).add_to(m)
except Exception as e:
print(f"Error adding NDVI After layer: {e}")
# Add Change Map layer (shown by default)
try:
change_img = create_image_overlay(
ndvi_change, CHANGE_COLORS, -0.3, 0.3, bounds
)
ImageOverlay(
image=change_img,
bounds=bounds,
opacity=0.7,
name='Vegetation Change',
show=True
).add_to(m)
except Exception as e:
print(f"Error adding Change layer: {e}")
# Add tenement boundary
boundary_coords = [
[min_lat, min_lon],
[min_lat, max_lon],
[max_lat, max_lon],
[max_lat, min_lon],
[min_lat, min_lon]
]
folium.PolyLine(
locations=boundary_coords,
color='#000000',
weight=3,
fill=False,
popup='Analysis Boundary'
).add_to(m)
# Add layer control
folium.LayerControl(position='topright').add_to(m)
# Add legends
_add_legends(m)
return m
def _add_legends(m: folium.Map) -> None:
"""Add color legends to the map."""
legend_html = '''
<div style="position: fixed; bottom: 50px; left: 50px; z-index: 1000;
background-color: white; padding: 10px; border-radius: 5px;
border: 2px solid grey; font-size: 12px; max-width: 150px;">
<p style="margin: 0 0 5px 0; font-weight: bold;">NDVI Scale</p>
<div style="background: linear-gradient(to right, #8B4513, #D2B48C, #FFFF00, #90EE90, #228B22, #006400);
width: 100%; height: 15px; border-radius: 3px;"></div>
<div style="display: flex; justify-content: space-between;">
<span>-0.1</span><span>0.8</span>
</div>
<hr style="margin: 8px 0;">
<p style="margin: 0 0 5px 0; font-weight: bold;">Change</p>
<div style="background: linear-gradient(to right, #B71C1C, #EF9A9A, #FFFFFF, #A5D6A7, #1B5E20);
width: 100%; height: 15px; border-radius: 3px;"></div>
<div style="display: flex; justify-content: space-between;">
<span style="color: #B71C1C;">-0.3</span>
<span style="color: #1B5E20;">+0.3</span>
</div>
<p style="margin: 5px 0 0 0; font-size: 10px; text-align: center;">
Red=Decline | Green=Growth
</p>
</div>
'''
m.get_root().html.add_child(folium.Element(legend_html))
def create_simple_map(
center_coords: Tuple[float, float],
zoom: int = 10,
bbox: Optional[Tuple[float, float, float, float]] = None
) -> folium.Map:
"""
Create a simple map for location preview.
Args:
center_coords: Map center (lat, lon)
zoom: Zoom level
bbox: Optional bounding box to display
Returns:
Folium Map object
"""
m = folium.Map(location=center_coords, zoom_start=zoom)
# Add satellite imagery
folium.TileLayer(
tiles='https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attr='Esri',
name='Satellite',
overlay=False
).add_to(m)
if bbox is not None:
min_lon, min_lat, max_lon, max_lat = bbox
boundary_coords = [
[min_lat, min_lon],
[min_lat, max_lon],
[max_lat, max_lon],
[max_lat, min_lon],
[min_lat, min_lon]
]
folium.Polygon(
locations=boundary_coords,
color='#1B5E20',
weight=3,
fill=True,
fillColor='#2E7D32',
fillOpacity=0.2,
popup='Analysis Area'
).add_to(m)
folium.LayerControl().add_to(m)
return m
def create_time_series_chart(
timeseries_data: List[Dict[str, Any]],
title: str = "NDVI Time Series"
) -> go.Figure:
"""
Create an interactive NDVI time series chart.
Args:
timeseries_data: List of dicts with 'date' and 'ndvi' keys
title: Chart title
Returns:
Plotly Figure object
"""
if not timeseries_data:
fig = go.Figure()
fig.add_annotation(
text="No time series data available",
xref="paper", yref="paper",
x=0.5, y=0.5, showarrow=False,
font=dict(size=16)
)
return fig
dates = [d['date'] for d in timeseries_data]
ndvi_values = [d['ndvi'] for d in timeseries_data]
fig = go.Figure()
# Add NDVI line
fig.add_trace(go.Scatter(
x=dates,
y=ndvi_values,
mode='lines+markers',
name='NDVI',
line=dict(color='#2E7D32', width=2),
marker=dict(size=6),
hovertemplate='Date: %{x}<br>NDVI: %{y:.3f}<extra></extra>'
))
# Add reference lines
fig.add_hline(y=0.6, line_dash="dash", line_color="#4CAF50",
annotation_text="Healthy Vegetation", annotation_position="right")
fig.add_hline(y=0.2, line_dash="dash", line_color="#FF9800",
annotation_text="Sparse Vegetation", annotation_position="right")
fig.update_layout(
title=dict(text=title, font=dict(size=18)),
xaxis_title="Date",
yaxis_title="NDVI",
yaxis=dict(range=[0, 1]),
template="plotly_white",
hovermode="x unified",
height=400,
margin=dict(l=60, r=40, t=60, b=60)
)
return fig
def create_stats_display(stats: Dict[str, float], rehab_score: int) -> None:
"""
Display statistics using Streamlit components.
Args:
stats: Statistics dictionary
rehab_score: Rehabilitation score (0-100)
"""
# Rehabilitation Score with large display
st.markdown("### Rehabilitation Score")
score_color = _get_score_color(rehab_score)
st.markdown(f"""
<div style="text-align: center; padding: 20px; background-color: {score_color}20;
border-radius: 10px; margin-bottom: 20px;">
<span style="font-size: 72px; font-weight: bold; color: {score_color};">
{rehab_score}
</span>
<span style="font-size: 24px; color: {score_color};">/100</span>
</div>
""", unsafe_allow_html=True)
# Progress bar
st.progress(rehab_score / 100)
# Key Metrics in columns
# Logic: arrow direction = numeric delta; color = good/bad for nature
st.markdown("### Key Metrics")
col1, col2, col3 = st.columns(3)
ndvi_change = stats.get('ndvi_change_mean', 0)
percent_change = stats.get('percent_change', 0)
with col1:
st.metric(
label="NDVI Before",
value=f"{stats['ndvi_before_mean']:.3f}",
help="Normalized Difference Vegetation Index: measures vegetation health (-1 to 1)"
)
st.metric(
label="Area Improved",
value=f"{stats['area_improved_ha']:.1f} ha",
delta=f"+{stats['percent_improved']:.1f}%",
delta_color="normal" # improvement is always good
)
with col2:
# NDVI: increase = good (green), decrease = bad (red)
st.metric(
label="NDVI After",
value=f"{stats['ndvi_after_mean']:.3f}",
delta=f"{ndvi_change:+.3f}" if ndvi_change != 0 else None,
delta_color="normal", # green for +, red for -
help="Current vegetation index value"
)
st.metric(
label="Area Degraded",
value=f"{stats['area_degraded_ha']:.1f} ha",
delta=f"-{stats['percent_degraded']:.1f}%",
delta_color="inverse" # degradation showing as negative is correct
)
with col3:
# Vegetation Change: increase = good (green), decrease = bad (red)
st.metric(
label="Vegetation Change",
value=f"{percent_change:+.1f}%",
delta=f"{percent_change:+.1f}%" if percent_change != 0 else None,
delta_color="normal", # green for +, red for -
help="Percentage change in vegetation cover"
)
st.metric(
label="Total Area",
value=f"{stats['total_area_ha']:.1f} ha"
)
def _get_score_color(score: int) -> str:
"""Get color based on rehabilitation score."""
if score >= 80:
return "#1B5E20"
elif score >= 60:
return "#4CAF50"
elif score >= 40:
return "#FF9800"
elif score >= 20:
return "#F57C00"
else:
return "#B71C1C"
def create_area_breakdown_chart(stats: Dict[str, float]) -> go.Figure:
"""
Create a pie chart showing area breakdown.
Args:
stats: Statistics dictionary with area values
Returns:
Plotly Figure object
"""
labels = ['Improved', 'Stable', 'Degraded']
values = [
stats['area_improved_ha'],
stats['area_stable_ha'],
stats['area_degraded_ha']
]
colors = ['#4CAF50', '#FFC107', '#F44336']
fig = go.Figure(data=[go.Pie(
labels=labels,
values=values,
marker_colors=colors,
hole=0.4,
textinfo='label+percent',
hovertemplate='%{label}<br>%{value:.1f} ha<br>%{percent}<extra></extra>'
)])
fig.update_layout(
title="Area Breakdown",
annotations=[dict(text='Area', x=0.5, y=0.5, font_size=16, showarrow=False)],
showlegend=True,
height=350
)
return fig
def create_ndvi_comparison_chart(stats: Dict[str, float]) -> go.Figure:
"""
Create a bar chart comparing before/after NDVI.
Args:
stats: Statistics dictionary
Returns:
Plotly Figure object
"""
fig = go.Figure()
fig.add_trace(go.Bar(
x=['Before', 'After'],
y=[stats['ndvi_before_mean'], stats['ndvi_after_mean']],
marker_color=['#8B4513', '#228B22'],
text=[f"{stats['ndvi_before_mean']:.3f}", f"{stats['ndvi_after_mean']:.3f}"],
textposition='outside'
))
fig.update_layout(
title="NDVI Comparison",
yaxis_title="NDVI",
yaxis=dict(range=[0, max(stats['ndvi_after_mean'], stats['ndvi_before_mean']) * 1.3]),
template="plotly_white",
height=350
)
return fig
def create_statistics_table(stats: Dict[str, float]) -> None:
"""
Display full statistics as a formatted table.
Args:
stats: Statistics dictionary
"""
import pandas as pd
data = {
'Metric': [
'NDVI Before (mean)',
'NDVI After (mean)',
'NDVI Change (mean)',
'NDVI Change (std dev)',
'Relative Change',
'Area Improved',
'Area Stable',
'Area Degraded',
'Total Area',
'% Improved',
'% Stable',
'% Degraded'
],
'Value': [
f"{stats['ndvi_before_mean']:.4f}",
f"{stats['ndvi_after_mean']:.4f}",
f"{stats['ndvi_change_mean']:.4f}",
f"{stats['ndvi_change_std']:.4f}",
f"{stats['percent_change']:.2f}%",
f"{stats['area_improved_ha']:.2f} ha",
f"{stats['area_stable_ha']:.2f} ha",
f"{stats['area_degraded_ha']:.2f} ha",
f"{stats['total_area_ha']:.2f} ha",
f"{stats['percent_improved']:.2f}%",
f"{stats['percent_stable']:.2f}%",
f"{stats['percent_degraded']:.2f}%"
],
'Description': [
'Mean vegetation index at analysis start',
'Mean vegetation index at analysis end',
'Average change in vegetation index',
'Variation in vegetation change',
'Percentage change in mean NDVI',
'Area with NDVI increase > 0.05',
'Area with NDVI change between -0.05 and 0.05',
'Area with NDVI decrease > 0.05',
'Total analyzed area',
'Percentage of area showing improvement',
'Percentage of area remaining stable',
'Percentage of area showing degradation'
]
}
df = pd.DataFrame(data)
st.dataframe(df, use_container_width=True, hide_index=True)
# =============================================================================
# NEW EXTENDED VISUALIZATIONS
# =============================================================================
# Color palettes for different indices
BSI_COLORS = ['#228B22', '#90EE90', '#FFFF00', '#D2B48C', '#8B4513'] # Green to brown
WATER_COLORS = ['#8B4513', '#D2B48C', '#87CEEB', '#4169E1', '#000080'] # Brown to blue
MOISTURE_COLORS = ['#B71C1C', '#FF5722', '#FFEB3B', '#8BC34A', '#1B5E20'] # Dry to wet
SLOPE_COLORS = ['#1B5E20', '#4CAF50', '#FFEB3B', '#FF9800', '#B71C1C'] # Flat to steep
EROSION_COLORS = ['#1B5E20', '#4CAF50', '#FFEB3B', '#FF5722', '#B71C1C'] # Low to high risk
# Land cover color mapping
LULC_COLORS = {
1: '#0000FF', # Water - Blue
2: '#228B22', # Trees - Forest Green
4: '#006400', # Flooded Vegetation - Dark Green
5: '#FFD700', # Crops - Gold
7: '#808080', # Built Area - Gray
8: '#D2691E', # Bare Ground - Chocolate
9: '#FFFFFF', # Snow/Ice - White
10: '#C0C0C0', # Clouds - Silver
11: '#9ACD32' # Rangeland - Yellow Green
}
def create_multi_index_map(
bbox: Tuple[float, float, float, float],
indices_after: Dict[str, xr.DataArray],
index_changes: Dict[str, xr.DataArray],
center_coords: Tuple[float, float],
zoom: int = 12
) -> folium.Map:
"""
Create an interactive map with multiple index layers.
"""
m = folium.Map(location=center_coords, zoom_start=zoom, tiles=None)
# Add basemaps
folium.TileLayer(
tiles='https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attr='Esri', name='Satellite', overlay=False
).add_to(m)
folium.TileLayer(tiles='openstreetmap', name='OpenStreetMap', overlay=False).add_to(m)
min_lon, min_lat, max_lon, max_lat = bbox
bounds = [[min_lat, min_lon], [max_lat, max_lon]]
# Index configurations: (data, colors, vmin, vmax, name)
index_configs = [
('ndvi', NDVI_COLORS, -0.1, 0.8, 'NDVI'),
('savi', NDVI_COLORS, -0.1, 0.8, 'SAVI'),
('evi', NDVI_COLORS, -0.1, 0.8, 'EVI'),
('bsi', BSI_COLORS, -0.5, 0.5, 'Bare Soil Index'),
('ndwi', WATER_COLORS, -0.5, 0.5, 'Water Index (NDWI)'),
('ndmi', MOISTURE_COLORS, -0.5, 0.5, 'Moisture Index (NDMI)'),
]
# Add current state layers
for idx_key, colors, vmin, vmax, name in index_configs:
if idx_key in indices_after:
try:
img = create_image_overlay(indices_after[idx_key], colors, vmin, vmax, bounds)
ImageOverlay(
image=img, bounds=bounds, opacity=0.7,
name=f'{name} (Current)', show=(idx_key == 'ndvi')
).add_to(m)
except Exception:
pass
# Add change layers
for idx_key, _, _, _, name in index_configs:
if idx_key in index_changes:
try:
img = create_image_overlay(index_changes[idx_key], CHANGE_COLORS, -0.3, 0.3, bounds)
ImageOverlay(
image=img, bounds=bounds, opacity=0.7,
name=f'{name} Change', show=False
).add_to(m)
except Exception:
pass
# Add boundary
boundary_coords = [
[min_lat, min_lon], [min_lat, max_lon],
[max_lat, max_lon], [max_lat, min_lon], [min_lat, min_lon]
]
folium.PolyLine(locations=boundary_coords, color='#000000', weight=3).add_to(m)
folium.LayerControl(position='topright').add_to(m)
_add_legends(m)
return m
def create_terrain_map(
bbox: Tuple[float, float, float, float],
slope: xr.DataArray,
aspect: Optional[xr.DataArray],
erosion_risk: Optional[xr.DataArray],
center_coords: Tuple[float, float],
zoom: int = 12
) -> folium.Map:
"""
Create an interactive terrain analysis map.
"""
m = folium.Map(location=center_coords, zoom_start=zoom, tiles=None)
folium.TileLayer(
tiles='https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attr='Esri', name='Satellite', overlay=False
).add_to(m)
min_lon, min_lat, max_lon, max_lat = bbox
bounds = [[min_lat, min_lon], [max_lat, max_lon]]
# Add slope layer
try:
slope_img = create_image_overlay(slope, SLOPE_COLORS, 0, 45, bounds)
ImageOverlay(
image=slope_img, bounds=bounds, opacity=0.7,
name='Slope (degrees)', show=True
).add_to(m)
except Exception:
pass
# Add erosion risk layer
if erosion_risk is not None:
try:
erosion_img = create_image_overlay(erosion_risk, EROSION_COLORS, 0, 1, bounds)
ImageOverlay(
image=erosion_img, bounds=bounds, opacity=0.7,
name='Erosion Risk', show=False
).add_to(m)
except Exception:
pass
folium.LayerControl(position='topright').add_to(m)
return m
def create_land_cover_map(
bbox: Tuple[float, float, float, float],
lulc: xr.DataArray,
center_coords: Tuple[float, float],
zoom: int = 12,
year: int = 2023
) -> folium.Map:
"""
Create a land cover classification map.
"""
from matplotlib.colors import ListedColormap
m = folium.Map(location=center_coords, zoom_start=zoom, tiles=None)
folium.TileLayer(
tiles='https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attr='Esri', name='Satellite', overlay=False
).add_to(m)
min_lon, min_lat, max_lon, max_lat = bbox
bounds = [[min_lat, min_lon], [max_lat, max_lon]]
# Create categorical colormap
try:
arr = lulc.values.squeeze()
rgba = np.zeros((*arr.shape, 4), dtype=np.uint8)
for class_id, color in LULC_COLORS.items():
mask = arr == class_id
r = int(color[1:3], 16)
g = int(color[3:5], 16)
b = int(color[5:7], 16)
rgba[mask] = [r, g, b, 200]
rgba = np.flipud(rgba)
img = Image.fromarray(rgba, mode='RGBA')
buffer = BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode()
img_url = f"data:image/png;base64,{img_base64}"
ImageOverlay(
image=img_url, bounds=bounds, opacity=0.7,
name=f'Land Cover {year}', show=True
).add_to(m)
except Exception:
pass
folium.LayerControl(position='topright').add_to(m)
return m
def create_multi_index_chart(stats: Dict[str, float]) -> go.Figure:
"""
Create a grouped bar chart comparing all indices before/after.
"""
indices = ['NDVI', 'SAVI', 'EVI', 'NDWI', 'NDMI', 'BSI']
before_values = []
after_values = []
for idx in ['ndvi', 'savi', 'evi', 'ndwi', 'ndmi', 'bsi']:
before_values.append(stats.get(f'{idx}_before_mean', 0))
after_values.append(stats.get(f'{idx}_after_mean', 0))
fig = go.Figure()
fig.add_trace(go.Bar(
name='Before', x=indices, y=before_values,
marker_color='#8B4513', text=[f'{v:.3f}' for v in before_values],
textposition='outside'
))
fig.add_trace(go.Bar(
name='After', x=indices, y=after_values,
marker_color='#228B22', text=[f'{v:.3f}' for v in after_values],
textposition='outside'
))
fig.update_layout(
title='Multi-Index Comparison',
barmode='group',
yaxis_title='Index Value',
template='plotly_white',
height=400,
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99)
)
return fig
def create_terrain_stats_chart(terrain_stats: Dict[str, float]) -> go.Figure:
"""
Create a chart showing terrain slope distribution.
"""
labels = ['Flat (<5°)', 'Gentle (5-15°)', 'Moderate (15-30°)', 'Steep (>30°)']
values = [
terrain_stats.get('percent_flat', 0),
terrain_stats.get('percent_gentle', 0),
terrain_stats.get('percent_moderate', 0),
terrain_stats.get('percent_steep', 0)
]
colors = ['#1B5E20', '#4CAF50', '#FF9800', '#B71C1C']
fig = go.Figure(data=[go.Pie(
labels=labels, values=values, marker_colors=colors,
hole=0.4, textinfo='label+percent'
)])
fig.update_layout(
title='Slope Distribution',
height=350
)
return fig
def create_land_cover_chart(land_cover_stats: Dict[str, Any]) -> go.Figure:
"""
Create a grouped bar chart showing land cover change.
"""
if 'class_changes' not in land_cover_stats:
return go.Figure()
changes = land_cover_stats['class_changes']
classes = list(changes.keys())
before = [changes[c].get('before', 0) for c in classes]
after = [changes[c].get('after', 0) for c in classes]
# Convert to percentages
total_before = sum(before) or 1
total_after = sum(after) or 1
before_pct = [b / total_before * 100 for b in before]
after_pct = [a / total_after * 100 for a in after]
fig = go.Figure()
fig.add_trace(go.Bar(
name=f"Year {land_cover_stats.get('year_before', 'Before')}",
x=classes, y=before_pct, marker_color='#8B4513'
))
fig.add_trace(go.Bar(
name=f"Year {land_cover_stats.get('year_after', 'After')}",
x=classes, y=after_pct, marker_color='#228B22'
))
fig.update_layout(
title='Land Cover Change',
barmode='group',
yaxis_title='Percentage (%)',
template='plotly_white',
height=400
)
return fig
def create_vegetation_health_chart(stats: Dict[str, float]) -> go.Figure:
"""
Create a chart showing vegetation health distribution.
"""
labels = ['Sparse (0-0.2)', 'Low (0.2-0.4)', 'Moderate (0.4-0.6)', 'Dense (>0.6)']
values = [
stats.get('percent_sparse_veg', 0),
stats.get('percent_low_veg', 0),
stats.get('percent_moderate_veg', 0),
stats.get('percent_dense_veg', 0)
]
colors = ['#D2B48C', '#90EE90', '#228B22', '#006400']
fig = go.Figure(data=[go.Pie(
labels=labels, values=values, marker_colors=colors,
hole=0.4, textinfo='label+percent'
)])
fig.update_layout(
title='Vegetation Health Distribution',
height=350
)
return fig
def create_environmental_indicators_chart(stats: Dict[str, float]) -> go.Figure:
"""
Create a radar chart showing environmental indicators.
"""
categories = ['Vegetation', 'Moisture', 'Soil Stability', 'Water Presence', 'Dense Veg']
# Normalize values to 0-100 scale
values = [
min(100, stats.get('ndvi_after_mean', 0) * 100 / 0.6), # NDVI
max(0, 100 - stats.get('percent_moisture_stressed', 50)), # Moisture health
max(0, 100 - stats.get('percent_bare_soil', 50)), # Soil stability
min(100, stats.get('percent_water', 0) * 10), # Water presence
stats.get('percent_dense_veg', 0) # Dense vegetation
]
fig = go.Figure()
fig.add_trace(go.Scatterpolar(
r=values + [values[0]], # Close the polygon
theta=categories + [categories[0]],
fill='toself',
fillcolor='rgba(46, 125, 50, 0.3)',
line=dict(color='#2E7D32', width=2),
name='Current State'
))
fig.update_layout(
polar=dict(
radialaxis=dict(visible=True, range=[0, 100])
),
title='Environmental Health Indicators',
height=400,
showlegend=False
)
return fig
def create_comprehensive_stats_display(
stats: Dict[str, float],
rehab_score: int,
terrain_stats: Optional[Dict] = None,
land_cover_stats: Optional[Dict] = None
) -> None:
"""
Display comprehensive statistics with all new metrics.
"""
# Rehabilitation Score
st.markdown("### Rehabilitation Score")
score_color = _get_score_color(rehab_score)
st.markdown(f"""
<div style="text-align: center; padding: 20px; background-color: {score_color}20;
border-radius: 10px; margin-bottom: 20px;">
<span style="font-size: 72px; font-weight: bold; color: {score_color};">
{rehab_score}
</span>
<span style="font-size: 24px; color: {score_color};">/100</span>
</div>
""", unsafe_allow_html=True)
st.progress(rehab_score / 100)
# Primary Metrics with tooltips
# Logic:
# - Arrow direction: based on numeric delta (positive=up, negative=down)
# - Color: "normal" = green for increase (good), red for decrease (bad)
# "inverse" = red for increase (bad), green for decrease (good)
st.markdown("### Key Metrics")
col1, col2, col3, col4 = st.columns(4)
# Get change values for proper arrow direction
ndvi_change = stats.get('ndvi_change_mean', 0)
percent_change = stats.get('percent_change', 0)
with col1:
# NDVI: increase = good (green), decrease = bad (red)
st.metric(
"NDVI",
f"{stats.get('ndvi_after_mean', 0):.3f}",
delta=f"{ndvi_change:+.3f}" if ndvi_change != 0 else None,
delta_color="normal", # green for +, red for -
help="Normalized Difference Vegetation Index: measures vegetation health. Values range from -1 to 1, with >0.4 indicating healthy vegetation."
)
with col2:
# Vegetation Change: increase = good (green), decrease = bad (red)
# Use numeric delta for correct arrow direction
st.metric(
"Vegetation Change",
f"{percent_change:+.1f}%",
delta=f"{percent_change:+.1f}%" if percent_change != 0 else None,
delta_color="normal", # green for +, red for -
help="Percentage change in vegetation cover between analysis dates."
)
with col3:
bsi_change = stats.get('bsi_change', 0)
# Bare Soil: increase = bad (red), decrease = good (green)
st.metric(
"Bare Soil",
f"{stats.get('percent_bare_soil', 0):.1f}%",
delta=f"{bsi_change:+.3f}" if bsi_change != 0 else None,
delta_color="inverse", # red for +, green for -
help="Percentage of area with exposed bare soil. Lower values indicate better vegetation cover."
)
with col4:
st.metric(
"Water Presence",
f"{stats.get('percent_water', 0):.1f}%",
help="Percentage of area with water bodies or saturated soil."
)
# Secondary Metrics with tooltips
st.markdown("### Additional Indices")
col1, col2, col3 = st.columns(3)
with col1:
savi_change = stats.get('savi_change', 0)
# SAVI: increase = good (green), decrease = bad (red)
st.metric(
"SAVI",
f"{stats.get('savi_after_mean', 0):.3f}",
delta=f"{savi_change:+.3f}" if savi_change != 0 else None,
delta_color="normal", # green for +, red for -
help="Soil Adjusted Vegetation Index: better for sparse vegetation as it accounts for soil brightness."
)
evi_change = stats.get('evi_change', 0)
# EVI: increase = good (green), decrease = bad (red)
st.metric(
"EVI",
f"{stats.get('evi_after_mean', 0):.3f}",
delta=f"{evi_change:+.3f}" if evi_change != 0 else None,
delta_color="normal", # green for +, red for -
help="Enhanced Vegetation Index: more sensitive in high-biomass areas and corrects for atmospheric effects."
)
with col2:
ndmi_change = stats.get('ndmi_change', 0)
# NDMI: increase = good (green), decrease = bad (red)
st.metric(
"NDMI",
f"{stats.get('ndmi_after_mean', 0):.3f}",
delta=f"{ndmi_change:+.3f}" if ndmi_change != 0 else None,
delta_color="normal", # green for +, red for -
help="Normalized Difference Moisture Index: measures vegetation water content. Higher values = more moisture."
)
bsi_val_change = stats.get('bsi_change', 0)
# BSI: increase = bad (red), decrease = good (green)
st.metric(
"BSI",
f"{stats.get('bsi_after_mean', 0):.3f}",
delta=f"{bsi_val_change:+.3f}" if bsi_val_change != 0 else None,
delta_color="inverse", # red for +, green for -
help="Bare Soil Index: identifies bare soil areas. Higher values indicate more exposed soil (negative for rehab)."
)
with col3:
st.metric(
"Moisture Stressed",
f"{stats.get('percent_moisture_stressed', 0):.1f}%",
help="Percentage of vegetation showing signs of water stress."
)
st.metric(
"Dense Vegetation",
f"{stats.get('percent_dense_veg', 0):.1f}%",
help="Percentage of area with dense, healthy vegetation (NDVI > 0.6)."
)
# Terrain stats if available
if terrain_stats and terrain_stats.get('slope_mean'):
st.markdown("### Terrain Analysis")
col1, col2, col3 = st.columns(3)
with col1:
st.metric("Mean Slope", f"{terrain_stats.get('slope_mean', 0):.1f}°")
with col2:
st.metric("Steep Areas", f"{terrain_stats.get('percent_steep', 0):.1f}%")
with col3:
if 'percent_high_erosion_risk' in terrain_stats:
st.metric("High Erosion Risk", f"{terrain_stats.get('percent_high_erosion_risk', 0):.1f}%",
delta_color="inverse")
# Land cover stats if available
if land_cover_stats and land_cover_stats.get('vegetation_cover_after'):
st.markdown("### Land Cover")
col1, col2 = st.columns(2)
with col1:
st.metric("Vegetation Cover",
f"{land_cover_stats.get('vegetation_cover_after', 0):.1f}%",
delta=f"{land_cover_stats.get('vegetation_cover_change', 0):.1f}%")
with col2:
st.metric("Bare Ground",
f"{land_cover_stats.get('bare_ground_after', 0):.1f}%",
delta=f"{land_cover_stats.get('bare_ground_change', 0):.1f}%",
delta_color="inverse")