| """ |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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") |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| |
| try: |
| arr = img_tensor_or_array.numpy() |
| except AttributeError: |
| arr = np.array(img_tensor_or_array) |
|
|
| |
| if arr.ndim == 4: |
| arr = arr[0] |
|
|
| |
| img_u8 = np.clip(arr * 255, 0, 255).astype(np.uint8) |
| gray = cv2.cvtColor(img_u8, cv2.COLOR_RGB2GRAY) |
|
|
| |
| lap = cv2.Laplacian(gray, cv2.CV_64F) |
| lap = np.abs(lap) |
|
|
| |
| 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) |
|
|
| |
| combined = 0.5 * lap + 0.5 * sobel |
| combined = cv2.GaussianBlur(combined, (21, 21), 0) |
|
|
| |
| mn, mx = combined.min(), combined.max() |
| if mx > mn: |
| combined = (combined - mn) / (mx - mn) |
| heatmap_u8 = np.uint8(255 * combined) |
|
|
| |
| colored = cv2.applyColorMap(heatmap_u8, cv2.COLORMAP_JET) |
| colored_rgb = cv2.cvtColor(colored, cv2.COLOR_BGR2RGB) |
|
|
| |
| overlay = cv2.addWeighted(img_u8, 0.5, colored_rgb, 0.5, 0) |
|
|
| return { |
| "heatmap_b64": _encode_b64(overlay), |
| "available": True, |
| "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} |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| |
| 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] |
|
|
| |
| 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() |
|
|