File size: 6,615 Bytes
928b74f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | """
explainability/gradcam.py
--------------------------
Grad-CAM implementation for the CNN Branch (EfficientNet-B0).
If the CNN model is untrained or fails, generates a meaningful
image-based saliency heatmap so the visualization is always populated.
"""
import numpy as np
import base64
import io
import cv2
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Helper: encode numpy image to base64 JPEG string
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _encode_b64(img_rgb: np.ndarray) -> str:
"""Encode (H, W, 3) uint8 RGB image to base64 JPEG string."""
bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
_, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, 90])
return base64.b64encode(buf.tobytes()).decode("utf-8")
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Saliency-based fallback (works without any model)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _saliency_heatmap(img_tensor_or_array) -> dict:
"""
Generate a gradient-free saliency/edge prominence heatmap from the
image itself using Laplacian + Sobel, colorized like a Grad-CAM.
This ensures the Grad-CAM panel always shows something informative.
"""
try:
# Accept tf.Tensor or numpy array
try:
arr = img_tensor_or_array.numpy()
except AttributeError:
arr = np.array(img_tensor_or_array)
# Remove batch dim if present
if arr.ndim == 4:
arr = arr[0]
# Convert [0,1] float to uint8
img_u8 = np.clip(arr * 255, 0, 255).astype(np.uint8)
gray = cv2.cvtColor(img_u8, cv2.COLOR_RGB2GRAY)
# Laplacian edge prominence
lap = cv2.Laplacian(gray, cv2.CV_64F)
lap = np.abs(lap)
# Sobel magnitude
sx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
sy = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
sobel = np.sqrt(sx**2 + sy**2)
# Combine & blur for smooth heatmap
combined = 0.5 * lap + 0.5 * sobel
combined = cv2.GaussianBlur(combined, (21, 21), 0)
# Normalize to [0, 255]
mn, mx = combined.min(), combined.max()
if mx > mn:
combined = (combined - mn) / (mx - mn)
heatmap_u8 = np.uint8(255 * combined)
# Apply JET colormap
colored = cv2.applyColorMap(heatmap_u8, cv2.COLORMAP_JET)
colored_rgb = cv2.cvtColor(colored, cv2.COLOR_BGR2RGB)
# Overlay on original image
overlay = cv2.addWeighted(img_u8, 0.5, colored_rgb, 0.5, 0)
return {
"heatmap_b64": _encode_b64(overlay),
"available": True, # show the image in the panel
"note": "Saliency heatmap (CNN weights loading failed β showing edge-based proxy)",
}
except Exception as e:
print(f"[GradCAM] Saliency fallback failed: {e}")
return _fallback_heatmap()
def _fallback_heatmap() -> dict:
"""Minimal fallback β tells frontend CNN is unavailable."""
return {"heatmap_b64": "", "available": False}
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# True Grad-CAM (requires trained CNN model)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def compute_gradcam(feature_model, img_tensor, target_class: int = 1) -> dict:
"""
Compute Grad-CAM heatmap using the CNN branch feature model.
Args:
feature_model : Keras Model returning (conv_output, predictions)
img_tensor : tf.Tensor (1, 224, 224, 3)
target_class : 1 = fake class
Returns:
dict with heatmap_b64, available, overlay_b64
"""
try:
import tensorflow as tf
with tf.GradientTape() as tape:
inputs = tf.cast(img_tensor, tf.float32)
tape.watch(inputs)
conv_outputs, predictions = feature_model(inputs, training=False)
if predictions.shape[-1] == 1:
loss = predictions[:, 0]
else:
loss = predictions[:, target_class]
grads = tape.gradient(loss, conv_outputs)
if grads is None:
raise ValueError("Gradients are None β check feature_model output.")
pooled_grads = tf.reduce_mean(grads, axis=(0, 1, 2))
conv_out = conv_outputs[0]
heatmap = tf.reduce_sum(
conv_out * pooled_grads[tf.newaxis, tf.newaxis, :], axis=-1
)
heatmap = np.maximum(heatmap.numpy(), 0)
if heatmap.max() > 0:
heatmap /= heatmap.max()
return _build_gradcam_output(heatmap, img_tensor)
except Exception as e:
print(f"[GradCAM] compute_gradcam failed: {e}. Falling back to saliency.")
return _saliency_heatmap(img_tensor)
def _build_gradcam_output(heatmap: np.ndarray, img_tensor) -> dict:
"""Resize heatmap, colorize, overlay on original, return base64."""
try:
# Get original image
try:
arr = img_tensor.numpy()
except AttributeError:
arr = np.array(img_tensor)
if arr.ndim == 4:
arr = arr[0]
img_u8 = np.clip(arr * 255, 0, 255).astype(np.uint8)
h, w = img_u8.shape[:2]
# Resize heatmap to image size
hm_resized = cv2.resize(heatmap, (w, h))
hm_u8 = np.uint8(255 * hm_resized)
colored = cv2.applyColorMap(hm_u8, cv2.COLORMAP_JET)
colored_rgb = cv2.cvtColor(colored, cv2.COLOR_BGR2RGB)
overlay = cv2.addWeighted(img_u8, 0.55, colored_rgb, 0.45, 0)
return {
"heatmap_b64": _encode_b64(overlay),
"available": True,
}
except Exception as e:
print(f"[GradCAM] _build_gradcam_output failed: {e}")
return _fallback_heatmap()
|