Spaces:
Runtime error
Runtime error
| """ | |
| utils/gradcam.py | |
| Grad-CAM (Gradient-weighted Class Activation Mapping) implementation. | |
| What is Grad-CAM? | |
| ───────────────── | |
| Grad-CAM creates a heatmap that shows WHICH PARTS of the image the model | |
| focused on when making a prediction. This helps us understand and trust | |
| the model's decisions. | |
| How it works (simplified): | |
| 1. Run image through the model. | |
| 2. Capture the output of the last convolutional layer (a feature map). | |
| 3. Compute how much each feature map channel contributed to the final prediction | |
| by looking at gradients (how much the output changes when feature maps change). | |
| 4. Weight the feature maps by their gradient importance. | |
| 5. Average them to get a single 2D heatmap. | |
| 6. Resize to original image size. | |
| 7. Apply a colormap (red = most important, blue = least important). | |
| 8. Overlay on original image. | |
| This implementation works with ResNet models. | |
| """ | |
| import torch | |
| import torch.nn.functional as F | |
| import numpy as np | |
| from PIL import Image | |
| import cv2 | |
| class GradCAM: | |
| """ | |
| Grad-CAM implementation that hooks into the last convolutional layer. | |
| """ | |
| def __init__(self, model: torch.nn.Module): | |
| """ | |
| Args: | |
| model: A loaded PyTorch model (ResNet architecture). | |
| """ | |
| self.model = model | |
| self.model.eval() | |
| # These will store the captured activations and gradients | |
| self.feature_maps = None | |
| self.gradients = None | |
| # For ResNet: the last conv layer is `layer4` | |
| # We attach "hooks" — functions that run automatically during forward/backward pass | |
| self.target_layer = model.layer4 | |
| # Forward hook: captures the output (feature maps) of layer4 | |
| self.forward_hook = self.target_layer.register_forward_hook( | |
| self._save_feature_maps | |
| ) | |
| # Backward hook: captures the gradients flowing back through layer4 | |
| self.backward_hook = self.target_layer.register_full_backward_hook( | |
| self._save_gradients | |
| ) | |
| def _save_feature_maps(self, module, input, output): | |
| """Called automatically during forward pass. Saves feature maps.""" | |
| self.feature_maps = output.detach() | |
| def _save_gradients(self, module, grad_input, grad_output): | |
| """Called automatically during backward pass. Saves gradients.""" | |
| self.gradients = grad_output[0].detach() | |
| def compute(self, tensor: torch.Tensor, class_idx: int = None) -> np.ndarray: | |
| """ | |
| Compute the Grad-CAM heatmap for the given input tensor. | |
| Args: | |
| tensor: Preprocessed image tensor of shape [1, 3, 224, 224]. | |
| class_idx: Which class to explain. If None, uses predicted class. | |
| Returns: | |
| A 2D numpy array (normalized 0–1) — the heatmap. | |
| """ | |
| device = next(self.model.parameters()).device | |
| tensor = tensor.to(device) | |
| tensor.requires_grad = True | |
| # Forward pass | |
| output = self.model(tensor) # [1, num_classes] | |
| # Use the predicted class if not specified | |
| if class_idx is None: | |
| class_idx = output.argmax(dim=1).item() | |
| # Zero existing gradients | |
| self.model.zero_grad() | |
| # Select the score for the target class and backpropagate | |
| # This tells us how each part of the network contributed to THIS class | |
| class_score = output[0, class_idx] | |
| class_score.backward() | |
| # Compute importance weights: global average pooling over spatial dimensions | |
| # gradients shape: [1, channels, h, w] | |
| weights = self.gradients.mean(dim=(2, 3), keepdim=True) # [1, channels, 1, 1] | |
| # Weighted combination of feature maps | |
| cam = (weights * self.feature_maps).sum(dim=1, keepdim=True) # [1, 1, h, w] | |
| # Apply ReLU — only care about features that positively influence the class | |
| cam = F.relu(cam) | |
| # Normalize to [0, 1] | |
| cam = cam.squeeze().cpu().numpy() # [h, w] | |
| cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8) | |
| return cam | |
| def remove_hooks(self): | |
| """Clean up hooks to prevent memory leaks.""" | |
| self.forward_hook.remove() | |
| self.backward_hook.remove() | |
| def generate_gradcam( | |
| original_image: Image.Image, | |
| model: torch.nn.Module, | |
| tensor: torch.Tensor | |
| ) -> Image.Image: | |
| """ | |
| Generate a Grad-CAM heatmap overlay for a given model and image. | |
| Args: | |
| original_image: The original PIL Image (before preprocessing). | |
| model: The trained PyTorch model. | |
| tensor: The preprocessed tensor [1, 3, 224, 224]. | |
| Returns: | |
| A PIL Image with the heatmap overlaid on the original image. | |
| """ | |
| # Create the Grad-CAM object and compute heatmap | |
| gradcam = GradCAM(model) | |
| try: | |
| cam = gradcam.compute(tensor) | |
| finally: | |
| # Always remove hooks, even if an error occurs | |
| gradcam.remove_hooks() | |
| # Resize the heatmap to match original image size | |
| original_np = np.array(original_image.resize((224, 224))) | |
| cam_resized = cv2.resize(cam, (224, 224)) | |
| # Apply colormap: converts grayscale heatmap to colorful visualization | |
| # COLORMAP_JET: blue (cold/ignored) → green → red (hot/focused) | |
| heatmap_colored = cv2.applyColorMap( | |
| np.uint8(255 * cam_resized), | |
| cv2.COLORMAP_JET | |
| ) | |
| # Convert from BGR (OpenCV default) to RGB (PIL/Streamlit default) | |
| heatmap_rgb = cv2.cvtColor(heatmap_colored, cv2.COLOR_BGR2RGB) | |
| # Blend: 60% original image + 40% heatmap | |
| overlay = cv2.addWeighted(original_np, 0.6, heatmap_rgb, 0.4, 0) | |
| # Convert numpy array back to PIL Image for Streamlit display | |
| return Image.fromarray(overlay) | |