mmer-backend / backend /services /explainability.py
Nishvaraj's picture
deploy MMER FastAPI backend on HF Spaces
02fcea5
Raw
History Blame Contribute Delete
10.8 kB
"""Explainability utilities for multimodal emotion recognition outputs."""
import os
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "0"
os.environ["QT_QPA_PLATFORM"] = "offscreen"
import torch
import torch.nn as nn
import numpy as np
import cv2
import librosa
import librosa.display
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from PIL import Image
from io import BytesIO
import base64
from pytorch_grad_cam import GradCAM, EigenCAM
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
from pytorch_grad_cam.utils.reshape_transforms import vit_reshape_transform
# ==================== MODEL WRAPPER ====================
class ViTLogitsWrapper(nn.Module):
def __init__(self, model):
super().__init__()
self.model = model
def forward(self, x):
# Grad-CAM expects a standard forward() that returns logits for the selected class.
return self.model(pixel_values=x).logits
# ==================== FACIAL EXPLAINABILITY ====================
def generate_grad_cam(image, model, processor, emotion_idx, emotions_list, device):
try:
img_rgb = np.array(image.convert('RGB'))
h, w = img_rgb.shape[:2]
img_pil = Image.fromarray(img_rgb)
inputs = processor(img_pil, return_tensors='pt').to(device)
input_tensor = inputs['pixel_values']
wrapped_model = ViTLogitsWrapper(model)
wrapped_model.eval()
# Try multiple layers because the last block can become too saturated for a usable heatmap.
layers_to_try = [
model.vit.encoder.layer[-1].layernorm_after,
model.vit.encoder.layer[-2].layernorm_after,
model.vit.encoder.layer[-3].layernorm_after,
]
cam_map = None
method_used = None
for i, layer in enumerate(layers_to_try):
try:
cam = GradCAM(
model=wrapped_model,
target_layers=[layer],
reshape_transform=vit_reshape_transform,
)
targets = [ClassifierOutputTarget(emotion_idx)]
grayscale_cam = cam(input_tensor=input_tensor, targets=targets)
result = grayscale_cam[0]
# Reject degenerate maps so the UI never shows a blank explanation as if it were valid.
if result.max() > 0.01:
cam_map = result
method_used = f"GradCAM (encoder block {12 - (i+1)})"
break
else:
print(f"[explainability] layer[-{i+1}] all zeros, trying next")
except Exception as e:
print(f"[explainability] GradCAM layer[-{i+1}] failed: {e}")
# Final fallback: EigenCAM gives a stable PCA-based map when gradients are unhelpful.
if cam_map is None:
print("[explainability] All GradCAM layers zero, using EigenCAM")
try:
eigen = EigenCAM(
model=wrapped_model,
target_layers=[model.vit.encoder.layer[-1].layernorm_after],
reshape_transform=vit_reshape_transform,
)
grayscale_cam = eigen(input_tensor=input_tensor)
cam_map = grayscale_cam[0]
method_used = "EigenCAM"
except Exception as e:
print(f"[explainability] EigenCAM failed: {e}")
return None, None
print(f"[explainability] {method_used} — min={cam_map.min():.3f}, max={cam_map.max():.3f}")
# Upscale and smooth the heatmap so it overlays cleanly on the source image.
cam_resized = cv2.resize(cam_map.astype(np.float32), (w, h), interpolation=cv2.INTER_CUBIC)
cam_resized = cv2.GaussianBlur(cam_resized, (13, 13), 0)
c_min, c_max = cam_resized.min(), cam_resized.max()
if c_max > c_min:
cam_resized = (cam_resized - c_min) / (c_max - c_min)
# Build the colored overlay and blend only the most salient regions.
cam_uint8 = np.uint8(255 * cam_resized)
heatmap_bgr = cv2.applyColorMap(cam_uint8, cv2.COLORMAP_JET)
heatmap_rgb = cv2.cvtColor(heatmap_bgr, cv2.COLOR_BGR2RGB)
threshold = np.percentile(cam_resized, 70)
blend_mask = (cam_resized > threshold).astype(np.float32)
blend_mask = cv2.GaussianBlur(blend_mask, (31, 31), 0)[..., None]
blended = (
(1 - blend_mask * 0.65) * img_rgb.astype(np.float32)
+ blend_mask * 0.65 * heatmap_rgb.astype(np.float32)
).clip(0, 255).astype(np.uint8)
orig_buf = BytesIO()
Image.fromarray(img_rgb).save(orig_buf, format='PNG')
orig_b64 = base64.b64encode(orig_buf.getvalue()).decode()
blend_buf = BytesIO()
Image.fromarray(blended).save(blend_buf, format='PNG')
blend_b64 = base64.b64encode(blend_buf.getvalue()).decode()
return orig_b64, blend_b64
except Exception as e:
print(f"[explainability] GradCAM generation failed: {e}")
return None, None
# ==================== AUDIO EXPLAINABILITY ====================
def generate_audio_saliency(audio, model, processor, emotion_idx, emotions_list, device, sr=16000):
try:
if audio is None or len(audio) == 0:
raise ValueError("Audio input is empty")
# Sanitize the audio before passing it into the speech backbone.
audio = np.asarray(audio, dtype=np.float32)
audio = np.nan_to_num(audio)
inputs = processor(audio, sampling_rate=sr, return_tensors="pt", padding=True)
input_values = inputs['input_values'].to(device)
input_values.requires_grad = True
model.zero_grad()
outputs = model(input_values)
score = outputs.logits[0, emotion_idx]
score.backward()
if input_values.grad is None:
raise RuntimeError("No gradients captured")
saliency = torch.abs(input_values.grad).cpu().detach().numpy()
if saliency.ndim == 3:
saliency = np.mean(saliency, axis=1)[0]
elif saliency.ndim == 2:
saliency = saliency[0]
saliency = saliency.reshape(-1).astype(np.float32)
if saliency.size > 11:
# Smooth the gradient spikes so the curve is readable in the plot.
kernel = np.ones(11, dtype=np.float32) / 11.0
saliency = np.convolve(saliency, kernel, mode='same')
s_min, s_max = saliency.min(), saliency.max()
if s_max > s_min:
saliency = (saliency - s_min) / (s_max - s_min)
else:
saliency = np.zeros_like(saliency)
# Build both the spectrogram and the saliency overlay for a side-by-side explanation.
S = librosa.feature.melspectrogram(y=audio, sr=sr, n_mels=128)
S_db = librosa.power_to_db(S, ref=np.max)
fig1, ax1 = plt.subplots(figsize=(10, 4), dpi=100)
img1 = librosa.display.specshow(S_db, sr=sr, x_axis='time', y_axis='mel', ax=ax1)
ax1.set_title(f'Audio Spectrogram — {emotions_list[emotion_idx]}')
fig1.colorbar(img1, ax=ax1, format='%+2.0f dB')
spec_buf = BytesIO()
fig1.savefig(spec_buf, format='PNG', bbox_inches='tight', dpi=100)
spec_b64 = base64.b64encode(spec_buf.getvalue()).decode()
plt.close(fig1)
fig2, (ax2, ax3) = plt.subplots(2, 1, figsize=(10, 5.5), dpi=100,
gridspec_kw={'height_ratios': [3, 1]}, sharex=False)
# Normalize the spectrogram so the saliency colors stay visible across different recordings.
S_norm = (S_db - S_db.min()) / max(S_db.max() - S_db.min(), 1e-8)
sal_resized = np.interp(np.linspace(0, 1, S_db.shape[1]),
np.linspace(0, 1, saliency.shape[0]), saliency)
sal_map = np.tile(sal_resized, (S_db.shape[0], 1))
ax2.imshow(S_norm, aspect='auto', origin='lower', cmap='viridis', interpolation='bilinear')
ax2.imshow(sal_map, aspect='auto', origin='lower', cmap='magma', alpha=0.6, interpolation='bilinear')
ax2.set_title(f'Audio Saliency — {emotions_list[emotion_idx]} (bright = important)')
ax2.set_ylabel('Mel Frequency')
# Highlight the strongest time steps to give the user a clear peak view.
peak_thr = np.percentile(sal_resized, 85)
x = np.arange(len(sal_resized))
ax3.plot(x, sal_resized, color='#f97316', linewidth=1.5)
ax3.fill_between(x, 0, sal_resized, where=sal_resized >= peak_thr, color='#ef4444', alpha=0.4)
ax3.axhline(peak_thr, color='#ef4444', linestyle='--', linewidth=1, alpha=0.8)
ax3.set_ylim(0, 1.05)
ax3.set_ylabel('Saliency')
ax3.set_xlabel('Time steps')
ax3.grid(alpha=0.2)
sal_buf = BytesIO()
fig2.tight_layout()
fig2.savefig(sal_buf, format='PNG', bbox_inches='tight', dpi=100)
sal_b64 = base64.b64encode(sal_buf.getvalue()).decode()
plt.close(fig2)
return spec_b64, sal_b64
except Exception as e:
print(f"[explainability] Audio saliency failed: {e}")
return None, None
# ==================== COMBINED VISUALIZATION ====================
def create_combined_visualization(grad_cam_base64, saliency_base64, facial_emotion, speech_emotion, concordance):
try:
# Use a soft status tint so the combined report communicates agreement at a glance.
bg_color = '#d4edda' if concordance == 'MATCH' else '#f8d7da'
html = f"""
<div style="display:flex;gap:20px;padding:20px;background:#f5f5f5;border-radius:10px;">
<div style="flex:1;">
<h3>Facial GradCAM — {facial_emotion}</h3>
<img src="data:image/png;base64,{grad_cam_base64}" style="width:100%;border-radius:8px;">
<p style="font-size:12px;color:#666;">Red/warm = regions that most influenced the {facial_emotion} prediction.</p>
</div>
<div style="flex:1;">
<h3>Speech Saliency — {speech_emotion}</h3>
<img src="data:image/png;base64,{saliency_base64}" style="width:100%;border-radius:8px;">
<p style="font-size:12px;color:#666;">Bright = time-frequency regions with strongest influence.</p>
</div>
</div>
<div style="margin-top:20px;padding:15px;background:{bg_color};border-radius:8px;text-align:center;">
<h4 style="margin:0;">Concordance: <strong>{concordance}</strong></h4>
</div>
"""
return base64.b64encode(html.encode()).decode()
except Exception as e:
print(f"[explainability] Combined visualisation failed: {e}")
return None